@exodus/stasis 1.0.0-beta.2 → 1.0.0-beta.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +23 -3
  2. package/bin/stasis.js +375 -170
  3. package/package.json +44 -11
  4. package/src/audit-corrections.js +68 -0
  5. package/src/audit.js +243 -59
  6. package/src/babel-worker.cjs +65 -0
  7. package/src/babel.js +131 -0
  8. package/src/bundle-from-lockfile.js +12 -18
  9. package/src/bundle-reader.js +77 -0
  10. package/src/cmd/build.js +95 -128
  11. package/src/cmd/bundle.js +877 -641
  12. package/src/cmd/diff.js +2 -19
  13. package/src/cmd/github-bundle.js +39 -0
  14. package/src/cmd/output.js +36 -0
  15. package/src/cmd/sbom.js +4 -18
  16. package/src/diff.js +67 -124
  17. package/src/json-stream.js +283 -0
  18. package/src/loader-mock.js +8 -15
  19. package/src/loaders/bash.js +49 -116
  20. package/src/loaders/cargo.js +1699 -0
  21. package/src/loaders/foundry.js +923 -0
  22. package/src/loaders/paths.js +33 -0
  23. package/src/loaders/php.js +339 -394
  24. package/src/loaders/rust.js +3254 -242
  25. package/src/loaders/solidity-ownership.js +531 -0
  26. package/src/loaders/solidity.js +412 -162
  27. package/src/loaders/toml.js +68 -0
  28. package/src/metro-resolver.js +150 -0
  29. package/src/mock.js +57 -165
  30. package/src/parse.js +4 -16
  31. package/src/resolve-fields.js +331 -0
  32. package/src/resolve-node.js +416 -0
  33. package/src/resolve-typescript.js +427 -0
  34. package/src/rollup.js +5 -0
  35. package/src/sbom.js +64 -92
  36. package/src/scan.js +234 -156
  37. package/src/vfs-bundle/entries.js +140 -0
  38. package/src/vfs-bundle/github.js +193 -0
  39. package/src/vfs-bundle/soldeer.js +125 -0
  40. package/src/vfs-bundle/tree.js +606 -0
  41. package/src/vfs-bundle.js +74 -0
  42. package/src/why.js +434 -0
  43. package/src/apis/npm/index.js +0 -36
  44. package/src/apis/npm/semver.cjs +0 -44
  45. package/src/cmd/extract.js +0 -193
package/src/scan.js CHANGED
@@ -1,60 +1,66 @@
1
- import { readFileSync, existsSync } from 'node:fs'
2
1
  import { extname, resolve as resolvePath, relative } from 'node:path'
3
2
  import { fileURLToPath, pathToFileURL } from 'node:url'
4
- import { createRequire, isBuiltin, findPackageJSON } from 'node:module'
3
+ import { createRequire, isBuiltin } from 'node:module'
5
4
  import assert from 'node:assert/strict'
5
+ import { packageType } from '@exodus/stasis-core/bundle-util'
6
+ import { classifyExtension, classifyFormat, relativeEscapes } from '@exodus/stasis-core/util'
7
+ import { resolveTypescriptFallback } from './resolve-typescript.js'
8
+ import { diskHost } from '@exodus/stasis-core/host'
6
9
 
7
- // Static require/import graph walker. Reads source files and parses them; never
8
- // loads or executes any user code. Resolves specifiers through Node's own
9
- // resolver (`createRequire(parent).resolve(spec, { conditions: Set })`), which
10
- // reads package.json manifests and applies `exports` conditions but never runs
11
- // the target module. Dynamic specifiers (`require(name)`, `import('./'+x)`)
12
- // are recorded as unresolved -- a fundamental limit of static analysis for
13
- // CJS, not a tooling shortcoming. Files that fail to parse are recorded in
14
- // `parseErrors` (and flagged with `parseError` in `files`): for script (CJS)
15
- // files oxc's recovered AST still yields the edges (Node's module wrapper
16
- // accepts code oxc rejects, e.g. top-level `return`), while module files keep
17
- // no edges -- a missed static edge is a hole consumers can't enumerate.
10
+ // Static require/import graph walker: parses source, never loads or executes user code.
11
+ // Dynamic specifiers (`require(name)`, `import('./'+x)`) are recorded as unresolved.
18
12
 
19
13
  const SCRIPT_EXTS = new Set(['.js', '.cjs', '.mjs', '.ts', '.cts', '.mts'])
20
14
  const RESOLVABLE_EXTS = new Set([...SCRIPT_EXTS, '.json'])
15
+ // Under `jsx`, these get parsed with JSX enabled. Excludes the .ts family: TypeScript's `<T>`
16
+ // generics collide with JSX, so tsc/oxc reserve JSX for .tsx — JSX-in-TS belongs in a .tsx file.
17
+ const JSX_EXTS = new Set(['.js', '.cjs', '.mjs'])
18
+ // The inherently-JSX extensions (oxc parses them as JSX/TSX purely from the filename, no flag).
19
+ // Scanned + carried only under `--jsx`: off by default the scanner treats them as opaque leaves,
20
+ // so a bundle whose toolchain can't build JSX never silently ships .jsx/.tsx source. Under `--jsx`
21
+ // they join SCRIPT_EXTS/RESOLVABLE_EXTS (see the constructor) so a reached .jsx/.tsx dependency
22
+ // — e.g. a package whose React Native entry is src/index.tsx — is parsed and carried like any .ts.
23
+ const JSX_FILE_EXTS = new Set(['.jsx', '.tsx'])
21
24
 
22
- // oxc-parser is a regular dependency, required lazily so non-JS bundlers (which
23
- // never reach getParser()) don't load the native parser.
25
+ // Flow type syntax lives in the JS (non-TS) family; oxc parses TS natively, and running
26
+ // flow-remove-types over a .ts/.tsx file would corrupt TS-only constructs, so --flow only strips
27
+ // these. .jsx is included (it's JS + JSX, and can carry Flow types like .js); .tsx is not (it's
28
+ // TypeScript). flow-remove-types preserves JSX, so a stripped .jsx re-parses under lang:jsx.
29
+ const FLOW_EXTS = new Set(['.js', '.cjs', '.mjs', '.jsx'])
30
+
31
+ // Required lazily so non-JS bundlers don't load the native oxc parser.
24
32
  let _parser
25
33
  function getParser() {
26
34
  _parser ??= createRequire(import.meta.url)('oxc-parser')
27
35
  return _parser
28
36
  }
29
37
 
30
- function packageType(file) {
31
- const pkg = findPackageJSON(pathToFileURL(file).toString())
32
- if (!pkg) return null
38
+ // flow-remove-types is an optional dependency, loaded lazily and only under --flow: it strips
39
+ // Flow type syntax down to plain JS before oxc (which parses JS/TS, not Flow) sees it. A missing
40
+ // dep is an env error, so surface it with an actionable install hint rather than a bare resolve throw.
41
+ let _flowRemoveTypes
42
+ function getFlowRemoveTypes() {
43
+ if (_flowRemoveTypes) return _flowRemoveTypes
33
44
  try {
34
- const type = JSON.parse(readFileSync(pkg, 'utf8')).type
35
- return type === 'module' || type === 'commonjs' ? type : null
36
- } catch {
37
- return null
45
+ _flowRemoveTypes = createRequire(import.meta.url)('flow-remove-types')
46
+ } catch (cause) {
47
+ throw new Error(
48
+ "--flow needs the optional 'flow-remove-types' dependency; install it (e.g. `npm i flow-remove-types`)",
49
+ { cause }
50
+ )
38
51
  }
52
+ return _flowRemoveTypes
39
53
  }
40
54
 
41
- // Format per Node's own rules. TypeScript files get Node's type-stripping
42
- // formats ('module-typescript' / 'commonjs-typescript'), matching what Node's
43
- // load hook reports for them. For .js/.ts the module system comes from the
44
- // nearest package.json `type`; when that's absent (or unrecognized), Node
45
- // falls back to module-syntax detection, which requires the parse — null
46
- // signals "detect from syntax" to the caller.
47
- function formatForFile(file) {
48
- const ext = extname(file)
49
- if (ext === '.mjs') return 'module'
50
- if (ext === '.cjs') return 'commonjs'
51
- if (ext === '.json') return 'json'
52
- if (ext === '.mts') return 'module-typescript'
53
- if (ext === '.cts') return 'commonjs-typescript'
54
- const type = packageType(file)
55
+ // classifyFormat is the shared name->format authority (static and runtime bundles must agree); it
56
+ // returns null for the .js/.ts family whose module system comes from package.json `type` (null =
57
+ // detect from syntax, signaled up as null).
58
+ function formatForFile(file, host) {
59
+ const format = classifyFormat(file)
60
+ if (format != null) return format
61
+ const type = packageType(file, host)
55
62
  if (type === null) return null
56
- const suffix = ext === '.ts' ? '-typescript' : ''
57
- return `${type}${suffix}`
63
+ return `${type}${extname(file) === '.ts' ? '-typescript' : ''}`
58
64
  }
59
65
 
60
66
  function literalSpec(node) {
@@ -66,9 +72,7 @@ function literalSpec(node) {
66
72
  return null
67
73
  }
68
74
 
69
- // Walk the AST for require() and dynamic import() calls. ESM static imports and
70
- // re-exports are picked up separately from oxc-parser's `module.staticImports` /
71
- // `module.staticExports` -- much cheaper than walking the whole tree for them.
75
+ // Walks require()/import() calls only; ESM static imports/re-exports come from oxc module records.
72
76
  function findCallSpecifiers(ast, push) {
73
77
  const visit = (node) => {
74
78
  if (!node || typeof node !== 'object') return
@@ -90,11 +94,8 @@ function findCallSpecifiers(ast, push) {
90
94
  if (spec != null) push({ kind: 'dynamic-import', spec })
91
95
  else push({ kind: 'dynamic-import', dynamic: true })
92
96
  } else if (node.type === 'TSImportEqualsDeclaration') {
93
- // `import lib = require('./dep.cts')`: TS-only CJS import form. Node's
94
- // type stripping refuses to run it (non-erasable syntax), but the
95
- // dependency edge is real — record it like a require() so the bundle
96
- // stays self-contained for transform-types users and external analysis.
97
- // Qualified-name references (`import A = B.C`) carry no file edge.
97
+ // `import lib = require('./dep.cts')`: TS-only CJS import; record the real edge.
98
+ // Qualified-name form (`import A = B.C`) carries no file edge.
98
99
  if (node.moduleReference?.type === 'TSExternalModuleReference') {
99
100
  const spec = literalSpec(node.moduleReference.expression)
100
101
  if (spec != null) push({ kind: 'require', spec })
@@ -121,29 +122,47 @@ export class Scan {
121
122
  parseErrors = []
122
123
  entries = new Set()
123
124
 
124
- // `conditions` is *additive* on top of the format-derived base set: ESM parents
125
- // get ['node', 'import'] and CJS parents get ['node', 'require'], with any
126
- // user-supplied conditions (e.g. 'browser', 'development') merged in. This
127
- // matches what the runtime loader records when Node reports conditions for an
128
- // edge -- different edges in the same scan can end up under different keys.
129
- //
130
- // `resolve` optionally replaces Node's own resolver with a custom one (the
131
- // legacy-field resolver in resolve-fields.js drives `stasis bundle --mainFields`).
132
- // It is called `(parentFile, specifier, conditions)` and returns
133
- // `{ url } | { empty: true } | { builtin: true } | null` -- `empty` marks a
134
- // browser/react-native `false` redirect (an empty-module stub the caller
135
- // materialises), the rest mirror Node resolution. When set, it also handles
136
- // builtins, so the built-in `isBuiltin` short-circuit is skipped.
137
- constructor({ conditions = [], resolve = null } = {}) {
125
+ // `conditions` is additive on top of the edge-context base set. `resolve` optionally
126
+ // replaces Node's resolver (resolve-fields.js): returns `{ url } | { empty } | { builtin } | null`
127
+ // (`empty` = a browser/react-native `false` redirect) and owns builtin handling when set.
128
+ // `jsx` opts the .js/.cjs/.mjs family into JSX syntax (React Native's JSX-in-.js convention);
129
+ // off by default because oxc, like tsc, only auto-enables JSX for .jsx/.tsx by extension.
130
+ // `flow`: strip Flow type syntax from JS-family sources before parsing (see #scanFile).
131
+ // `typescript`: retry a failed resolution with tsc's mapping (see #typescriptResolve), with
132
+ // `typescriptPaths` (a loadTsconfigPaths matcher) adding tsconfig alias support; only consulted
133
+ // on the built-in (Node) resolver -- a custom `resolve` owns its own TS handling.
134
+ // `resources` (a `parseResourcesOption` Set of extensions/filenames): reached files matching it
135
+ // are carried as opaque resources (bytes only) rather than rejected as un-carryable -- for graphs
136
+ // that aren't fully loadable in JS (e.g. Metro consuming .png/.svg assets).
137
+ // `host`: the filesystem the walk reads and resolves through (@exodus/stasis-core/host).
138
+ constructor({ conditions = [], resolve = null, jsx = false, flow = false, typescript = false, typescriptPaths = null, resources = new Set(), host = diskHost } = {}) {
138
139
  this.extraConditions = [...conditions]
139
140
  this.customResolve = resolve
141
+ this.jsx = jsx
142
+ this.flow = flow
143
+ this.typescript = typescript
144
+ this.typescriptPaths = typescriptPaths
145
+ this.resources = resources
146
+ this.host = host
147
+ // --jsx widens the script/resolvable extension sets to include .jsx/.tsx, so those files are
148
+ // parsed (not left as opaque leaves) and queued when reached. Off by default the base sets apply.
149
+ this.scriptExts = jsx ? new Set([...SCRIPT_EXTS, ...JSX_FILE_EXTS]) : SCRIPT_EXTS
150
+ this.resolvableExts = jsx ? new Set([...RESOLVABLE_EXTS, ...JSX_FILE_EXTS]) : RESOLVABLE_EXTS
151
+ }
152
+
153
+ // A reached file whose extension/filename is in the --resources allowlist: carried as an opaque
154
+ // resource (bytes only, no parse, no edges), not rejected as "a source bundle can't carry".
155
+ // classifyExtension returns 'resource' only for non-code names in the allowlist, so this never
156
+ // shadows a script/json extension (parseResourcesOption rejects code extensions).
157
+ #isResource(file) {
158
+ return this.resources.size > 0 && classifyExtension(file, this.resources) === 'resource'
140
159
  }
141
160
 
142
161
  walk(entries) {
143
162
  const queue = []
144
163
  for (const entry of entries) {
145
164
  const abs = resolvePath(entry)
146
- assert.ok(existsSync(abs), `entry not found: ${abs}`)
165
+ assert.ok(this.host.stat(abs) !== null, `entry not found: ${abs}`)
147
166
  const url = pathToFileURL(abs).toString()
148
167
  this.entries.add(url)
149
168
  queue.push(url)
@@ -156,29 +175,60 @@ export class Scan {
156
175
  return this
157
176
  }
158
177
 
159
- // Match the runtime resolver's full condition set. Node 22 passes
160
- // `['node', import|require, 'module-sync', 'node-addons']` to the resolve
161
- // hook; if we omit any of these, a package whose `exports` map gates on the
162
- // runtime-only conditions (e.g. `"module-sync": "./a.js", "import": "./b.js"`)
163
- // resolves to a different file under static scan than under plain Node.
164
- // Confirmed by reproducer with module-sync: plain node hits sync.js, static
165
- // bundle loads with async.js -- silently runs different code.
166
- #conditionsFor(format) {
167
- const base = ['module', 'module-typescript'].includes(format)
168
- ? ['node', 'import', 'module-sync', 'node-addons']
169
- : ['node', 'require', 'module-sync', 'node-addons']
170
- return new Set([...base, ...this.extraConditions])
178
+ // Must match the runtime resolver's condition set (omitting one resolves `exports`-gated
179
+ // packages to a different file) AND its order: the recorded key must equal the runtime
180
+ // hooks' string for exact lookups. Extras go where Node puts user conditions in each set.
181
+ #conditionSet(context) {
182
+ return context === 'import'
183
+ ? new Set(['node', 'import', 'module-sync', 'node-addons', ...this.extraConditions])
184
+ : new Set(['require', 'node', 'node-addons', ...this.extraConditions, 'module-sync'])
171
185
  }
172
186
 
173
- // `recovered: false` means the parser crashed on this file and nothing was
174
- // salvaged -- its edges are entirely unknown. `recovered: true` means oxc
175
- // returned, but we discarded its partial module records (module-family
176
- // formats only; see #scanFile).
187
+ // `recovered`: false = parser crashed, nothing salvaged; true = oxc returned but we
188
+ // discarded its partial module records (module-family only; see #scanFile).
177
189
  #recordParseError(url, format, message, recovered) {
178
190
  this.files.set(url, { format, edges: [], parseError: message })
179
191
  this.parseErrors.push({ url, format, message, recovered })
180
192
  }
181
193
 
194
+ // --typescript: resolve `spec` the way tsc maps TS sources, invoked only AFTER Node's own
195
+ // resolution missed -- so an on-disk `.js` (or any Node-resolvable target) always wins, and the
196
+ // mapping only completes a failed resolution, never rewrites a successful one. The rules
197
+ // (extension substitution, extensionless/index completion, manifest-target and tsconfig-paths
198
+ // mapping) live in the shared dispatcher (resolve-typescript.js), so this resolver and the
199
+ // legacy-field one cannot drift. The hit is realpathed to match req.resolve's canonical output
200
+ // (every other recorded edge is a realpath).
201
+ #typescriptResolve(parentFile, spec, conditions) {
202
+ const hit = resolveTypescriptFallback(parentFile, spec, {
203
+ conditions,
204
+ tsx: this.jsx,
205
+ paths: this.typescriptPaths,
206
+ host: this.host,
207
+ })
208
+ if (hit == null) return null
209
+ try {
210
+ return this.host.realpath(hit)
211
+ } catch {
212
+ return null
213
+ }
214
+ }
215
+
216
+ // Strip Flow type syntax to plain JS via the optional flow-remove-types dep (resolved lazily; a
217
+ // missing dep is an env error and propagates). Only invoked as a parse fallback (see #scanFile),
218
+ // so plain-JS files never reach it. Returns the rewritten source, or null when the transform
219
+ // itself fails on this file -- the caller then keeps oxc's original parse error.
220
+ #stripFlow(src, file) {
221
+ const flowRemoveTypes = getFlowRemoveTypes()
222
+ try {
223
+ // `all: true` ignores the @flow/@noflow pragma (RN sources mix both); the transform blanks
224
+ // types with whitespace, so byte offsets and every import specifier are preserved.
225
+ return flowRemoveTypes(src, { all: true }).toString()
226
+ } catch (cause) {
227
+ console.warn(`[stasis] --flow: could not strip Flow types from ${file}; leaving it to oxc (${cause.message})`)
228
+ return null
229
+ }
230
+ }
231
+
182
232
  #scanFile(url, queue) {
183
233
  const file = fileURLToPath(url)
184
234
  const ext = extname(file)
@@ -186,107 +236,135 @@ export class Scan {
186
236
  this.files.set(url, { format: 'json', edges: [] })
187
237
  return
188
238
  }
189
- if (!SCRIPT_EXTS.has(ext)) {
239
+ if (this.#isResource(file)) {
240
+ // Allowlisted asset: carry it as a resource with no parse and no edges. `resource: true` is
241
+ // the authoritative signal (both bundle builders key off it); the concrete format
242
+ // (resource vs resource:base64) is byte-derived by the builder, so leave it unset here.
243
+ this.files.set(url, { format: null, edges: [], resource: true })
244
+ return
245
+ }
246
+ if (!this.scriptExts.has(ext)) {
190
247
  this.files.set(url, { format: null, edges: [] })
191
248
  return
192
249
  }
193
250
 
194
- // declared === null: typeless package, where Node decides .js/.ts by
195
- // module-syntax detection — that needs the parse, so resolve it below.
196
- const declared = formatForFile(file)
197
- const src = readFileSync(file, 'utf8')
251
+ // declared === null: typeless package; Node decides .js/.ts by syntax, resolved after parse.
252
+ const declared = formatForFile(file, this.host)
253
+ const src = this.host.readFile(file).toString('utf8')
198
254
 
199
- // Resolve the parser outside the per-file try below: a missing oxc-parser is
200
- // an environment error, not a property of this file, so it must not be
201
- // swallowed into a silent zero-edge leaf.
255
+ // Resolve the parser outside the try: a missing oxc-parser is an env error, not this
256
+ // file's — it must not be swallowed into a silent zero-edge leaf.
202
257
  const parser = getParser()
258
+ // oxc picks JS/TS + JSX from the filename; the .js family lands on plain JS (no JSX), so
259
+ // force the JSX variant when opted in (`--jsx`) to parse React Native's JSX-in-.js source.
260
+ const lang = this.jsx && JSX_EXTS.has(ext) ? 'jsx' : undefined
261
+ const parseOptions = (sourceType) => (lang ? { sourceType, lang } : { sourceType })
262
+ const baseSourceType = declared === null ? 'unambiguous'
263
+ : declared.startsWith('module') ? 'module' : 'script'
203
264
  let parsed
204
265
  try {
205
- parsed = parser.parseSync(file, src, {
206
- sourceType: declared === null ? 'unambiguous'
207
- : declared.startsWith('module') ? 'module' : 'script',
208
- })
266
+ parsed = parser.parseSync(file, src, parseOptions(baseSourceType))
209
267
  } catch (cause) {
210
268
  this.#recordParseError(url, declared, cause.message, false)
211
269
  return
212
270
  }
213
271
 
214
- // Match Node's module-syntax detection for typeless packages: ESM syntax
215
- // (import/export/import.meta) selects the module variant. Recording the
216
- // commonjs variant here would make `--bundle=load` feed the source to the
217
- // wrong translator, which throws instead of reparsing as ESM the way
218
- // Node's on-disk loader does.
219
- let format = declared
220
- ?? `${parsed.module.hasModuleSyntax ? 'module' : 'commonjs'}${ext === '.ts' ? '-typescript' : ''}`
221
-
222
- // oxc-parser recovers from syntax errors instead of throwing: it reports
223
- // them in `parsed.errors` and returns whatever AST/module records it could
224
- // salvage. (Warning/advice-severity diagnostics don't imply a broken parse
225
- // and are not errors.)
272
+ // Match Node's syntax detection for typeless packages: ESM syntax picks the module variant
273
+ // (mislabeling as commonjs makes `--bundle=load` feed the wrong translator). Only .ts takes the
274
+ // `-typescript` suffix; .jsx/.tsx deliberately land on plain module/commonjs (like a JSX-in-.js
275
+ // file) — they're bundler-transformed, keyed by extension in `stasis build`, not Node-strippable,
276
+ // so the "needs a JSX/TS transform" signal rides the .jsx/.tsx extension, not the format tag.
277
+ const tsSuffix = ext === '.ts' ? '-typescript' : ''
278
+ const detectedFormat = (p) => `${p.module.hasModuleSyntax ? 'module' : 'commonjs'}${tsSuffix}`
279
+ let format = declared ?? detectedFormat(parsed)
280
+
281
+ // oxc recovers from syntax errors (reports them in parsed.errors) rather than throwing;
282
+ // Warning/Advice severities aren't real parse errors.
226
283
  const nonWarning = (p) => (p.errors ?? []).filter((e) => e.severity !== 'Warning' && e.severity !== 'Advice')
227
284
  let errors = nonWarning(parsed)
228
285
 
229
- // Node's detector also counts top-level await as module syntax, which
230
- // oxc's hasModuleSyntax does not flag: a typeless file whose ESM-ness
231
- // rests on TLA alone parses here as a broken script while plain node runs
232
- // it as a clean module. Mirror Node's parse-as-CJS-retry-as-ESM: when the
233
- // detected-commonjs parse has errors but a module parse is clean, the
234
- // module parse wins. Genuine CJS salvage cases (e.g. a top-level `return`
235
- // guard) error under BOTH source types and stay on the script path.
286
+ // oxc's hasModuleSyntax misses top-level await, so a TLA-only ESM file parses here as a
287
+ // broken script; mirror Node by retrying as module and preferring a clean module parse.
236
288
  if (errors.length > 0 && declared === null && !format.startsWith('module')) {
237
289
  try {
238
- const asModule = parser.parseSync(file, src, { sourceType: 'module' })
290
+ const asModule = parser.parseSync(file, src, parseOptions('module'))
239
291
  if (nonWarning(asModule).length === 0) {
240
292
  parsed = asModule
241
- format = `module${ext === '.ts' ? '-typescript' : ''}`
293
+ format = `module${tsSuffix}`
242
294
  errors = []
243
295
  }
244
296
  } catch { /* keep the script parse and its recorded errors */ }
245
297
  }
246
298
 
299
+ // --flow: oxc parses JS/TS but not Flow, so a Flow-typed .js/.cjs/.mjs file lands here with
300
+ // parse errors. Only now -- after a raw oxc parse actually failed -- strip Flow types and
301
+ // re-parse, so plain-JS files never pay the transform cost. Only the parse input is rewritten;
302
+ // the bundle stores the pristine on-disk source (State/buildResolvedJsBundle re-read it), so
303
+ // attestation still covers the real bytes. A clean re-parse replaces the errored one; otherwise
304
+ // we keep the original result so a genuine (non-Flow) parse error still surfaces.
305
+ if (errors.length > 0 && this.flow && FLOW_EXTS.has(ext)) {
306
+ const flowSrc = this.#stripFlow(src, file)
307
+ if (flowSrc !== null) {
308
+ try {
309
+ const asFlow = parser.parseSync(file, flowSrc, parseOptions(baseSourceType))
310
+ if (nonWarning(asFlow).length === 0) {
311
+ parsed = asFlow
312
+ format = declared ?? detectedFormat(parsed)
313
+ errors = []
314
+ }
315
+ } catch { /* keep the original parse + its errors */ }
316
+ }
317
+ }
318
+
247
319
  let parseError
248
320
  if (errors.length > 0) {
249
321
  parseError = errors.map((e) => e.message).join('; ')
250
- // A partially-parsed module file may be missing static link-time edges
251
- // -- holes we can't enumerate -- so don't pretend we know its imports.
322
+ // A partially-parsed module file may be missing link-time edges we can't enumerate.
252
323
  if (format.startsWith('module')) {
253
324
  this.#recordParseError(url, format, parseError, true)
254
325
  return
255
326
  }
256
- // Script (CJS) files: Node's module wrapper accepts code oxc rejects
257
- // (e.g. a top-level `return` guard), and oxc's recovered AST keeps the
258
- // require()/import() calls -- salvage the edges, record the error, and
259
- // let callers decide how loud to be.
327
+ // Script (CJS) files: oxc's recovered AST still yields the require()/import() edges — salvage them.
260
328
  this.parseErrors.push({ url, format, message: parseError, recovered: true })
261
329
  }
262
330
 
263
331
  const specs = []
264
- // ESM static imports + re-exports come from oxc's pre-extracted module info.
265
- // Type-only edges (`import type ... from`, `export type ... from`) are
266
- // erased before runtime and never loaded by Node; skip them so the bundle
267
- // matches Node's actual load graph. A mixed `import { type A, B }` still
268
- // loads, and bare side-effect imports have no entries — both are kept.
269
- if (parsed.module?.staticImports) {
270
- for (const imp of parsed.module.staticImports) {
271
- if (imp.entries.length > 0 && imp.entries.every((e) => e.isType)) continue
272
- specs.push({ kind: 'import', spec: imp.moduleRequest.value })
332
+ // STATEMENT-level type-ness decides an edge, matching what survives type erasure at runtime
333
+ // (verbatimModuleSyntax / Node's own type stripping): an `import type` / `export type`
334
+ // statement is erased whole, so no edge; a statement that merely lists inline `type`
335
+ // specifiers (`import { type A }`, `export { type B } from`) -- or none at all (`import {}`,
336
+ // `export {} from`) -- still loads its module, so its edge is real. oxc's module records
337
+ // can't draw that line (`import type { A }` and `import { type A }` yield identical
338
+ // all-isType entries, and `export {} from` yields no entry at all), so the statements are
339
+ // read off the AST, where importKind/exportKind carry it.
340
+ for (const node of parsed.program.body) {
341
+ if (node.type === 'ImportDeclaration') {
342
+ if (node.importKind !== 'type') specs.push({ kind: 'import', spec: node.source.value })
343
+ } else if (node.type === 'ExportNamedDeclaration') {
344
+ if (node.source != null && node.exportKind !== 'type') specs.push({ kind: 'export-from', spec: node.source.value })
345
+ } else if (node.type === 'ExportAllDeclaration') {
346
+ if (node.exportKind !== 'type') specs.push({ kind: 'export-from', spec: node.source.value })
273
347
  }
274
348
  }
275
- if (parsed.module?.staticExports) {
276
- for (const exp of parsed.module.staticExports) {
277
- for (const entry of exp.entries) {
278
- if (entry.moduleRequest && !entry.isType) specs.push({ kind: 'export-from', spec: entry.moduleRequest.value })
279
- }
280
- }
281
- }
282
- // require() and dynamic import() come from the AST.
283
349
  findCallSpecifiers(parsed.program, (s) => specs.push(s))
284
350
 
285
- const conditions = this.#conditionsFor(format)
286
- const key = condKey(conditions)
287
- const req = createRequire(file)
351
+ // The edge KIND picks the resolution context, not the parent's format: createRequire()d
352
+ // require() inside ESM resolves under REQUIRE conditions, literal import() inside CJS under
353
+ // IMPORT conditions -- the parent-format set baked the wrong `exports` branch into the bundle.
354
+ // The custom (legacy-field) resolver keeps the format set: bundlers don't split per edge.
355
+ const formatContext = ['module', 'module-typescript'].includes(format) ? 'import' : 'require'
288
356
  const edges = []
289
- const specMap = new Map()
357
+ const specMaps = new Map() // condition key -> specifier -> child URL
358
+ const addChild = (s, key, childURL, childPath) => {
359
+ edges.push({ ...s, child: childURL })
360
+ if (!specMaps.has(key)) specMaps.set(key, new Map())
361
+ specMaps.get(key).set(s.spec, childURL)
362
+ if (this.resolvableExts.has(extname(childPath)) || this.#isResource(childPath)) queue.push(childURL)
363
+ }
364
+ const addUnresolved = (s, reason) => {
365
+ edges.push({ ...s, error: reason })
366
+ this.unresolved.push({ parentURL: url, kind: s.kind, spec: s.spec, reason })
367
+ }
290
368
 
291
369
  for (const s of specs) {
292
370
  if (s.dynamic) {
@@ -294,10 +372,11 @@ export class Scan {
294
372
  this.unresolved.push({ parentURL: url, kind: s.kind, reason: 'dynamic' })
295
373
  continue
296
374
  }
297
- // Custom (legacy-field) resolver: it owns builtin handling and adds the
298
- // `empty` outcome (a browser/react-native `false` redirect). An empty edge
299
- // is recorded but neither queued (an empty module has no edges) nor counted
300
- // as unresolved (it resolved -- to nothing).
375
+ const context = this.customResolve ? formatContext : (s.kind === 'require' ? 'require' : 'import')
376
+ const conditions = this.#conditionSet(context)
377
+ const key = condKey(conditions)
378
+ // Custom (legacy-field) resolver owns builtin handling and the `empty` outcome (browser/RN
379
+ // `false` redirect): an empty edge is recorded but neither queued nor counted as unresolved.
301
380
  if (this.customResolve) {
302
381
  const r = this.customResolve(file, s.spec, conditions)
303
382
  if (r?.builtin) {
@@ -305,12 +384,9 @@ export class Scan {
305
384
  } else if (r?.empty) {
306
385
  edges.push({ ...s, empty: true })
307
386
  } else if (r?.url) {
308
- edges.push({ ...s, child: r.url })
309
- specMap.set(s.spec, r.url)
310
- if (RESOLVABLE_EXTS.has(extname(fileURLToPath(r.url)))) queue.push(r.url)
387
+ addChild(s, key, r.url, fileURLToPath(r.url))
311
388
  } else {
312
- edges.push({ ...s, error: 'MODULE_NOT_FOUND' })
313
- this.unresolved.push({ parentURL: url, kind: s.kind, spec: s.spec, reason: 'MODULE_NOT_FOUND' })
389
+ addUnresolved(s, 'MODULE_NOT_FOUND')
314
390
  }
315
391
  continue
316
392
  }
@@ -318,20 +394,22 @@ export class Scan {
318
394
  edges.push({ ...s, builtin: true })
319
395
  continue
320
396
  }
397
+ let childPath
321
398
  try {
322
- const childPath = req.resolve(s.spec, { conditions })
323
- const childURL = pathToFileURL(childPath).toString()
324
- edges.push({ ...s, child: childURL })
325
- specMap.set(s.spec, childURL)
326
- const childExt = extname(childPath)
327
- if (RESOLVABLE_EXTS.has(childExt)) queue.push(childURL)
399
+ childPath = this.host.resolve(file, s.spec, conditions)
328
400
  } catch (cause) {
329
- edges.push({ ...s, error: cause.code ?? cause.message })
330
- this.unresolved.push({ parentURL: url, kind: s.kind, spec: s.spec, reason: cause.code ?? cause.message })
401
+ // --typescript: complete the miss with tsc's mapping (see #typescriptResolve); the edge
402
+ // stays keyed by the ORIGINAL specifier -- only the target is the mapped file.
403
+ if (this.typescript) childPath = this.#typescriptResolve(file, s.spec, conditions)
404
+ if (childPath == null) {
405
+ addUnresolved(s, cause.code ?? cause.message)
406
+ continue
407
+ }
331
408
  }
409
+ addChild(s, key, pathToFileURL(childPath).toString(), childPath)
332
410
  }
333
411
 
334
- if (specMap.size > 0) {
412
+ for (const [key, specMap] of specMaps) {
335
413
  if (!this.imports.has(key)) this.imports.set(key, new Map())
336
414
  this.imports.get(key).set(url, specMap)
337
415
  }
@@ -342,7 +420,7 @@ export class Scan {
342
420
  const rel = (url) => {
343
421
  const abs = fileURLToPath(url)
344
422
  const r = relative(root, abs)
345
- if (r.startsWith('..')) throw new Error(`Path outside root: ${abs}`)
423
+ if (relativeEscapes(r)) throw new Error(`Path outside root: ${abs}`)
346
424
  return r
347
425
  }
348
426
  const relEdge = (e) => (e.child ? { ...e, child: rel(e.child) } : e)