Error: Import failed 指向了一行创建错误对象的代码。打开这一行,你能看见应用如何报告失败。要找到真正失败的操作,还需要沿着附带的 cause 找到解析器,再检查传给它的输入。

下载完整的 JavaScript 错误堆栈练习,解压后进入 read-javascript-stack-trace 目录。示例只使用本地文件和 Node 内置模块,无需安装依赖,也不发起网络请求。这是为教学构造的例子:导入步骤只读取并解析记录,不把结果保存到其他地方。下文命令实际运行于 Node.js 22.22.1;练习要求 Node.js 22 或更新版本。

三个源码文件组成了需要调查的路径:

文件 执行的工作 需要核对的证据
run.mjs 选择输入,等待导入结果 输入文件名、结果、退出码
importer.mjs 读取文件,调用解析器,包装失败 try 分支和捕获的 cause
parser.mjs 把文本交给 JSON.parse 解析调用及收到的文本

先复现失败,把两份堆栈都留下

node run.mjs with-cause

这个命令预期失败,退出码为 1。下面合并展示实际运行的标准输出和错误输出,仅把绝对目录前缀替换成了 file:///lab/;文件名、行列号和错误文字都来自这次运行。

Input: records.bad.json
Error: Import failed
    at importRecords (file:///lab/importer.mjs:10:19)
    at async file:///lab/run.mjs:14:19

Caused by:
SyntaxError: Expected double-quoted property name in JSON at position 29 (line 2 column 28)
    at JSON.parse (<anonymous>)
    at parseRecords (file:///lab/parser.mjs:2:15)
    at importRecords (file:///lab/importer.mjs:7:12)
    at async file:///lab/run.mjs:14:19

run.mjs 分别打印了 error.stack 和 error.cause.stack。第一份属于外层包装错误,第二份属于被捕获的 SyntaxError。复制错误报告时,要保留这个对应关系。如果日志只留下外层消息,这个例子里大部分有用信息就丢了。

importer.mjs:10:19 最后的两个数字是源码行号和列号。解析错误消息里的“line 2 column 28”则指向 JSON 输入。它们是两套不同的位置坐标,要分别对上相应文件或值,并使用产生这份堆栈的源码版本。

打开构造错误的位置,再读完整 catch 分支

下载包中的 importer.mjs 全文如下:

import { readFile } from 'node:fs/promises';
import { parseRecords } from './parser.mjs';

export async function importRecords(input, { preserveCause = true } = {}) {
  try {
    const text = await readFile(input, 'utf8');
    return parseRecords(text);
  } catch (cause) {
    const options = preserveCause ? { cause } : undefined;
    const error = new Error('Import failed', options);
    throw error;
  }
}

第 10 行构造外层错误,第 11 行才抛出已有的对象。外层堆栈从第 10 行开始,因为 Node 记录的是调用 new Error() 的位置。稍后再抛出同一个对象,不会把捕获的位置移到 throw 那一行。Node.js 22 的错误文档说明了这个行为。

还要看 try 包住了什么:readFile 和 parseRecords 都可能失败,两种失败都会被包装成相同消息。只凭“Import failed”无法判断是哪一步出了问题。例如,练习中的缺失文件测试得到的是 ENOENT cause,而不是 SyntaxError。

沿 cause 找到解析器,再找到实际输入

cause 堆栈的第一项是 JSON.parse。继续看你能打开的第一处应用代码:parser.mjs:2:15。

export function parseRecords(text) {
  return JSON.parse(text);
}

再把 text 追到 readFile(input, 'utf8')。run.mjs 选择的是 records.bad.json,它的完整内容是:

[
  {"id": "tea", "count": 2,}
]

2 后面的逗号让解析器继续等待下一个属性名,却在报告的位置遇到了 }。现在你有了具体输入、失败操作和可重复的结果。外层错误的构造行给了调查入口;修改那条错误消息不会改变这份 JSON。

new Error(message, { cause }) 会让新错误保留对捕获值的访问。Node 从 16.9.0 开始支持这个选项,本例使用的 22.22.1 文档也包含它。cause 可以是任意值,通用错误处理代码读取其 .stack 前应先检查类型;本例打印器使用 instanceof Error。详见 Node 的 error.cause 说明。

去掉 cause,看看丢失了什么

node run.mjs without-cause

它同样以退出码 1 结束。相同输入仍然失败,但输出现在只剩下这份错误信息:

Error: Import failed
    at importRecords (file:///lab/importer.mjs:10:19)
    at async file:///lab/run.mjs:14:19

Cause: unavailable

这个模式没有把 cause 选项传给 new Error,因此外层错误不再携带解析错误。从它的堆栈中无法找回被丢弃的输入位置,也无法把本次失败与缺失文件分支区分开。你需要补充观察,例如在本地保留原始错误后再次复现。

保留 cause 能改善调查条件,但不会修正输入:第一个 with-cause 命令仍然失败。

把异步帧当线索,再用代码补齐路径

两份实测堆栈都出现了 at async .../run.mjs:14:19,对应等待结果的调用方。解析失败时,readFile 已经完成。它的工作没有出现在这份堆栈里,却为解析器提供了文本。

V8 的异步堆栈说明描述了围绕 await 和部分 Promise 操作恢复异步帧的能力,包括 Promise.all() 与 Promise.any()。这些帧只覆盖部分异步关系,不是所有回调、定时器和 I/O 的完整执行历史。少了一帧,也不能证明对应工作没有发生。

Node/V8 版本可能影响 SyntaxError 的措辞和可见帧。按照实际显示的位置打开对应源码,不要把本文输出当成所有环境都相同的格式。练习里的测试检查错误属性和有用的应用帧,没有把完整堆栈字符串写死。

用输入检验“重试就好”的解释

考虑这样一个说法:“这是临时网络问题,重试应该就能恢复。”这里把它作为需要验证的教学假设,没有把它当成真实 AI 输出或线上故障记录。

堆栈给出的是 JSON.parse 处的 SyntaxError,源码说明解析器读到的字节来自本地文件。这个练习没有网络操作;同一份坏输入连续尝试三次,都仍然失败。这些证据不支持上述解释。

现在运行修正后的输入;它只删除了多余的尾逗号:

node run.mjs fixed

命令退出码为 0,输出为:

Input: records.fixed.json
Parsed records: [{"id":"tea","count":2}]

再运行回归检查:

node --test importer.test.mjs

九个测试全部通过,覆盖保留解析 cause、丢失 cause 的包装、相同输入重试失败、修正输入成功、缺失文件的不同 cause、构造位置与稍后抛出的位置,以及三种 CLI 模式。两个失败演示命令是预期结果,不表示回归测试失败。

这些结果解释了本例的本地输入。调查真实导入流程时,还要继续读产生异常文本的代码,再判断文本为什么不合法。这个练习只验证 JSON 语法和示例结果,不检查记录 schema,也不保存导入数据。

在 Yenpo 中读上下文,在终端里验证

在 Yenpo 打开解压后的目录,按照代码阅读指南定位 importer.mjs、parser.mjs 和选中的输入。先看报告的行号,再读完整 catch 分支,以及提供 input 的调用方。

配置模型后,可以让 Yenpo AI Reader帮助检查这条路径。一个具体的请求是:

从 Import failed 的构造位置沿 cause 追到传给 JSON.parse 的输入,引用对应文件和行号。区分堆栈直接显示了什么、源码能证明什么,以及哪些结论还需要在终端运行确认。检查本例中重试能否改变输入字节。

在终端运行命令,对照输出检查解释。