Skip to content

Commit 81c6c88

Browse files
robertsLandoclaude
andauthored
fix(detector): stop silent dependency drops in SEA mode (ESM parse, dynamic import, decorators) (#268)
* fix(sea): parse ESM files as modules and walk dynamic import() literals `detector.parse()` called babel with the default `sourceType: 'script'`, so SEA-mode walker runs over `import.meta` / top-level `await` failed to parse and silently skipped the file's dependency traversal. Thread `isESMFile(record.file)` through `stepDetect` → `detect()` → `parse()` so ESM files get `sourceType: 'module'`. Also teach the visitor to recognize `import('literal')` `CallExpression`s so bundler-emitted dynamic imports are bundled like static ones. * docs(detector): add concise jsdoc for visitor and helper functions * fix(detector): enable decorators-legacy plugin for babel parse Third-party sources that ship raw `@decorator` syntax (fontkit, older MobX / Nest builds) tripped the same silent-drop failure mode as #264: babel.parse threw, `detect()` logged a warning, and the file's dependency graph was dropped. Enable `decorators-legacy` in both the walker's detector and the ESM-transformer parse calls so these sources parse cleanly and their requires/imports get bundled. Extend test-94 with a decorator fixture walked via `pkg.scripts`. * fix(detector): reject non-string specifiers in dynamic import matcher Guard visitorDynamicImport against `import(0)` / `import(true)` so a numeric or boolean literal can't flow through the walker as an alias and crash downstream string checks (e.g. isBuiltin's moduleName.startsWith). Addresses Copilot review feedback on PR #268. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
1 parent d155e24 commit 81c6c88

11 files changed

Lines changed: 189 additions & 6 deletions

File tree

eslint.config.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ module.exports = [
1515
'test/test-51-esm-import-meta/esm-module/test-import-meta-basic.js',
1616
'lib/log.js', // ESM re-export file
1717
'test/test-50-extensions/test-y-esnext.js', // ESM test file
18+
'test/test-94-sea-esm-import-meta/app/lib/decorated.js', // decorator syntax fixture
19+
1820
'prelude/sea-bootstrap.bundle.js', // Generated by esbuild
1921
'prelude/sea-bootstrap-esm.bundle.mjs', // Generated by esbuild
2022
],

lib/detector.ts

Lines changed: 84 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { log } from './log';
55

66
import { ALIAS_AS_RELATIVE, ALIAS_AS_RESOLVABLE } from './common';
77

8+
/** Type guard for plain literal nodes; rejects template literals with interpolations. */
89
function isLiteral(node: babelTypes.Node): node is babelTypes.Literal {
910
if (node == null) {
1011
return false;
@@ -21,6 +22,7 @@ function isLiteral(node: babelTypes.Node): node is babelTypes.Literal {
2122
return true;
2223
}
2324

25+
/** Extracts the runtime value of a literal. Throws on null/regexp — never valid module specifiers. */
2426
function getLiteralValue(node: babelTypes.Literal) {
2527
if (node.type === 'TemplateLiteral') {
2628
return node.quasis[0].value.raw;
@@ -37,6 +39,7 @@ function getLiteralValue(node: babelTypes.Literal) {
3739
return node.value;
3840
}
3941

42+
/** Renders an import specifier list back to source (`a, { b, c as d }`) for log output. */
4043
function reconstructSpecifiers(
4144
specs: (
4245
| babelTypes.ImportDefaultSpecifier
@@ -79,6 +82,7 @@ function reconstructSpecifiers(
7982
return defaults.join(', ');
8083
}
8184

85+
/** Prints any AST node back to a single-line source string, used when an arg isn't a literal. */
8286
function reconstruct(node: babelTypes.Node) {
8387
let v = generate(node, { comments: false }).code.replace(/\n/g, '');
8488
let v2;
@@ -102,6 +106,7 @@ interface Was {
102106
v3?: string;
103107
}
104108

109+
/** Fills a template (e.g. `require({v1}{c2}{v2})`) with captured args to produce the printable form of a match. */
105110
function forge(pattern: string, was: Was) {
106111
return pattern
107112
.replace('{c1}', ', ')
@@ -112,6 +117,7 @@ function forge(pattern: string, was: Was) {
112117
.replace('{v3}', was.v3 ? was.v3 : '');
113118
}
114119

120+
/** Guards the 2nd arg of require/require.resolve — only pkg's `must-exclude`/`may-exclude` markers are honored. */
115121
function valid2(v2?: Was['v2']) {
116122
return (
117123
v2 === undefined ||
@@ -121,6 +127,7 @@ function valid2(v2?: Was['v2']) {
121127
);
122128
}
123129

130+
/** Matches `require.resolve("lit"[, "lit"])`. Returns captured args or null. */
124131
function visitorRequireResolve(n: babelTypes.Node) {
125132
if (!babelTypes.isCallExpression(n)) {
126133
return null;
@@ -150,6 +157,7 @@ function visitorRequireResolve(n: babelTypes.Node) {
150157
};
151158
}
152159

160+
/** Matches `require("lit"[, "lit"])`. Returns captured args or null. */
153161
function visitorRequire(n: babelTypes.Node) {
154162
if (!babelTypes.isCallExpression(n)) {
155163
return null;
@@ -173,6 +181,7 @@ function visitorRequire(n: babelTypes.Node) {
173181
};
174182
}
175183

184+
/** Matches a static ESM `import … from "lit"` declaration. */
176185
function visitorImport(n: babelTypes.Node) {
177186
if (!babelTypes.isImportDeclaration(n)) {
178187
return null;
@@ -181,6 +190,32 @@ function visitorImport(n: babelTypes.Node) {
181190
return { v1: n.source.value, v3: reconstructSpecifiers(n.specifiers) };
182191
}
183192

193+
/** Matches dynamic `import("lit")` so bundler-emitted chunk splits get walked like static imports. */
194+
function visitorDynamicImport(n: babelTypes.Node) {
195+
if (!babelTypes.isCallExpression(n)) {
196+
return null;
197+
}
198+
199+
if (n.callee.type !== 'Import') {
200+
return null;
201+
}
202+
203+
if (!n.arguments || !isLiteral(n.arguments[0])) {
204+
return null;
205+
}
206+
207+
// Module specifiers are always strings — reject `import(0)` / `import(true)`
208+
// so a non-string value can't reach the walker's alias handling.
209+
const value = getLiteralValue(n.arguments[0] as babelTypes.Literal);
210+
211+
if (typeof value !== 'string') {
212+
return null;
213+
}
214+
215+
return { v1: value };
216+
}
217+
218+
/** Matches `path.join(__dirname, "lit")` — treats the joined path as a snapshot asset reference. */
184219
function visitorPathJoin(n: babelTypes.Node) {
185220
if (!babelTypes.isCallExpression(n)) {
186221
return null;
@@ -221,6 +256,11 @@ function visitorPathJoin(n: babelTypes.Node) {
221256
return { v1: getLiteralValue(n.arguments[1] as babelTypes.StringLiteral) };
222257
}
223258

259+
/**
260+
* Runs each literal-arg matcher in order and returns the first hit as a
261+
* `{alias, aliasType, mustExclude?, mayExclude?}` derivative for the walker to
262+
* bundle. When `test` is true returns a printable form (used by unit tests).
263+
*/
224264
export function visitorSuccessful(node: babelTypes.Node, test = false) {
225265
let was: Was | null = visitorRequireResolve(node);
226266

@@ -270,6 +310,16 @@ export function visitorSuccessful(node: babelTypes.Node, test = false) {
270310
return { alias: was.v1, aliasType: ALIAS_AS_RESOLVABLE };
271311
}
272312

313+
was = visitorDynamicImport(node);
314+
315+
if (was) {
316+
if (test) {
317+
return forge('import({v1})', was);
318+
}
319+
320+
return { alias: was.v1, aliasType: ALIAS_AS_RESOLVABLE };
321+
}
322+
273323
was = visitorPathJoin(node);
274324

275325
if (was) {
@@ -283,6 +333,7 @@ export function visitorSuccessful(node: babelTypes.Node, test = false) {
283333
return null;
284334
}
285335

336+
/** Matches `require.resolve(<non-literal>[, "lit"])` — feeds the "Cannot resolve" warning path. */
286337
function nonLiteralRequireResolve(n: babelTypes.Node) {
287338
if (!babelTypes.isCallExpression(n)) {
288339
return null;
@@ -322,6 +373,7 @@ function nonLiteralRequireResolve(n: babelTypes.Node) {
322373
};
323374
}
324375

376+
/** Matches `require(<non-literal>[, "lit"])` — feeds the "Cannot resolve" warning path. */
325377
function nonLiteralRequire(n: babelTypes.Node) {
326378
if (!babelTypes.isCallExpression(n)) {
327379
return null;
@@ -355,6 +407,7 @@ function nonLiteralRequire(n: babelTypes.Node) {
355407
};
356408
}
357409

410+
/** Entry visitor for dynamic requires whose target isn't known at build time — returns the alias to warn about. */
358411
export function visitorNonLiteral(n: babelTypes.Node) {
359412
const was = nonLiteralRequireResolve(n) || nonLiteralRequire(n);
360413

@@ -373,6 +426,7 @@ export function visitorNonLiteral(n: babelTypes.Node) {
373426
return null;
374427
}
375428

429+
/** Loose `require(...)` match (no literal gate) — used only to surface malformed-require diagnostics. */
376430
function isRequire(n: babelTypes.Node) {
377431
if (!babelTypes.isCallExpression(n)) {
378432
return null;
@@ -395,6 +449,7 @@ function isRequire(n: babelTypes.Node) {
395449
return { v1: reconstruct(n.arguments[0]) };
396450
}
397451

452+
/** Loose `require.resolve(...)` match (no literal gate) — used only for malformed-require diagnostics. */
398453
function isRequireResolve(n: babelTypes.Node) {
399454
if (!babelTypes.isCallExpression(n)) {
400455
return null;
@@ -423,6 +478,7 @@ function isRequireResolve(n: babelTypes.Node) {
423478
return { v1: reconstruct(n.arguments[0]) };
424479
}
425480

481+
/** Fires on require/require.resolve shapes the literal matchers rejected (wrong arg count, etc.). */
426482
export function visitorMalformed(n: babelTypes.Node) {
427483
const was = isRequireResolve(n) || isRequire(n);
428484

@@ -433,6 +489,7 @@ export function visitorMalformed(n: babelTypes.Node) {
433489
return null;
434490
}
435491

492+
/** Flags `path.resolve(...)` so the walker can warn that it resolves against `process.cwd()` at runtime, not `__dirname`. */
436493
export function visitorUseSCWD(n: babelTypes.Node) {
437494
if (!babelTypes.isCallExpression(n)) {
438495
return null;
@@ -463,6 +520,11 @@ export function visitorUseSCWD(n: babelTypes.Node) {
463520

464521
type VisitorFunction = (node: babelTypes.Node, trying?: boolean) => boolean;
465522

523+
/**
524+
* Iterative DFS over the AST. `visitor` returns true to descend into children;
525+
* `trying` is propagated inside try/catch bodies so the walker can downgrade
526+
* downstream warnings to debug.
527+
*/
466528
function traverse(ast: babelTypes.File, visitor: VisitorFunction) {
467529
// modified esprima-walk to support
468530
// visitor return value and "trying" flag
@@ -495,18 +557,37 @@ function traverse(ast: babelTypes.File, visitor: VisitorFunction) {
495557
}
496558
}
497559

498-
export function parse(body: string) {
560+
/**
561+
* `babel.parse` wrapper. `isEsm` selects `sourceType: 'module'` so `import.meta`
562+
* / top-level await parse cleanly. `decorators-legacy` is enabled so third-party
563+
* sources that ship raw `@decorator` syntax (fontkit, older MobX/Nest builds,
564+
* etc.) don't trip the parser and silently drop their dependency graph.
565+
*/
566+
export function parse(body: string, isEsm = false) {
499567
return babel.parse(body, {
500568
allowImportExportEverywhere: true,
501569
allowReturnOutsideFunction: true,
570+
sourceType: isEsm ? 'module' : 'script',
571+
plugins: ['decorators-legacy'],
502572
});
503573
}
504574

505-
export function detect(body: string, visitor: VisitorFunction, file?: string) {
575+
/**
576+
* Parses `body` and walks the AST with `visitor`. Parse failures are logged
577+
* (not thrown) so one unparseable file doesn't abort the whole build — but the
578+
* file's dependencies are then skipped, which is why callers must pass the
579+
* correct `isEsm` flag.
580+
*/
581+
export function detect(
582+
body: string,
583+
visitor: VisitorFunction,
584+
file?: string,
585+
isEsm = false,
586+
) {
506587
let json;
507588

508589
try {
509-
json = parse(body);
590+
json = parse(body, isEsm);
510591
} catch (error) {
511592
const fileInfo = file ? ` in ${file}` : '';
512593
log.warn(`Babel parse has failed: ${(error as Error).message}${fileInfo}`);

lib/esm-transformer.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ function hasImportMeta(code: string): boolean {
3535
try {
3636
const ast = babel.parse(code, {
3737
sourceType: 'module',
38-
plugins: [],
38+
plugins: ['decorators-legacy'],
3939
});
4040

4141
if (!ast) {
@@ -85,7 +85,7 @@ function detectESMFeatures(
8585
try {
8686
const ast = babel.parse(code, {
8787
sourceType: 'module',
88-
plugins: [],
88+
plugins: ['decorators-legacy'],
8989
});
9090

9191
if (!ast) {
@@ -300,7 +300,7 @@ export function transformESMtoCJS(
300300
// Parse the code to check for exports and collect imports
301301
const ast = babel.parse(code, {
302302
sourceType: 'module',
303-
plugins: [],
303+
plugins: ['decorators-legacy'],
304304
});
305305

306306
let hasExports = false;

lib/walker.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,7 @@ function stepDetect(
325325
return true; // can i go inside?
326326
},
327327
record.file,
328+
isESMFile(record.file),
328329
);
329330
} catch (error) {
330331
log.error((error as Error).message, record.file);
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// `import.meta` is only valid in `sourceType: "module"`. Before the fix for
2+
// issue #264 the SEA walker parsed this body in script mode, so the parse
3+
// failed and the detector never saw the imports below — neither the static
4+
// one nor the dynamic one ended up in the snapshot.
5+
import { greet } from './lib/helper.mjs';
6+
7+
const here = new URL(import.meta.url).pathname;
8+
console.log('here:' + here.split('/').pop());
9+
console.log('static:' + greet('world'));
10+
11+
const dyn = await import('./lib/dyn.mjs');
12+
console.log('dynamic:' + dyn.shout('world'));
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// Decorator syntax forces the walker's Babel parser to enable the
2+
// `decorators-legacy` plugin. Without it pkg would log "Babel parse has
3+
// failed: This experimental syntax requires enabling one of the following
4+
// parser plugin(s)" and silently drop this file's dependency graph. This
5+
// file is walked via `pkg.scripts` but never imported at runtime — Node
6+
// can't execute raw decorator syntax.
7+
function log(Cls) {
8+
return Cls;
9+
}
10+
11+
@log
12+
export class Widget {
13+
greet(name) {
14+
return 'widget:' + name;
15+
}
16+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function shout(name) {
2+
return 'HELLO ' + name.toUpperCase();
3+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function greet(name) {
2+
return 'hello ' + name;
3+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"name": "test-94-sea-esm-import-meta",
3+
"version": "1.0.0",
4+
"type": "module",
5+
"main": "index.mjs",
6+
"bin": "index.mjs",
7+
"pkg": {
8+
"scripts": [
9+
"lib/decorated.js"
10+
]
11+
}
12+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
#!/usr/bin/env node
2+
3+
'use strict';
4+
5+
const assert = require('assert');
6+
const utils = require('../utils.js');
7+
8+
// Enhanced SEA requires Node.js >= 22
9+
if (utils.getNodeMajorVersion() < 22) {
10+
return;
11+
}
12+
13+
assert(__dirname === process.cwd());
14+
15+
const input = './app/package.json';
16+
const testName = 'test-94-sea-esm-import-meta';
17+
18+
const SEA_PLATFORM_SUFFIX = {
19+
linux: 'linux',
20+
darwin: 'macos',
21+
win32: 'win.exe',
22+
};
23+
const suffix = SEA_PLATFORM_SUFFIX[process.platform];
24+
25+
const newcomers = utils.seaHostOutputs(testName);
26+
const before = utils.filesBefore(newcomers);
27+
28+
// Capture pkg's output so we can assert the Babel parse warning (issue #264)
29+
// never surfaces when walking ESM files that use `import.meta`.
30+
const args = suffix
31+
? [input, '--sea', '--target', 'host', '--output', `${testName}-${suffix}`]
32+
: [input, '--sea'];
33+
34+
const build = utils.pkg.sync(args, { stdio: ['pipe', 'pipe', 'pipe'] });
35+
const buildLog = build.stdout + build.stderr;
36+
37+
assert(
38+
buildLog.indexOf('Babel parse has failed') === -1,
39+
'pkg must parse ESM files as modules (issue #264)\npkg output was:\n' +
40+
buildLog,
41+
);
42+
43+
// A successful parse means both imports were walked and bundled — running the
44+
// binary proves it by printing the imported values. Skip on unsupported hosts.
45+
if (suffix) {
46+
utils.assertSeaOutput(
47+
testName,
48+
'here:index.mjs\nstatic:hello world\ndynamic:HELLO WORLD\n',
49+
);
50+
}
51+
52+
utils.filesAfter(before, newcomers, { tolerateWindowsEbusy: true });

0 commit comments

Comments
 (0)