Skip to content

Commit 9da88df

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 6b5178f commit 9da88df

File tree

6 files changed

+101
-3
lines changed

6 files changed

+101
-3
lines changed

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

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

196196
constructor(asyncLoaderHooks) {
197197
this.#setAsyncLoaderHooks(asyncLoaderHooks);
198+
this.importParents = new Map();
198199
}
199200

200201
/**

β€Ž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)