From d117c74ff7e7a377c4eb244bf1b9d59546db96bd Mon Sep 17 00:00:00 2001 From: Matt Skelley Date: Sun, 1 Feb 2026 19:22:02 +0800 Subject: [PATCH 1/2] 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 37eb267e154cc7..64b8a2191a0685 100644 --- a/lib/internal/modules/esm/loader.js +++ b/lib/internal/modules/esm/loader.js @@ -195,6 +195,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 From 7888512e0afa4e95e23c2e0b48c7a1c9487cbb9f Mon Sep 17 00:00:00 2001 From: Matt Skelley Date: Tue, 24 Feb 2026 20:49:48 +0800 Subject: [PATCH 2/2] esm: Fix linting errors and test coverage Remove importParents guard. Use primordials instead. --- lib/internal/modules/esm/loader.js | 3 ++- lib/internal/modules/esm/module_job.js | 27 ++++++++++++++++---------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/lib/internal/modules/esm/loader.js b/lib/internal/modules/esm/loader.js index 64b8a2191a0685..53ea9916c6b903 100644 --- a/lib/internal/modules/esm/loader.js +++ b/lib/internal/modules/esm/loader.js @@ -10,6 +10,7 @@ const { Promise, PromisePrototypeThen, RegExpPrototypeSymbolReplace, + SafeMap, encodeURIComponent, hardenRegExp, } = primordials; @@ -195,7 +196,7 @@ class ModuleLoader { constructor(asyncLoaderHooks) { this.#setAsyncLoaderHooks(asyncLoaderHooks); - this.importParents = new Map(); + this.importParents = new SafeMap(); } /** diff --git a/lib/internal/modules/esm/module_job.js b/lib/internal/modules/esm/module_job.js index 5b0c38ed5bf20b..f0e6a49c6ede5b 100644 --- a/lib/internal/modules/esm/module_job.js +++ b/lib/internal/modules/esm/module_job.js @@ -24,25 +24,28 @@ let debug = require('internal/util/debuglog').debuglog('esm', (fn) => { }); const { - overrideStackTrace, ErrorPrepareStackTrace, - codes, + codes: { + ERR_REQUIRE_ASYNC_MODULE, + }, + overrideStackTrace, } = 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. + * @returns {Array<{child: string, parent: string}>|null} */ function buildImportTrace(importParents, startURL) { const trace = []; let current = startURL; - const seen = new Set([current]); + const seen = new SafeSet([current]); while (true) { const parent = importParents.get(current); - if (!parent || seen.has(parent)) break; + if (!parent || seen.has(parent)) { + break; + } trace.push({ child: current, parent }); seen.add(current); @@ -54,6 +57,7 @@ function buildImportTrace(importParents, startURL) { /** * Formats an import trace for inclusion in an error stack. + * @returns {string} */ function formatImportTrace(trace) { return trace @@ -62,12 +66,13 @@ function formatImportTrace(trace) { } /** - * Appends an ESM import trace to an error’s stack output. + * 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; + if (!e || typeof e !== 'object') { + return; + } overrideStackTrace.set(e, (error, trace) => { let thrownURL; @@ -84,7 +89,9 @@ function decorateErrorWithImportTrace(e, importParents) { const importTrace = thrownURL ? buildImportTrace(importParents, thrownURL) : null; const stack = ErrorPrepareStackTrace(error, trace); - if (!importTrace) return stack; + if (!importTrace) { + return stack; + } return `${stack}\n\nImport trace:\n${formatImportTrace(importTrace)}`; });