From a56e7710c98a6bddf0b1c74a879ee0c3a697f719 Mon Sep 17 00:00:00 2001 From: Matt Skelley Date: Sun, 1 Feb 2026 19:22:02 +0800 Subject: [PATCH] esm: add import trace for evaluation errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/internal/modules/esm/loader.js | 1 + lib/internal/modules/esm/module_job.js | 74 ++++++++++++++++++- test/es-module/test-esm-import-trace.mjs | 26 +++++++ test/fixtures/es-modules/import-trace/bar.mjs | 1 + .../es-modules/import-trace/entry.mjs | 1 + test/fixtures/es-modules/import-trace/foo.mjs | 1 + 6 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 test/es-module/test-esm-import-trace.mjs create mode 100644 test/fixtures/es-modules/import-trace/bar.mjs create mode 100644 test/fixtures/es-modules/import-trace/entry.mjs create mode 100644 test/fixtures/es-modules/import-trace/foo.mjs diff --git a/lib/internal/modules/esm/loader.js b/lib/internal/modules/esm/loader.js index 0bff0763fcf58f..cb0ee6d252627d 100644 --- a/lib/internal/modules/esm/loader.js +++ b/lib/internal/modules/esm/loader.js @@ -211,6 +211,7 @@ class ModuleLoader { constructor(asyncLoaderHooks) { this.#setAsyncLoaderHooks(asyncLoaderHooks); + this.importParents = new Map(); } /** diff --git a/lib/internal/modules/esm/module_job.js b/lib/internal/modules/esm/module_job.js index 929577c0da6d08..5b0c38ed5bf20b 100644 --- a/lib/internal/modules/esm/module_job.js +++ b/lib/internal/modules/esm/module_job.js @@ -23,6 +23,73 @@ let debug = require('internal/util/debuglog').debuglog('esm', (fn) => { debug = fn; }); +const { + overrideStackTrace, + ErrorPrepareStackTrace, + codes, +} = require('internal/errors'); + +const { ERR_REQUIRE_ASYNC_MODULE } = codes; + +/** + * Builds a linear import trace by walking parent modules + * from the module that threw during evaluation. + */ +function buildImportTrace(importParents, startURL) { + const trace = []; + let current = startURL; + const seen = new Set([current]); + + while (true) { + const parent = importParents.get(current); + if (!parent || seen.has(parent)) break; + + trace.push({ child: current, parent }); + seen.add(current); + current = parent; + } + + return trace.length ? trace : null; +} + +/** + * Formats an import trace for inclusion in an error stack. + */ +function formatImportTrace(trace) { + return trace + .map(({ child, parent }) => ` ${child} imported by ${parent}`) + .join('\n'); +} + +/** + * Appends an ESM import trace to an error’s stack output. + * Uses a per-error stack override; no global side effects. + */ +function decorateErrorWithImportTrace(e, importParents) { + if (!importParents || typeof importParents.get !== 'function') return; + if (!e || typeof e !== 'object') return; + + overrideStackTrace.set(e, (error, trace) => { + let thrownURL; + for (const cs of trace) { + const getFileName = cs.getFileName; + if (typeof getFileName === 'function') { + const file = getFileName.call(cs); + if (typeof file === 'string' && file.startsWith('file://')) { + thrownURL = file; + break; + } + } + } + + const importTrace = thrownURL ? buildImportTrace(importParents, thrownURL) : null; + const stack = ErrorPrepareStackTrace(error, trace); + if (!importTrace) return stack; + + return `${stack}\n\nImport trace:\n${formatImportTrace(importTrace)}`; + }); +} + const { ModuleWrap, kErrored, @@ -53,9 +120,6 @@ const { } = require('internal/modules/helpers'); const { getOptionValue } = require('internal/options'); const noop = FunctionPrototype; -const { - ERR_REQUIRE_ASYNC_MODULE, -} = require('internal/errors').codes; let hasPausedEntry = false; const CJSGlobalLike = [ @@ -159,6 +223,7 @@ class ModuleJobBase { // that hooks can pre-fetch sources off-thread. const job = this.loader.getOrCreateModuleJob(this.url, request, requestType); debug(`ModuleJobBase.syncLink() ${this.url} -> ${request.specifier}`, job); + this.loader.importParents.set(job.url, this.url); assert(!isPromise(job)); assert(job.module instanceof ModuleWrap); if (request.phase === kEvaluationPhase) { @@ -430,6 +495,9 @@ class ModuleJob extends ModuleJobBase { await this.module.evaluate(timeout, breakOnSigint); } catch (e) { explainCommonJSGlobalLikeNotDefinedError(e, this.module.url, this.module.hasTopLevelAwait); + + decorateErrorWithImportTrace(e, this.loader.importParents); + throw e; } return { __proto__: null, module: this.module }; diff --git a/test/es-module/test-esm-import-trace.mjs b/test/es-module/test-esm-import-trace.mjs new file mode 100644 index 00000000000000..0a33f2e9f88c9a --- /dev/null +++ b/test/es-module/test-esm-import-trace.mjs @@ -0,0 +1,26 @@ +import { spawnSync } from 'node:child_process'; +import assert from 'node:assert'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; +import { test } from 'node:test'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const fixture = path.join( + __dirname, + '../fixtures/es-modules/import-trace/entry.mjs' +); + +test('includes import trace for evaluation-time errors', () => { + const result = spawnSync( + process.execPath, + [fixture], + { encoding: 'utf8' } + ); + + assert.notStrictEqual(result.status, 0); + assert.match(result.stderr, /Import trace:/); + assert.match(result.stderr, /bar\.mjs imported by .*foo\.mjs/); + assert.match(result.stderr, /foo\.mjs imported by .*entry\.mjs/); +}); \ No newline at end of file diff --git a/test/fixtures/es-modules/import-trace/bar.mjs b/test/fixtures/es-modules/import-trace/bar.mjs new file mode 100644 index 00000000000000..8d48f71d57cbde --- /dev/null +++ b/test/fixtures/es-modules/import-trace/bar.mjs @@ -0,0 +1 @@ +throw new Error('bar failed'); diff --git a/test/fixtures/es-modules/import-trace/entry.mjs b/test/fixtures/es-modules/import-trace/entry.mjs new file mode 100644 index 00000000000000..a63434dddb1bb6 --- /dev/null +++ b/test/fixtures/es-modules/import-trace/entry.mjs @@ -0,0 +1 @@ +import './foo.mjs'; diff --git a/test/fixtures/es-modules/import-trace/foo.mjs b/test/fixtures/es-modules/import-trace/foo.mjs new file mode 100644 index 00000000000000..118fb781654638 --- /dev/null +++ b/test/fixtures/es-modules/import-trace/foo.mjs @@ -0,0 +1 @@ +import './bar.mjs'; \ No newline at end of file