@exodus/stasis 1.0.0-beta.3 → 1.0.0-beta.6

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.
package/src/sbom.js CHANGED
@@ -1,4 +1,4 @@
1
- import semver from './apis/npm/semver.cjs'
1
+ import { compareVersions, valid } from '@preventive/upstream/semver.js'
2
2
  import pkg from '../package.json' with { type: 'json' }
3
3
 
4
4
  // Derive an SPDX 2.3 or CycloneDX 1.5 SBOM from already-parsed stasis artifacts (`Bundle`/`Lockfile`).
@@ -10,7 +10,9 @@ const DEFAULT_TOOL = { name: '@exodus/stasis', version: pkg.version, vendor: 'Ex
10
10
  // Web Crypto (global) instead of node:crypto, keeping this module dependency-light.
11
11
  const newUuid = () => globalThis.crypto.randomUUID()
12
12
 
13
- // purl types we can mint; an ecosystem with no registered type (`soldeer`) gets no purl rather than a fabricated one.
13
+ // purl types we can mint; an ecosystem with no registered type (`soldeer`) gets no purl rather than a fabricated one,
14
+ // and so does a crate vendored from git or from a source not known (`cargo-git`, `cargo-unknown`): `pkg:cargo` names
15
+ // crates.io's crate of that name, which it may not be.
14
16
  const PURL_TYPES = new Set(['npm', 'composer', 'cargo', 'github'])
15
17
 
16
18
  // Infer an artifact's ecosystem from its loader formats: PHP bundles map to Composer, everything
@@ -27,11 +29,12 @@ function detectEcosystem(artifact) {
27
29
  }
28
30
 
29
31
  // Legacy scope classification (pre-`ecosystem` artifacts): non-`node_modules` dirs are first-party,
30
- // except in a Composer bundle where vendor packages live outside `node_modules`.
32
+ // except in a Composer bundle where vendor packages live outside `node_modules`. `node_modules` is
33
+ // a path segment, as stasis-core's hasNodeModulesSegment has it: `foo_node_modules/x` is first-party.
31
34
  function classifyScope(ecosystem, dir) {
32
35
  if (dir === '.') return 'workspace'
33
36
  if (ecosystem === 'composer') return 'dependency'
34
- return dir.includes('node_modules') ? 'dependency' : 'workspace'
37
+ return dir.split('/').includes('node_modules') ? 'dependency' : 'workspace'
35
38
  }
36
39
 
37
40
  // Split a package name into purl/CycloneDX namespace (group) + bare name: npm scopes, Composer
@@ -49,6 +52,8 @@ function splitName(ecosystem, name) {
49
52
  // Build a Package URL (purl) for a known ecosystem, or null when it has no purl type. Each path
50
53
  // segment is percent-encoded (npm scope's `@` -> `%40`) while the `/` namespace separator and the
51
54
  // `@` before the version stay literal; Composer and GitHub names are lowercased, per the purl spec.
55
+ // The version qualifier is optional in the purl spec, so a versionless workspace package mints a
56
+ // bare-name purl rather than a fabricated version.
52
57
  export function buildPurl(ecosystem, rawName, version) {
53
58
  if (!PURL_TYPES.has(ecosystem)) return null
54
59
  let { group, name } = splitName(ecosystem, rawName)
@@ -57,23 +62,29 @@ export function buildPurl(ecosystem, rawName, version) {
57
62
  name = name.toLowerCase()
58
63
  }
59
64
  const namespace = group ? `${encodeURIComponent(group)}/` : ''
60
- return `pkg:${ecosystem}/${namespace}${encodeURIComponent(name)}@${encodeURIComponent(version)}`
65
+ const at = version == null ? '' : `@${encodeURIComponent(version)}`
66
+ return `pkg:${ecosystem}/${namespace}${encodeURIComponent(name)}${at}`
61
67
  }
62
68
 
63
69
  function compareComponents(a, b) {
64
70
  if (a.name !== b.name) return a.name < b.name ? -1 : 1
65
- if (a.version !== b.version) {
66
- if (semver.valid(a.version) && semver.valid(b.version)) return semver.compare(a.version, b.version)
67
- return a.version < b.version ? -1 : 1
71
+ // A missing version sorts first, as '' (comparing raw undefined would be order-unstable).
72
+ const [av, bv] = [a.version ?? '', b.version ?? '']
73
+ if (av !== bv) {
74
+ if (valid(av) && valid(bv)) return compareVersions(av, bv)
75
+ return av < bv ? -1 : 1
68
76
  }
69
77
  return a.ecosystem < b.ecosystem ? -1 : a.ecosystem > b.ecosystem ? 1 : 0
70
78
  }
71
79
 
72
80
  // Collect the deduplicated, sorted set of packages across parsed artifacts. Unlike `stasis audit`
73
81
  // (installed deps only), an SBOM is the *full* bill of materials, so workspace/first-party packages
74
- // are included too, tagged scope 'workspace' vs 'dependency'. Buckets without a name+version are
75
- // skipped. Dedup is by ecosystem+name+version; a package seen as workspace in any artifact stays
76
- // workspace regardless of argument order, so output doesn't depend on how artifacts are listed.
82
+ // are included too, tagged scope 'workspace' vs 'dependency'. Buckets without a name are skipped,
83
+ // and so are DEPENDENCY buckets without a version (v0 partial metadata: unverifiable identity) --
84
+ // but a workspace package may legitimately omit version (private/unpublished) and is kept, since
85
+ // dropping it would silently lose the document's own first-party subject. Dedup is by
86
+ // ecosystem+name+version; a package seen as workspace in any artifact stays workspace regardless
87
+ // of argument order, so output doesn't depend on how artifacts are listed.
77
88
  export function collectComponents(artifacts) {
78
89
  const seen = new Map()
79
90
  for (const artifact of artifacts) {
@@ -83,7 +94,7 @@ export function collectComponents(artifacts) {
83
94
  const tagged = records.some(([, m]) => m.ecosystem !== undefined)
84
95
  const fileEcosystem = detectEcosystem(artifact)
85
96
  for (const [dir, { name, version, ecosystem }] of records) {
86
- if (!name || !version) continue
97
+ if (!name) continue
87
98
  let scope, eco
88
99
  if (ecosystem !== undefined) {
89
100
  scope = 'dependency'
@@ -96,13 +107,14 @@ export function collectComponents(artifacts) {
96
107
  scope = classifyScope(fileEcosystem, dir)
97
108
  eco = fileEcosystem
98
109
  }
99
- const key = `${eco} ${name} ${version}`
110
+ if (!version && scope !== 'workspace') continue
111
+ const key = `${eco} ${name} ${version ?? ''}`
100
112
  const existing = seen.get(key)
101
113
  if (existing) {
102
114
  if (scope === 'workspace') existing.scope = 'workspace'
103
115
  continue
104
116
  }
105
- seen.set(key, { name, version, scope, ecosystem: eco, purl: buildPurl(eco, name, version) })
117
+ seen.set(key, { name, version: version ?? undefined, scope, ecosystem: eco, purl: buildPurl(eco, name, version) })
106
118
  }
107
119
  }
108
120
  return [...seen.values()].toSorted(compareComponents)
@@ -121,8 +133,8 @@ function selectPrimary(components) {
121
133
  const isoSeconds = (date) => date.toISOString().replace(/\.\d{3}Z$/u, 'Z')
122
134
 
123
135
  // A CycloneDX bom-ref must uniquely identify a component; use the purl when present, else
124
- // ecosystem+name+version (unique by construction -- it's the dedup key).
125
- const bomRef = (c) => c.purl ?? `${c.ecosystem}:${c.name}@${c.version}`
136
+ // ecosystem+name(+version) (unique by construction -- it's the dedup key).
137
+ const bomRef = (c) => c.purl ?? `${c.ecosystem}:${c.name}${c.version == null ? '' : `@${c.version}`}`
126
138
 
127
139
  // Render an SPDX 2.3 document. It DESCRIBES its primary component, which DEPENDS_ON each installed
128
140
  // dependency (flat graph). With no single primary, the document DESCRIBES the workspace packages,
@@ -135,7 +147,8 @@ export function toSpdx(components, { tool = DEFAULT_TOOL, now = new Date(), uuid
135
147
  const packages = ordered.map((c) => ({
136
148
  SPDXID: ids.get(c),
137
149
  name: c.name,
138
- versionInfo: c.version,
150
+ // SPDX versionInfo is optional; omit it for a versionless workspace package.
151
+ ...(c.version == null ? {} : { versionInfo: c.version }),
139
152
  downloadLocation: 'NOASSERTION',
140
153
  filesAnalyzed: false,
141
154
  ...(c.purl && {
@@ -159,7 +172,9 @@ export function toSpdx(components, { tool = DEFAULT_TOOL, now = new Date(), uuid
159
172
  }
160
173
  }
161
174
 
162
- const docName = primary ? `${primary.name}@${primary.version}` : 'stasis-sbom'
175
+ const docName = primary
176
+ ? (primary.version == null ? primary.name : `${primary.name}@${primary.version}`)
177
+ : 'stasis-sbom'
163
178
  return {
164
179
  spdxVersion: 'SPDX-2.3',
165
180
  dataLicense: 'CC0-1.0',
@@ -186,7 +201,8 @@ export function toCyclonedx(components, { tool = DEFAULT_TOOL, now = new Date(),
186
201
  'bom-ref': bomRef(c),
187
202
  ...(group && { group }),
188
203
  name,
189
- version: c.version,
204
+ // CycloneDX 1.5 `version` is optional; omit it for a versionless workspace package.
205
+ ...(c.version == null ? {} : { version: c.version }),
190
206
  ...(c.purl && { purl: c.purl }),
191
207
  }
192
208
  }
package/src/scan.js CHANGED
@@ -1,35 +1,34 @@
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
3
  import { createRequire, isBuiltin } from 'node:module'
5
4
  import assert from 'node:assert/strict'
6
5
  import { packageType } from '@exodus/stasis-core/bundle-util'
7
- import { classifyExtension, classifyFormat } from '@exodus/stasis-core/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'
8
9
 
9
10
  // Static require/import graph walker: parses source, never loads or executes user code.
10
11
  // Dynamic specifiers (`require(name)`, `import('./'+x)`) are recorded as unresolved.
11
12
 
12
- const SCRIPT_EXTS = new Set(['.js', '.cjs', '.mjs', '.ts', '.cts', '.mts'])
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'])
13
16
  const RESOLVABLE_EXTS = new Set([...SCRIPT_EXTS, '.json'])
14
- // Under `jsx`, these get parsed with JSX enabled. Excludes the .ts family: TypeScript's `<T>`
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>`
15
19
  // generics collide with JSX, so tsc/oxc reserve JSX for .tsx — JSX-in-TS belongs in a .tsx file.
16
20
  const JSX_EXTS = new Set(['.js', '.cjs', '.mjs'])
17
- // The inherently-JSX extensions (oxc parses them as JSX/TSX purely from the filename, no flag).
18
- // Scanned + carried only under `--jsx`: off by default the scanner treats them as opaque leaves,
19
- // so a bundle whose toolchain can't build JSX never silently ships .jsx/.tsx source. Under `--jsx`
20
- // they join SCRIPT_EXTS/RESOLVABLE_EXTS (see the constructor) so a reached .jsx/.tsx dependency
21
- // — e.g. a package whose React Native entry is src/index.tsx — is parsed and carried like any .ts.
22
- const JSX_FILE_EXTS = new Set(['.jsx', '.tsx'])
23
21
 
24
22
  // Flow type syntax lives in the JS (non-TS) family; oxc parses TS natively, and running
25
23
  // flow-remove-types over a .ts/.tsx file would corrupt TS-only constructs, so --flow only strips
26
24
  // these. .jsx is included (it's JS + JSX, and can carry Flow types like .js); .tsx is not (it's
27
25
  // TypeScript). flow-remove-types preserves JSX, so a stripped .jsx re-parses under lang:jsx.
28
26
  const FLOW_EXTS = new Set(['.js', '.cjs', '.mjs', '.jsx'])
27
+ const TS_EXTS = new Set(['.ts', '.cts', '.mts', '.tsx'])
29
28
 
30
29
  // Required lazily so non-JS bundlers don't load the native oxc parser.
31
30
  let _parser
32
- function getParser() {
31
+ export function getParser() {
33
32
  _parser ??= createRequire(import.meta.url)('oxc-parser')
34
33
  return _parser
35
34
  }
@@ -54,15 +53,18 @@ function getFlowRemoveTypes() {
54
53
  // classifyFormat is the shared name->format authority (static and runtime bundles must agree); it
55
54
  // returns null for the .js/.ts family whose module system comes from package.json `type` (null =
56
55
  // detect from syntax, signaled up as null).
57
- function formatForFile(file) {
56
+ function formatForFile(file, host) {
58
57
  const format = classifyFormat(file)
59
58
  if (format != null) return format
60
- const type = packageType(file)
59
+ const type = packageType(file, host)
61
60
  if (type === null) return null
62
61
  return `${type}${extname(file) === '.ts' ? '-typescript' : ''}`
63
62
  }
64
63
 
65
- 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) {
66
68
  if (!node) return null
67
69
  if (node.type === 'Literal' && typeof node.value === 'string') return node.value
68
70
  if (node.type === 'TemplateLiteral' && node.expressions.length === 0 && node.quasis.length === 1) {
@@ -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
  }
@@ -127,19 +190,26 @@ export class Scan {
127
190
  // `jsx` opts the .js/.cjs/.mjs family into JSX syntax (React Native's JSX-in-.js convention);
128
191
  // off by default because oxc, like tsc, only auto-enables JSX for .jsx/.tsx by extension.
129
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.
130
199
  // `resources` (a `parseResourcesOption` Set of extensions/filenames): reached files matching it
131
200
  // are carried as opaque resources (bytes only) rather than rejected as un-carryable -- for graphs
132
201
  // that aren't fully loadable in JS (e.g. Metro consuming .png/.svg assets).
133
- constructor({ conditions = [], resolve = null, jsx = false, flow = false, resources = new Set() } = {}) {
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 } = {}) {
134
204
  this.extraConditions = [...conditions]
135
205
  this.customResolve = resolve
206
+ this.typescriptResolve = typescriptResolve
136
207
  this.jsx = jsx
137
208
  this.flow = flow
209
+ this.typescript = typescript
210
+ this.typescriptPaths = typescriptPaths
138
211
  this.resources = resources
139
- // --jsx widens the script/resolvable extension sets to include .jsx/.tsx, so those files are
140
- // parsed (not left as opaque leaves) and queued when reached. Off by default the base sets apply.
141
- this.scriptExts = jsx ? new Set([...SCRIPT_EXTS, ...JSX_FILE_EXTS]) : SCRIPT_EXTS
142
- this.resolvableExts = jsx ? new Set([...RESOLVABLE_EXTS, ...JSX_FILE_EXTS]) : RESOLVABLE_EXTS
212
+ this.host = host
143
213
  }
144
214
 
145
215
  // A reached file whose extension/filename is in the --resources allowlist: carried as an opaque
@@ -154,7 +224,7 @@ export class Scan {
154
224
  const queue = []
155
225
  for (const entry of entries) {
156
226
  const abs = resolvePath(entry)
157
- assert.ok(existsSync(abs), `entry not found: ${abs}`)
227
+ assert.ok(this.host.stat(abs) !== null, `entry not found: ${abs}`)
158
228
  const url = pathToFileURL(abs).toString()
159
229
  this.entries.add(url)
160
230
  queue.push(url)
@@ -183,6 +253,37 @@ export class Scan {
183
253
  this.parseErrors.push({ url, format, message, recovered })
184
254
  }
185
255
 
256
+ // --typescript: resolve `spec` the way tsc maps TS sources, invoked only AFTER Node's own
257
+ // resolution missed -- so an on-disk `.js` (or any Node-resolvable target) always wins, and the
258
+ // mapping only completes a failed resolution, never rewrites a successful one. The rules
259
+ // (extension substitution, extensionless/index completion, manifest-target and tsconfig-paths
260
+ // mapping) live in the shared dispatcher (resolve-typescript.js), so this resolver and the
261
+ // legacy-field one cannot drift. The hit is realpathed to match req.resolve's canonical output
262
+ // (every other recorded edge is a realpath).
263
+ #typescriptResolve(parentFile, spec, conditions) {
264
+ const hit = resolveTypescriptFallback(parentFile, spec, {
265
+ conditions,
266
+ paths: this.typescriptPaths,
267
+ host: this.host,
268
+ })
269
+ if (hit == null) return null
270
+ try {
271
+ return this.host.realpath(hit)
272
+ } catch {
273
+ return null
274
+ }
275
+ }
276
+
277
+ // The file: URL a miss would resolve to under --typescript, or null: a hint for the unresolved
278
+ // report only, never an edge. A custom resolver asks its --typescript twin; the built-in one
279
+ // tsc's mapping, as #typescriptResolve gives it under --typescript.
280
+ #typescriptCandidate(parentFile, spec, conditions) {
281
+ if (this.typescript) return null
282
+ if (this.customResolve) return this.typescriptResolve?.(parentFile, spec, conditions)?.url ?? null
283
+ const hit = this.#typescriptResolve(parentFile, spec, conditions)
284
+ return hit == null ? null : pathToFileURL(hit).toString()
285
+ }
286
+
186
287
  // Strip Flow type syntax to plain JS via the optional flow-remove-types dep (resolved lazily; a
187
288
  // missing dep is an env error and propagates). Only invoked as a parse fallback (see #scanFile),
188
289
  // so plain-JS files never reach it. Returns the rewritten source, or null when the transform
@@ -199,28 +300,15 @@ export class Scan {
199
300
  }
200
301
  }
201
302
 
202
- #scanFile(url, queue) {
203
- const file = fileURLToPath(url)
303
+ // Parse `file` (a script extension) as Node loads it: under the format its name and package
304
+ // declare, or for a typeless .js/.ts the one Node detects from its syntax. -> { format, parsed,
305
+ // errors } (`errors`: what the parse still reports), or { declared, crash } where the parser
306
+ // itself threw on the file, salvaging nothing.
307
+ parseFile(file) {
204
308
  const ext = extname(file)
205
- if (ext === '.json') {
206
- this.files.set(url, { format: 'json', edges: [] })
207
- return
208
- }
209
- if (this.#isResource(file)) {
210
- // Allowlisted asset: carry it as a resource with no parse and no edges. `resource: true` is
211
- // the authoritative signal (both bundle builders key off it); the concrete format
212
- // (resource vs resource:base64) is byte-derived by the builder, so leave it unset here.
213
- this.files.set(url, { format: null, edges: [], resource: true })
214
- return
215
- }
216
- if (!this.scriptExts.has(ext)) {
217
- this.files.set(url, { format: null, edges: [] })
218
- return
219
- }
220
-
221
309
  // declared === null: typeless package; Node decides .js/.ts by syntax, resolved after parse.
222
- const declared = formatForFile(file)
223
- const src = readFileSync(file, 'utf8')
310
+ const declared = formatForFile(file, this.host)
311
+ const src = this.host.readFile(file).toString('utf8')
224
312
 
225
313
  // Resolve the parser outside the try: a missing oxc-parser is an env error, not this
226
314
  // file's — it must not be swallowed into a silent zero-edge leaf.
@@ -229,14 +317,15 @@ export class Scan {
229
317
  // force the JSX variant when opted in (`--jsx`) to parse React Native's JSX-in-.js source.
230
318
  const lang = this.jsx && JSX_EXTS.has(ext) ? 'jsx' : undefined
231
319
  const parseOptions = (sourceType) => (lang ? { sourceType, lang } : { sourceType })
320
+ // `commonjs`, not `script`: Node runs CJS inside its module wrapper function, where a top-level
321
+ // `return` or `new.target` is legal; oxc's `commonjs` accepts both and is otherwise `script`.
232
322
  const baseSourceType = declared === null ? 'unambiguous'
233
- : declared.startsWith('module') ? 'module' : 'script'
323
+ : declared.startsWith('module') ? 'module' : 'commonjs'
234
324
  let parsed
235
325
  try {
236
326
  parsed = parser.parseSync(file, src, parseOptions(baseSourceType))
237
327
  } catch (cause) {
238
- this.#recordParseError(url, declared, cause.message, false)
239
- return
328
+ return { declared, crash: cause.message }
240
329
  }
241
330
 
242
331
  // Match Node's syntax detection for typeless packages: ESM syntax picks the module variant
@@ -244,22 +333,36 @@ export class Scan {
244
333
  // `-typescript` suffix; .jsx/.tsx deliberately land on plain module/commonjs (like a JSX-in-.js
245
334
  // file) — they're bundler-transformed, keyed by extension in `stasis build`, not Node-strippable,
246
335
  // so the "needs a JSX/TS transform" signal rides the .jsx/.tsx extension, not the format tag.
247
- let format = declared
248
- ?? `${parsed.module.hasModuleSyntax ? 'module' : 'commonjs'}${ext === '.ts' ? '-typescript' : ''}`
336
+ const tsSuffix = ext === '.ts' ? '-typescript' : ''
337
+ const detectedFormat = (p) => `${p.module.hasModuleSyntax ? 'module' : 'commonjs'}${tsSuffix}`
338
+ let format = declared ?? detectedFormat(parsed)
249
339
 
250
340
  // oxc recovers from syntax errors (reports them in parsed.errors) rather than throwing;
251
341
  // Warning/Advice severities aren't real parse errors.
252
- const nonWarning = (p) => (p.errors ?? []).filter((e) => e.severity !== 'Warning' && e.severity !== 'Advice')
253
- let errors = nonWarning(parsed)
342
+ let errors = syntaxErrors(parsed)
254
343
 
255
- // oxc's hasModuleSyntax misses top-level await, so a TLA-only ESM file parses here as a
256
- // broken script; mirror Node by retrying as module and preferring a clean module parse.
344
+ // oxc's `unambiguous` mode counts top-level await as module syntax (since 0.109; before, a
345
+ // TLA-only ESM file parsed here as a broken script). Keep mirroring Node for any script parse
346
+ // that still fails: retry as module and prefer a clean module parse.
257
347
  if (errors.length > 0 && declared === null && !format.startsWith('module')) {
258
348
  try {
259
349
  const asModule = parser.parseSync(file, src, parseOptions('module'))
260
- if (nonWarning(asModule).length === 0) {
350
+ if (syntaxErrors(asModule).length === 0) {
261
351
  parsed = asModule
262
- format = `module${ext === '.ts' ? '-typescript' : ''}`
352
+ format = `module${tsSuffix}`
353
+ errors = []
354
+ }
355
+ } catch { /* keep the script parse and its recorded errors */ }
356
+ }
357
+
358
+ // A typeless file Node detects as CJS runs inside the module wrapper too, but `unambiguous`
359
+ // parses it as a plain script, so a top-level `return` still fails there: retry as `commonjs`
360
+ // and prefer a clean parse (the detected commonjs format stands).
361
+ if (errors.length > 0 && declared === null && !format.startsWith('module')) {
362
+ try {
363
+ const asCommonjs = parser.parseSync(file, src, parseOptions('commonjs'))
364
+ if (syntaxErrors(asCommonjs).length === 0) {
365
+ parsed = asCommonjs
263
366
  errors = []
264
367
  }
265
368
  } catch { /* keep the script parse and its recorded errors */ }
@@ -276,15 +379,44 @@ export class Scan {
276
379
  if (flowSrc !== null) {
277
380
  try {
278
381
  const asFlow = parser.parseSync(file, flowSrc, parseOptions(baseSourceType))
279
- if (nonWarning(asFlow).length === 0) {
382
+ if (syntaxErrors(asFlow).length === 0) {
280
383
  parsed = asFlow
281
- format = declared ?? `${parsed.module.hasModuleSyntax ? 'module' : 'commonjs'}${ext === '.ts' ? '-typescript' : ''}`
384
+ format = declared ?? detectedFormat(parsed)
282
385
  errors = []
283
386
  }
284
387
  } catch { /* keep the original parse + its errors */ }
285
388
  }
286
389
  }
287
390
 
391
+ return { format, parsed, errors }
392
+ }
393
+
394
+ #scanFile(url, queue) {
395
+ const file = fileURLToPath(url)
396
+ const ext = extname(file)
397
+ if (ext === '.json') {
398
+ this.files.set(url, { format: 'json', edges: [] })
399
+ return
400
+ }
401
+ if (this.#isResource(file)) {
402
+ // Allowlisted asset: carry it as a resource with no parse and no edges. `resource: true` is
403
+ // the authoritative signal (both bundle builders key off it); the concrete format
404
+ // (resource vs resource:base64) is byte-derived by the builder, so leave it unset here.
405
+ this.files.set(url, { format: null, edges: [], resource: true })
406
+ return
407
+ }
408
+ if (!SCRIPT_EXTS.has(ext)) {
409
+ this.files.set(url, { format: null, edges: [] })
410
+ return
411
+ }
412
+
413
+ const result = this.parseFile(file)
414
+ if (result.crash !== undefined) {
415
+ this.#recordParseError(url, result.declared, result.crash, false)
416
+ return
417
+ }
418
+ const { format, parsed, errors } = result
419
+
288
420
  let parseError
289
421
  if (errors.length > 0) {
290
422
  parseError = errors.map((e) => e.message).join('; ')
@@ -297,35 +429,26 @@ export class Scan {
297
429
  this.parseErrors.push({ url, format, message: parseError, recovered: true })
298
430
  }
299
431
 
300
- const specs = []
301
- // Type-only edges (`import type`, `export type`) are erased before runtime — skip them so the
302
- // bundle matches Node's load graph. Mixed (`{ type A, B }`) and bare side-effect imports are kept.
303
- if (parsed.module?.staticImports) {
304
- for (const imp of parsed.module.staticImports) {
305
- if (imp.entries.length > 0 && imp.entries.every((e) => e.isType)) continue
306
- specs.push({ kind: 'import', spec: imp.moduleRequest.value })
307
- }
308
- }
309
- if (parsed.module?.staticExports) {
310
- for (const exp of parsed.module.staticExports) {
311
- for (const entry of exp.entries) {
312
- if (entry.moduleRequest && !entry.isType) specs.push({ kind: 'export-from', spec: entry.moduleRequest.value })
313
- }
314
- }
315
- }
316
- findCallSpecifiers(parsed.program, (s) => specs.push(s))
432
+ const specs = collectSpecifiers(parsed.program)
317
433
 
318
434
  // The edge KIND picks the resolution context, not the parent's format: createRequire()d
319
435
  // require() inside ESM resolves under REQUIRE conditions, literal import() inside CJS under
320
436
  // IMPORT conditions -- the parent-format set baked the wrong `exports` branch into the bundle.
321
437
  // The custom (legacy-field) resolver keeps the format set: bundlers don't split per edge.
322
438
  const formatContext = ['module', 'module-typescript'].includes(format) ? 'import' : 'require'
323
- const req = createRequire(file)
324
439
  const edges = []
325
440
  const specMaps = new Map() // condition key -> specifier -> child URL
326
- const record = (key, spec, childURL) => {
441
+ const addChild = (s, key, childURL, childPath) => {
442
+ edges.push({ ...s, child: childURL })
327
443
  if (!specMaps.has(key)) specMaps.set(key, new Map())
328
- specMaps.get(key).set(spec, childURL)
444
+ specMaps.get(key).set(s.spec, childURL)
445
+ if (RESOLVABLE_EXTS.has(extname(childPath)) || this.#isResource(childPath)) queue.push(childURL)
446
+ }
447
+ // Records the file --typescript would land the miss on too (typescriptURL), resolved as the miss was.
448
+ const addUnresolved = (s, reason, conditions) => {
449
+ edges.push({ ...s, error: reason })
450
+ const typescriptURL = this.#typescriptCandidate(file, s.spec, conditions)
451
+ this.unresolved.push({ parentURL: url, kind: s.kind, spec: s.spec, reason, ...(typescriptURL ? { typescriptURL } : {}) })
329
452
  }
330
453
 
331
454
  for (const s of specs) {
@@ -346,13 +469,9 @@ export class Scan {
346
469
  } else if (r?.empty) {
347
470
  edges.push({ ...s, empty: true })
348
471
  } else if (r?.url) {
349
- edges.push({ ...s, child: r.url })
350
- record(key, s.spec, r.url)
351
- const childPath = fileURLToPath(r.url)
352
- if (this.resolvableExts.has(extname(childPath)) || this.#isResource(childPath)) queue.push(r.url)
472
+ addChild(s, key, r.url, fileURLToPath(r.url))
353
473
  } else {
354
- edges.push({ ...s, error: 'MODULE_NOT_FOUND' })
355
- this.unresolved.push({ parentURL: url, kind: s.kind, spec: s.spec, reason: 'MODULE_NOT_FOUND' })
474
+ addUnresolved(s, 'MODULE_NOT_FOUND', conditions)
356
475
  }
357
476
  continue
358
477
  }
@@ -360,17 +479,19 @@ export class Scan {
360
479
  edges.push({ ...s, builtin: true })
361
480
  continue
362
481
  }
482
+ let childPath
363
483
  try {
364
- const childPath = req.resolve(s.spec, { conditions })
365
- const childURL = pathToFileURL(childPath).toString()
366
- edges.push({ ...s, child: childURL })
367
- record(key, s.spec, childURL)
368
- const childExt = extname(childPath)
369
- if (this.resolvableExts.has(childExt) || this.#isResource(childPath)) queue.push(childURL)
484
+ childPath = this.host.resolve(file, s.spec, conditions)
370
485
  } catch (cause) {
371
- edges.push({ ...s, error: cause.code ?? cause.message })
372
- this.unresolved.push({ parentURL: url, kind: s.kind, spec: s.spec, reason: cause.code ?? cause.message })
486
+ // --typescript: complete the miss with tsc's mapping (see #typescriptResolve); the edge
487
+ // stays keyed by the ORIGINAL specifier -- only the target is the mapped file.
488
+ if (this.typescript) childPath = this.#typescriptResolve(file, s.spec, conditions)
489
+ if (childPath == null) {
490
+ addUnresolved(s, cause.code ?? cause.message, conditions)
491
+ continue
492
+ }
373
493
  }
494
+ addChild(s, key, pathToFileURL(childPath).toString(), childPath)
374
495
  }
375
496
 
376
497
  for (const [key, specMap] of specMaps) {
@@ -384,7 +505,7 @@ export class Scan {
384
505
  const rel = (url) => {
385
506
  const abs = fileURLToPath(url)
386
507
  const r = relative(root, abs)
387
- if (r.startsWith('..')) throw new Error(`Path outside root: ${abs}`)
508
+ if (relativeEscapes(r)) throw new Error(`Path outside root: ${abs}`)
388
509
  return r
389
510
  }
390
511
  const relEdge = (e) => (e.child ? { ...e, child: rel(e.child) } : e)
@@ -404,7 +525,7 @@ export class Scan {
404
525
  ),
405
526
  ])
406
527
  ),
407
- unresolved: this.unresolved.map(({ parentURL, ...rest }) => ({ parent: rel(parentURL), ...rest })),
528
+ unresolved: this.unresolved.map(({ parentURL, typescriptURL, ...rest }) => ({ parent: rel(parentURL), ...rest, ...(typescriptURL ? { typescript: rel(typescriptURL) } : {}) })),
408
529
  parseErrors: this.parseErrors.map(({ url, ...rest }) => ({ file: rel(url), ...rest })),
409
530
  }
410
531
  }