Skip to content

Commit a56e771

Browse files
committed
esm: add import trace for evaluation errors
Errors thrown during ESM module evaluation often do not show how the failing module was reached via imports, making it hard to understand why it was loaded. This change appends an "Import trace" section to the formatted error stack for evaluation-time ESM errors. The trace is derived from the loader’s import graph and shows the chain of modules leading to the failure. The implementation preserves existing stack formatting and source map handling, and is limited to module evaluation only. A new test verifies that the expected import chain is included. Refs: #46992
1 parent f6464c5 commit a56e771

6 files changed

Lines changed: 101 additions & 3 deletions

File tree

β€Žlib/internal/modules/esm/loader.jsβ€Ž

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,7 @@ class ModuleLoader {
211211

212212
constructor(asyncLoaderHooks) {
213213
this.#setAsyncLoaderHooks(asyncLoaderHooks);
214+
this.importParents = new Map();
214215
}
215216

216217
/**

β€Žlib/internal/modules/esm/module_job.jsβ€Ž

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,73 @@ let debug = require('internal/util/debuglog').debuglog('esm', (fn) => {
2323
debug = fn;
2424
});
2525

26+
const {
27+
overrideStackTrace,
28+
ErrorPrepareStackTrace,
29+
codes,
30+
} = require('internal/errors');
31+
32+
const { ERR_REQUIRE_ASYNC_MODULE } = codes;
33+
34+
/**
35+
* Builds a linear import trace by walking parent modules
36+
* from the module that threw during evaluation.
37+
*/
38+
function buildImportTrace(importParents, startURL) {
39+
const trace = [];
40+
let current = startURL;
41+
const seen = new Set([current]);
42+
43+
while (true) {
44+
const parent = importParents.get(current);
45+
if (!parent || seen.has(parent)) break;
46+
47+
trace.push({ child: current, parent });
48+
seen.add(current);
49+
current = parent;
50+
}
51+
52+
return trace.length ? trace : null;
53+
}
54+
55+
/**
56+
* Formats an import trace for inclusion in an error stack.
57+
*/
58+
function formatImportTrace(trace) {
59+
return trace
60+
.map(({ child, parent }) => ` ${child} imported by ${parent}`)
61+
.join('\n');
62+
}
63+
64+
/**
65+
* Appends an ESM import trace to an error’s stack output.
66+
* Uses a per-error stack override; no global side effects.
67+
*/
68+
function decorateErrorWithImportTrace(e, importParents) {
69+
if (!importParents || typeof importParents.get !== 'function') return;
70+
if (!e || typeof e !== 'object') return;
71+
72+
overrideStackTrace.set(e, (error, trace) => {
73+
let thrownURL;
74+
for (const cs of trace) {
75+
const getFileName = cs.getFileName;
76+
if (typeof getFileName === 'function') {
77+
const file = getFileName.call(cs);
78+
if (typeof file === 'string' && file.startsWith('file://')) {
79+
thrownURL = file;
80+
break;
81+
}
82+
}
83+
}
84+
85+
const importTrace = thrownURL ? buildImportTrace(importParents, thrownURL) : null;
86+
const stack = ErrorPrepareStackTrace(error, trace);
87+
if (!importTrace) return stack;
88+
89+
return `${stack}\n\nImport trace:\n${formatImportTrace(importTrace)}`;
90+
});
91+
}
92+
2693
const {
2794
ModuleWrap,
2895
kErrored,
@@ -53,9 +120,6 @@ const {
53120
} = require('internal/modules/helpers');
54121
const { getOptionValue } = require('internal/options');
55122
const noop = FunctionPrototype;
56-
const {
57-
ERR_REQUIRE_ASYNC_MODULE,
58-
} = require('internal/errors').codes;
59123
let hasPausedEntry = false;
60124

61125
const CJSGlobalLike = [
@@ -159,6 +223,7 @@ class ModuleJobBase {
159223
// that hooks can pre-fetch sources off-thread.
160224
const job = this.loader.getOrCreateModuleJob(this.url, request, requestType);
161225
debug(`ModuleJobBase.syncLink() ${this.url} -> ${request.specifier}`, job);
226+
this.loader.importParents.set(job.url, this.url);
162227
assert(!isPromise(job));
163228
assert(job.module instanceof ModuleWrap);
164229
if (request.phase === kEvaluationPhase) {
@@ -430,6 +495,9 @@ class ModuleJob extends ModuleJobBase {
430495
await this.module.evaluate(timeout, breakOnSigint);
431496
} catch (e) {
432497
explainCommonJSGlobalLikeNotDefinedError(e, this.module.url, this.module.hasTopLevelAwait);
498+
499+
decorateErrorWithImportTrace(e, this.loader.importParents);
500+
433501
throw e;
434502
}
435503
return { __proto__: null, module: this.module };
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { spawnSync } from 'node:child_process';
2+
import assert from 'node:assert';
3+
import { fileURLToPath } from 'node:url';
4+
import path from 'node:path';
5+
import { test } from 'node:test';
6+
7+
const __filename = fileURLToPath(import.meta.url);
8+
const __dirname = path.dirname(__filename);
9+
10+
const fixture = path.join(
11+
__dirname,
12+
'../fixtures/es-modules/import-trace/entry.mjs'
13+
);
14+
15+
test('includes import trace for evaluation-time errors', () => {
16+
const result = spawnSync(
17+
process.execPath,
18+
[fixture],
19+
{ encoding: 'utf8' }
20+
);
21+
22+
assert.notStrictEqual(result.status, 0);
23+
assert.match(result.stderr, /Import trace:/);
24+
assert.match(result.stderr, /bar\.mjs imported by .*foo\.mjs/);
25+
assert.match(result.stderr, /foo\.mjs imported by .*entry\.mjs/);
26+
});
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
throw new Error('bar failed');
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
import './foo.mjs';
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
import './bar.mjs';

0 commit comments

Comments
Β (0)