@exodus/stasis 1.0.0-beta.1 → 1.0.0-beta.10

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 (48) hide show
  1. package/README.md +32 -8
  2. package/bin/stasis.js +386 -213
  3. package/package.json +52 -12
  4. package/src/audit-corrections.js +96 -0
  5. package/src/audit.js +289 -64
  6. package/src/babel-worker.cjs +65 -0
  7. package/src/babel.js +131 -0
  8. package/src/bundle-from-lockfile.js +14 -22
  9. package/src/bundle-reader.js +77 -0
  10. package/src/cmd/build.js +96 -128
  11. package/src/cmd/bundle.js +1121 -654
  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 +7 -19
  16. package/src/diff.js +80 -129
  17. package/src/installed-commits.js +182 -0
  18. package/src/json-stream.js +283 -0
  19. package/src/loader-mock.js +8 -15
  20. package/src/loaders/bash.js +49 -116
  21. package/src/loaders/cargo.js +1716 -0
  22. package/src/loaders/foundry.js +923 -0
  23. package/src/loaders/paths.js +33 -0
  24. package/src/loaders/php.js +375 -402
  25. package/src/loaders/rust.js +3254 -242
  26. package/src/loaders/solidity-ownership.js +531 -0
  27. package/src/loaders/solidity.js +412 -162
  28. package/src/loaders/toml.js +68 -0
  29. package/src/metro-resolver.js +150 -0
  30. package/src/metro-transformer.js +3 -2
  31. package/src/mock.js +57 -165
  32. package/src/parse.js +4 -16
  33. package/src/resolve-fields.js +370 -0
  34. package/src/resolve-node.js +419 -0
  35. package/src/resolve-typescript.js +541 -0
  36. package/src/rollup.js +5 -0
  37. package/src/sbom.js +120 -97
  38. package/src/scan.js +348 -180
  39. package/src/vfs-bundle/entries.js +144 -0
  40. package/src/vfs-bundle/github.js +202 -0
  41. package/src/vfs-bundle/prisma.js +439 -0
  42. package/src/vfs-bundle/soldeer.js +125 -0
  43. package/src/vfs-bundle/tree.js +647 -0
  44. package/src/vfs-bundle.js +80 -0
  45. package/src/why.js +434 -0
  46. package/src/apis/npm/index.js +0 -36
  47. package/src/apis/npm/semver.cjs +0 -44
  48. package/src/cmd/extract.js +0 -193
package/src/scan.js CHANGED
@@ -1,63 +1,70 @@
1
- import { readFileSync, existsSync } from 'node:fs'
2
- import { extname, resolve as resolvePath, relative } from 'node:path'
1
+ import { dirname, 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, isTypeDeclaration, relativeEscapes } from '@exodus/stasis-core/util'
7
+ import { inNodeModules, resolveTypescriptFallback, typescriptSiblings } 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.
18
-
19
- const SCRIPT_EXTS = new Set(['.js', '.cjs', '.mjs', '.ts', '.cts', '.mts'])
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.
12
+
13
+ // .jsx/.tsx need no flag: oxc parses them as JSX/TSX purely from the filename, so a reached one
14
+ // -- e.g. a package whose React Native entry is src/index.tsx -- is parsed and carried like any .ts.
15
+ const SCRIPT_EXTS = new Set(['.js', '.cjs', '.mjs', '.ts', '.cts', '.mts', '.jsx', '.tsx'])
20
16
  const RESOLVABLE_EXTS = new Set([...SCRIPT_EXTS, '.json'])
17
+ // The extensions whose name can't tell whether they hold JSX, parsed with JSX enabled only under
18
+ // `jsx` (React Native's JSX-in-.js convention). Excludes the .ts family: TypeScript's `<T>`
19
+ // generics collide with JSX, so tsc/oxc reserve JSX for .tsx — JSX-in-TS belongs in a .tsx file.
20
+ const JSX_EXTS = new Set(['.js', '.cjs', '.mjs'])
21
+
22
+ // Flow type syntax lives in the JS (non-TS) family; oxc parses TS natively, and running
23
+ // flow-remove-types over a .ts/.tsx file would corrupt TS-only constructs, so --flow only strips
24
+ // these. .jsx is included (it's JS + JSX, and can carry Flow types like .js); .tsx is not (it's
25
+ // TypeScript). flow-remove-types preserves JSX, so a stripped .jsx re-parses under lang:jsx.
26
+ const FLOW_EXTS = new Set(['.js', '.cjs', '.mjs', '.jsx'])
27
+ const TS_EXTS = new Set(['.ts', '.cts', '.mts', '.tsx'])
21
28
 
22
- // oxc-parser is a regular dependency, required lazily so non-JS bundlers (which
23
- // never reach getParser()) don't load the native parser.
29
+ // Required lazily so non-JS bundlers don't load the native oxc parser.
24
30
  let _parser
25
- function getParser() {
31
+ export function getParser() {
26
32
  _parser ??= createRequire(import.meta.url)('oxc-parser')
27
33
  return _parser
28
34
  }
29
35
 
30
- function packageType(file) {
31
- const pkg = findPackageJSON(pathToFileURL(file).toString())
32
- if (!pkg) return null
36
+ // flow-remove-types is an optional dependency, loaded lazily and only under --flow: it strips
37
+ // Flow type syntax down to plain JS before oxc (which parses JS/TS, not Flow) sees it. A missing
38
+ // dep is an env error, so surface it with an actionable install hint rather than a bare resolve throw.
39
+ let _flowRemoveTypes
40
+ function getFlowRemoveTypes() {
41
+ if (_flowRemoveTypes) return _flowRemoveTypes
33
42
  try {
34
- const type = JSON.parse(readFileSync(pkg, 'utf8')).type
35
- return type === 'module' || type === 'commonjs' ? type : null
36
- } catch {
37
- return null
43
+ _flowRemoveTypes = createRequire(import.meta.url)('flow-remove-types')
44
+ } catch (cause) {
45
+ throw new Error(
46
+ "--flow needs the optional 'flow-remove-types' dependency; install it (e.g. `npm i flow-remove-types`)",
47
+ { cause }
48
+ )
38
49
  }
50
+ return _flowRemoveTypes
39
51
  }
40
52
 
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)
53
+ // classifyFormat is the shared name->format authority (static and runtime bundles must agree); it
54
+ // returns null for the .js/.ts family whose module system comes from package.json `type` (null =
55
+ // detect from syntax, signaled up as null).
56
+ function formatForFile(file, host) {
57
+ const format = classifyFormat(file)
58
+ if (format != null) return format
59
+ const type = packageType(file, host)
55
60
  if (type === null) return null
56
- const suffix = ext === '.ts' ? '-typescript' : ''
57
- return `${type}${suffix}`
61
+ return `${type}${extname(file) === '.ts' ? '-typescript' : ''}`
58
62
  }
59
63
 
60
- function literalSpec(node) {
64
+ // The errors oxc reports for a parse, but its warnings and advice.
65
+ export const syntaxErrors = (parsed) => (parsed.errors ?? []).filter((e) => e.severity !== 'Warning' && e.severity !== 'Advice')
66
+
67
+ export function literalSpec(node) {
61
68
  if (!node) return null
62
69
  if (node.type === 'Literal' && typeof node.value === 'string') return node.value
63
70
  if (node.type === 'TemplateLiteral' && node.expressions.length === 0 && node.quasis.length === 1) {
@@ -66,9 +73,7 @@ function literalSpec(node) {
66
73
  return null
67
74
  }
68
75
 
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.
76
+ // Walks require()/import() calls only; ESM static imports/re-exports come from oxc module records.
72
77
  function findCallSpecifiers(ast, push) {
73
78
  const visit = (node) => {
74
79
  if (!node || typeof node !== 'object') return
@@ -90,11 +95,8 @@ function findCallSpecifiers(ast, push) {
90
95
  if (spec != null) push({ kind: 'dynamic-import', spec })
91
96
  else push({ kind: 'dynamic-import', dynamic: true })
92
97
  } 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.
98
+ // `import lib = require('./dep.cts')`: TS-only CJS import; record the real edge.
99
+ // Qualified-name form (`import A = B.C`) carries no file edge.
98
100
  if (node.moduleReference?.type === 'TSExternalModuleReference') {
99
101
  const spec = literalSpec(node.moduleReference.expression)
100
102
  if (spec != null) push({ kind: 'require', spec })
@@ -110,6 +112,67 @@ function findCallSpecifiers(ast, push) {
110
112
  visit(ast)
111
113
  }
112
114
 
115
+ // The import specifiers `program` holds, as { kind, spec } ({ kind, dynamic: true } for a computed
116
+ // one). STATEMENT-level type-ness decides an edge, matching what survives type erasure at runtime
117
+ // (verbatimModuleSyntax / Node's own type stripping): an `import type` / `export type` statement is
118
+ // erased whole, so no edge; a statement that merely lists inline `type` specifiers
119
+ // (`import { type A }`, `export { type B } from`) -- or none at all (`import {}`, `export {} from`)
120
+ // -- still loads its module, so its edge is real. oxc's module records can't draw that line
121
+ // (`import type { A }` and `import { type A }` yield identical all-isType entries, and
122
+ // `export {} from` yields no entry at all), so the statements are read off the AST, where
123
+ // importKind/exportKind carry it.
124
+ function collectSpecifiers(program) {
125
+ const specs = []
126
+ for (const node of program.body) {
127
+ if (node.type === 'ImportDeclaration') {
128
+ if (node.importKind !== 'type') specs.push({ kind: 'import', spec: node.source.value })
129
+ } else if (node.type === 'ExportNamedDeclaration') {
130
+ if (node.source != null && node.exportKind !== 'type') specs.push({ kind: 'export-from', spec: node.source.value })
131
+ } else if (node.type === 'ExportAllDeclaration') {
132
+ if (node.exportKind !== 'type') specs.push({ kind: 'export-from', spec: node.source.value })
133
+ }
134
+ }
135
+ findCallSpecifiers(program, (s) => specs.push(s))
136
+ return specs
137
+ }
138
+
139
+ // Whether `entries` (absolute paths) import TS sources by their OUTPUT names -- tsc's convention,
140
+ // which only --typescript resolves -- so the build can take --typescript as given: every entry is
141
+ // TypeScript, they hold at least one relative import naming a JS output (`./a.js`, or .jsx/.mjs/
142
+ // .cjs), and not one of those is on disk while the TS source it maps to is (`./a.ts`; see
143
+ // typescriptSiblings), outside node_modules as the resolver requires. Any part missing is no tell, and neither is an entry that doesn't parse as
144
+ // the scan parses it (Scan#parseFile; the scan reports it), nor one in node_modules, where
145
+ // --typescript maps nothing. Read off the entries alone, before the scan.
146
+ export function importsTypescriptByOutputName(entries, host = diskHost) {
147
+ if (!entries.every((entry) => TS_EXTS.has(extname(entry)) && !inNodeModules(entry, host))) return false
148
+ // Outside the try, as in the scan: a missing oxc-parser is an env error, not an entry's.
149
+ getParser()
150
+ const scanner = new Scan({ host })
151
+ const targets = []
152
+ for (const entry of entries) {
153
+ let result
154
+ try {
155
+ result = scanner.parseFile(entry)
156
+ } catch {
157
+ return false // unreadable: the scan reports it
158
+ }
159
+ if (result.crash !== undefined || result.errors.length > 0) return false
160
+ for (const { spec } of collectSpecifiers(result.parsed.program)) {
161
+ if (spec?.startsWith('./') || spec?.startsWith('../')) {
162
+ const target = resolvePath(dirname(entry), spec)
163
+ if (typescriptSiblings(target).length > 0) targets.push(target)
164
+ }
165
+ }
166
+ }
167
+ // tsc's pick, the first TS source on disk, as the resolver maps it: none where it lies in node_modules.
168
+ const isSource = (file) => !isTypeDeclaration(file) && (host.stat(file)?.isFile() ?? false)
169
+ const mapsTo = (target) => {
170
+ const pick = typescriptSiblings(target).find(isSource)
171
+ return pick !== undefined && !inNodeModules(pick, host)
172
+ }
173
+ return targets.length > 0 && targets.every((target) => host.stat(target) === null && mapsTo(target))
174
+ }
175
+
113
176
  function condKey(set) {
114
177
  return [...set].join(', ')
115
178
  }
@@ -121,29 +184,47 @@ export class Scan {
121
184
  parseErrors = []
122
185
  entries = new Set()
123
186
 
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 } = {}) {
187
+ // `conditions` is additive on top of the edge-context base set. `resolve` optionally
188
+ // replaces Node's resolver (resolve-fields.js): returns `{ url } | { empty } | { builtin } | null`
189
+ // (`empty` = a browser/react-native `false` redirect) and owns builtin handling when set.
190
+ // `jsx` opts the .js/.cjs/.mjs family into JSX syntax (React Native's JSX-in-.js convention);
191
+ // off by default because oxc, like tsc, only auto-enables JSX for .jsx/.tsx by extension.
192
+ // `flow`: strip Flow type syntax from JS-family sources before parsing (see #scanFile).
193
+ // `typescript`: retry a failed resolution with tsc's mapping (see #typescriptResolve), with
194
+ // `typescriptPaths` (a tsconfig paths matcher; see resolveTypescriptFallback) adding tsconfig alias support; only consulted
195
+ // on the built-in (Node) resolver -- a custom `resolve` owns its own TS handling. Without it, the
196
+ // mapping only names the file a miss would resolve to under --typescript (see #typescriptCandidate).
197
+ // `typescriptResolve`: the custom `resolve`'s --typescript twin (same shape), consulted only for
198
+ // that hint; null when there is no custom resolver or `typescript` is on.
199
+ // `resources` (a `parseResourcesOption` Set of extensions/filenames): reached files matching it
200
+ // are carried as opaque resources (bytes only) rather than rejected as un-carryable -- for graphs
201
+ // that aren't fully loadable in JS (e.g. Metro consuming .png/.svg assets).
202
+ // `host`: the filesystem the walk reads and resolves through (@exodus/stasis-core/host).
203
+ constructor({ conditions = [], resolve = null, jsx = false, flow = false, typescript = false, typescriptPaths = null, typescriptResolve = null, resources = new Set(), host = diskHost } = {}) {
138
204
  this.extraConditions = [...conditions]
139
205
  this.customResolve = resolve
206
+ this.typescriptResolve = typescriptResolve
207
+ this.jsx = jsx
208
+ this.flow = flow
209
+ this.typescript = typescript
210
+ this.typescriptPaths = typescriptPaths
211
+ this.resources = resources
212
+ this.host = host
213
+ }
214
+
215
+ // A reached file whose extension/filename is in the --resources allowlist: carried as an opaque
216
+ // resource (bytes only, no parse, no edges), not rejected as "a source bundle can't carry".
217
+ // classifyExtension returns 'resource' only for non-code names in the allowlist, so this never
218
+ // shadows a script/json extension (parseResourcesOption rejects code extensions).
219
+ #isResource(file) {
220
+ return this.resources.size > 0 && classifyExtension(file, this.resources) === 'resource'
140
221
  }
141
222
 
142
223
  walk(entries) {
143
224
  const queue = []
144
225
  for (const entry of entries) {
145
226
  const abs = resolvePath(entry)
146
- assert.ok(existsSync(abs), `entry not found: ${abs}`)
227
+ assert.ok(this.host.stat(abs) !== null, `entry not found: ${abs}`)
147
228
  const url = pathToFileURL(abs).toString()
148
229
  this.entries.add(url)
149
230
  queue.push(url)
@@ -156,137 +237,224 @@ export class Scan {
156
237
  return this
157
238
  }
158
239
 
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])
240
+ // Node's resolver: must match the runtime resolver's condition set (omitting one resolves
241
+ // `exports`-gated packages to a different file) AND its order: the recorded key must equal the
242
+ // runtime hooks' string for exact lookups. Extras go where Node puts user conditions in each set.
243
+ // A custom resolver (--mainFields/--metro) resolves for a bundler instead, so it gets what
244
+ // esbuild, webpack and Metro all assert: `import`/`require`, `default` (which Node's resolver
245
+ // matches anyway) and the extras, never `node`, `node-addons` or `module-sync`; the same set as
246
+ // resolve-fields.js's resolveConditions.
247
+ #conditionSet(context) {
248
+ if (this.customResolve) return new Set([context, 'default', ...this.extraConditions])
249
+ return context === 'import'
250
+ ? new Set(['node', 'import', 'module-sync', 'node-addons', ...this.extraConditions])
251
+ : new Set(['require', 'node', 'node-addons', ...this.extraConditions, 'module-sync'])
171
252
  }
172
253
 
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).
254
+ // `recovered`: false = parser crashed, nothing salvaged; true = oxc returned but we
255
+ // discarded its partial module records (module-family only; see #scanFile).
177
256
  #recordParseError(url, format, message, recovered) {
178
257
  this.files.set(url, { format, edges: [], parseError: message })
179
258
  this.parseErrors.push({ url, format, message, recovered })
180
259
  }
181
260
 
182
- #scanFile(url, queue) {
183
- const file = fileURLToPath(url)
184
- const ext = extname(file)
185
- if (ext === '.json') {
186
- this.files.set(url, { format: 'json', edges: [] })
187
- return
261
+ // --typescript: resolve `spec` the way tsc maps TS sources, invoked only AFTER Node's own
262
+ // resolution missed -- so an on-disk `.js` (or any Node-resolvable target) always wins, and the
263
+ // mapping only completes a failed resolution, never rewrites a successful one. The rules
264
+ // (extension substitution, extensionless/index completion, manifest-target and tsconfig-paths
265
+ // mapping) live in the shared dispatcher (resolve-typescript.js), so this resolver and the
266
+ // legacy-field one cannot drift. The hit is realpathed to match req.resolve's canonical output
267
+ // (every other recorded edge is a realpath).
268
+ #typescriptResolve(parentFile, spec, conditions) {
269
+ const hit = resolveTypescriptFallback(parentFile, spec, {
270
+ conditions,
271
+ paths: this.typescriptPaths,
272
+ host: this.host,
273
+ })
274
+ if (hit == null) return null
275
+ try {
276
+ return this.host.realpath(hit)
277
+ } catch {
278
+ return null
188
279
  }
189
- if (!SCRIPT_EXTS.has(ext)) {
190
- this.files.set(url, { format: null, edges: [] })
191
- return
280
+ }
281
+
282
+ // The file: URL a miss would resolve to under --typescript, or null: a hint for the unresolved
283
+ // report only, never an edge. A custom resolver asks its --typescript twin; the built-in one
284
+ // tsc's mapping, as #typescriptResolve gives it under --typescript.
285
+ #typescriptCandidate(parentFile, spec, conditions) {
286
+ if (this.typescript) return null
287
+ if (this.customResolve) return this.typescriptResolve?.(parentFile, spec, conditions)?.url ?? null
288
+ const hit = this.#typescriptResolve(parentFile, spec, conditions)
289
+ return hit == null ? null : pathToFileURL(hit).toString()
290
+ }
291
+
292
+ // Strip Flow type syntax to plain JS via the optional flow-remove-types dep (resolved lazily; a
293
+ // missing dep is an env error and propagates). Only invoked as a parse fallback (see #scanFile),
294
+ // so plain-JS files never reach it. Returns the rewritten source, or null when the transform
295
+ // itself fails on this file -- the caller then keeps oxc's original parse error.
296
+ #stripFlow(src, file) {
297
+ const flowRemoveTypes = getFlowRemoveTypes()
298
+ try {
299
+ // `all: true` ignores the @flow/@noflow pragma (RN sources mix both); the transform blanks
300
+ // types with whitespace, so byte offsets and every import specifier are preserved.
301
+ return flowRemoveTypes(src, { all: true }).toString()
302
+ } catch (cause) {
303
+ console.warn(`[stasis] --flow: could not strip Flow types from ${file}; leaving it to oxc (${cause.message})`)
304
+ return null
192
305
  }
306
+ }
193
307
 
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')
308
+ // Parse `file` (a script extension) as Node loads it: under the format its name and package
309
+ // declare, or for a typeless .js/.ts the one Node detects from its syntax. -> { format, parsed,
310
+ // errors } (`errors`: what the parse still reports), or { declared, crash } where the parser
311
+ // itself threw on the file, salvaging nothing.
312
+ parseFile(file) {
313
+ const ext = extname(file)
314
+ // declared === null: typeless package; Node decides .js/.ts by syntax, resolved after parse.
315
+ const declared = formatForFile(file, this.host)
316
+ const src = this.host.readFile(file).toString('utf8')
198
317
 
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.
318
+ // Resolve the parser outside the try: a missing oxc-parser is an env error, not this
319
+ // file's — it must not be swallowed into a silent zero-edge leaf.
202
320
  const parser = getParser()
321
+ // oxc picks JS/TS + JSX from the filename; the .js family lands on plain JS (no JSX), so
322
+ // force the JSX variant when opted in (`--jsx`) to parse React Native's JSX-in-.js source.
323
+ const lang = this.jsx && JSX_EXTS.has(ext) ? 'jsx' : undefined
324
+ const parseOptions = (sourceType) => (lang ? { sourceType, lang } : { sourceType })
325
+ // `commonjs`, not `script`: Node runs CJS inside its module wrapper function, where a top-level
326
+ // `return` or `new.target` is legal; oxc's `commonjs` accepts both and is otherwise `script`.
327
+ const baseSourceType = declared === null ? 'unambiguous'
328
+ : declared.startsWith('module') ? 'module' : 'commonjs'
203
329
  let parsed
204
330
  try {
205
- parsed = parser.parseSync(file, src, {
206
- sourceType: declared === null ? 'unambiguous'
207
- : declared.startsWith('module') ? 'module' : 'script',
208
- })
331
+ parsed = parser.parseSync(file, src, parseOptions(baseSourceType))
209
332
  } catch (cause) {
210
- this.#recordParseError(url, declared, cause.message, false)
211
- return
333
+ return { declared, crash: cause.message }
212
334
  }
213
335
 
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.)
226
- const nonWarning = (p) => (p.errors ?? []).filter((e) => e.severity !== 'Warning' && e.severity !== 'Advice')
227
- let errors = nonWarning(parsed)
228
-
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.
336
+ // Match Node's syntax detection for typeless packages: ESM syntax picks the module variant
337
+ // (mislabeling as commonjs makes `--bundle=load` feed the wrong translator). Only .ts takes the
338
+ // `-typescript` suffix; .jsx/.tsx deliberately land on plain module/commonjs (like a JSX-in-.js
339
+ // file) — they're bundler-transformed, keyed by extension in `stasis build`, not Node-strippable,
340
+ // so the "needs a JSX/TS transform" signal rides the .jsx/.tsx extension, not the format tag.
341
+ const tsSuffix = ext === '.ts' ? '-typescript' : ''
342
+ const detectedFormat = (p) => `${p.module.hasModuleSyntax ? 'module' : 'commonjs'}${tsSuffix}`
343
+ let format = declared ?? detectedFormat(parsed)
344
+
345
+ // oxc recovers from syntax errors (reports them in parsed.errors) rather than throwing;
346
+ // Warning/Advice severities aren't real parse errors.
347
+ let errors = syntaxErrors(parsed)
348
+
349
+ // oxc's `unambiguous` mode counts top-level await as module syntax (since 0.109; before, a
350
+ // TLA-only ESM file parsed here as a broken script). Keep mirroring Node for any script parse
351
+ // that still fails: retry as module and prefer a clean module parse.
236
352
  if (errors.length > 0 && declared === null && !format.startsWith('module')) {
237
353
  try {
238
- const asModule = parser.parseSync(file, src, { sourceType: 'module' })
239
- if (nonWarning(asModule).length === 0) {
354
+ const asModule = parser.parseSync(file, src, parseOptions('module'))
355
+ if (syntaxErrors(asModule).length === 0) {
240
356
  parsed = asModule
241
- format = `module${ext === '.ts' ? '-typescript' : ''}`
357
+ format = `module${tsSuffix}`
242
358
  errors = []
243
359
  }
244
360
  } catch { /* keep the script parse and its recorded errors */ }
245
361
  }
246
362
 
363
+ // A typeless file Node detects as CJS runs inside the module wrapper too, but `unambiguous`
364
+ // parses it as a plain script, so a top-level `return` still fails there: retry as `commonjs`
365
+ // and prefer a clean parse (the detected commonjs format stands).
366
+ if (errors.length > 0 && declared === null && !format.startsWith('module')) {
367
+ try {
368
+ const asCommonjs = parser.parseSync(file, src, parseOptions('commonjs'))
369
+ if (syntaxErrors(asCommonjs).length === 0) {
370
+ parsed = asCommonjs
371
+ errors = []
372
+ }
373
+ } catch { /* keep the script parse and its recorded errors */ }
374
+ }
375
+
376
+ // --flow: oxc parses JS/TS but not Flow, so a Flow-typed .js/.cjs/.mjs file lands here with
377
+ // parse errors. Only now -- after a raw oxc parse actually failed -- strip Flow types and
378
+ // re-parse, so plain-JS files never pay the transform cost. Only the parse input is rewritten;
379
+ // the bundle stores the pristine on-disk source (State/buildResolvedJsBundle re-read it), so
380
+ // attestation still covers the real bytes. A clean re-parse replaces the errored one; otherwise
381
+ // we keep the original result so a genuine (non-Flow) parse error still surfaces.
382
+ if (errors.length > 0 && this.flow && FLOW_EXTS.has(ext)) {
383
+ const flowSrc = this.#stripFlow(src, file)
384
+ if (flowSrc !== null) {
385
+ try {
386
+ const asFlow = parser.parseSync(file, flowSrc, parseOptions(baseSourceType))
387
+ if (syntaxErrors(asFlow).length === 0) {
388
+ parsed = asFlow
389
+ format = declared ?? detectedFormat(parsed)
390
+ errors = []
391
+ }
392
+ } catch { /* keep the original parse + its errors */ }
393
+ }
394
+ }
395
+
396
+ return { format, parsed, errors }
397
+ }
398
+
399
+ #scanFile(url, queue) {
400
+ const file = fileURLToPath(url)
401
+ const ext = extname(file)
402
+ if (ext === '.json') {
403
+ this.files.set(url, { format: 'json', edges: [] })
404
+ return
405
+ }
406
+ if (this.#isResource(file)) {
407
+ // Allowlisted asset: carry it as a resource with no parse and no edges. `resource: true` is
408
+ // the authoritative signal (both bundle builders key off it); the concrete format
409
+ // (resource vs resource:base64) is byte-derived by the builder, so leave it unset here.
410
+ this.files.set(url, { format: null, edges: [], resource: true })
411
+ return
412
+ }
413
+ if (!SCRIPT_EXTS.has(ext)) {
414
+ this.files.set(url, { format: null, edges: [] })
415
+ return
416
+ }
417
+
418
+ const result = this.parseFile(file)
419
+ if (result.crash !== undefined) {
420
+ this.#recordParseError(url, result.declared, result.crash, false)
421
+ return
422
+ }
423
+ const { format, parsed, errors } = result
424
+
247
425
  let parseError
248
426
  if (errors.length > 0) {
249
427
  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.
428
+ // A partially-parsed module file may be missing link-time edges we can't enumerate.
252
429
  if (format.startsWith('module')) {
253
430
  this.#recordParseError(url, format, parseError, true)
254
431
  return
255
432
  }
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.
433
+ // Script (CJS) files: oxc's recovered AST still yields the require()/import() edges — salvage them.
260
434
  this.parseErrors.push({ url, format, message: parseError, recovered: true })
261
435
  }
262
436
 
263
- 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 })
273
- }
274
- }
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
- findCallSpecifiers(parsed.program, (s) => specs.push(s))
437
+ const specs = collectSpecifiers(parsed.program)
284
438
 
285
- const conditions = this.#conditionsFor(format)
286
- const key = condKey(conditions)
287
- const req = createRequire(file)
439
+ // The edge KIND picks the resolution context, not the parent's format: createRequire()d
440
+ // require() inside ESM resolves under REQUIRE conditions, literal import() inside CJS under
441
+ // IMPORT conditions -- the parent-format set baked the wrong `exports` branch into the bundle.
442
+ // The custom (legacy-field) resolver keeps the format set: bundlers don't split per edge.
443
+ const formatContext = ['module', 'module-typescript'].includes(format) ? 'import' : 'require'
288
444
  const edges = []
289
- const specMap = new Map()
445
+ const specMaps = new Map() // condition key -> specifier -> child URL
446
+ const addChild = (s, key, childURL, childPath) => {
447
+ edges.push({ ...s, child: childURL })
448
+ if (!specMaps.has(key)) specMaps.set(key, new Map())
449
+ specMaps.get(key).set(s.spec, childURL)
450
+ if (RESOLVABLE_EXTS.has(extname(childPath)) || this.#isResource(childPath)) queue.push(childURL)
451
+ }
452
+ // Records the file --typescript would land the miss on too (typescriptURL), resolved as the miss was.
453
+ const addUnresolved = (s, reason, conditions) => {
454
+ edges.push({ ...s, error: reason })
455
+ const typescriptURL = this.#typescriptCandidate(file, s.spec, conditions)
456
+ this.unresolved.push({ parentURL: url, kind: s.kind, spec: s.spec, reason, ...(typescriptURL ? { typescriptURL } : {}) })
457
+ }
290
458
 
291
459
  for (const s of specs) {
292
460
  if (s.dynamic) {
@@ -294,10 +462,11 @@ export class Scan {
294
462
  this.unresolved.push({ parentURL: url, kind: s.kind, reason: 'dynamic' })
295
463
  continue
296
464
  }
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).
465
+ const context = this.customResolve ? formatContext : (s.kind === 'require' ? 'require' : 'import')
466
+ const conditions = this.#conditionSet(context)
467
+ const key = condKey(conditions)
468
+ // Custom (legacy-field) resolver owns builtin handling and the `empty` outcome (browser/RN
469
+ // `false` redirect): an empty edge is recorded but neither queued nor counted as unresolved.
301
470
  if (this.customResolve) {
302
471
  const r = this.customResolve(file, s.spec, conditions)
303
472
  if (r?.builtin) {
@@ -305,12 +474,9 @@ export class Scan {
305
474
  } else if (r?.empty) {
306
475
  edges.push({ ...s, empty: true })
307
476
  } 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)
477
+ addChild(s, key, r.url, fileURLToPath(r.url))
311
478
  } 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' })
479
+ addUnresolved(s, 'MODULE_NOT_FOUND', conditions)
314
480
  }
315
481
  continue
316
482
  }
@@ -318,20 +484,22 @@ export class Scan {
318
484
  edges.push({ ...s, builtin: true })
319
485
  continue
320
486
  }
487
+ let childPath
321
488
  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)
489
+ childPath = this.host.resolve(file, s.spec, conditions)
328
490
  } 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 })
491
+ // --typescript: complete the miss with tsc's mapping (see #typescriptResolve); the edge
492
+ // stays keyed by the ORIGINAL specifier -- only the target is the mapped file.
493
+ if (this.typescript) childPath = this.#typescriptResolve(file, s.spec, conditions)
494
+ if (childPath == null) {
495
+ addUnresolved(s, cause.code ?? cause.message, conditions)
496
+ continue
497
+ }
331
498
  }
499
+ addChild(s, key, pathToFileURL(childPath).toString(), childPath)
332
500
  }
333
501
 
334
- if (specMap.size > 0) {
502
+ for (const [key, specMap] of specMaps) {
335
503
  if (!this.imports.has(key)) this.imports.set(key, new Map())
336
504
  this.imports.get(key).set(url, specMap)
337
505
  }
@@ -342,7 +510,7 @@ export class Scan {
342
510
  const rel = (url) => {
343
511
  const abs = fileURLToPath(url)
344
512
  const r = relative(root, abs)
345
- if (r.startsWith('..')) throw new Error(`Path outside root: ${abs}`)
513
+ if (relativeEscapes(r)) throw new Error(`Path outside root: ${abs}`)
346
514
  return r
347
515
  }
348
516
  const relEdge = (e) => (e.child ? { ...e, child: rel(e.child) } : e)
@@ -362,7 +530,7 @@ export class Scan {
362
530
  ),
363
531
  ])
364
532
  ),
365
- unresolved: this.unresolved.map(({ parentURL, ...rest }) => ({ parent: rel(parentURL), ...rest })),
533
+ unresolved: this.unresolved.map(({ parentURL, typescriptURL, ...rest }) => ({ parent: rel(parentURL), ...rest, ...(typescriptURL ? { typescript: rel(typescriptURL) } : {}) })),
366
534
  parseErrors: this.parseErrors.map(({ url, ...rest }) => ({ file: rel(url), ...rest })),
367
535
  }
368
536
  }