@exodus/stasis 1.0.0-alpha.3 → 1.0.0-beta.0

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 ADDED
@@ -0,0 +1,266 @@
1
+ import semver from './apis/npm/semver.cjs'
2
+ import pkg from '../package.json' with { type: 'json' }
3
+
4
+ // `@exodus/stasis/sbom` — derive an SPDX 2.3 or CycloneDX 1.5 Software Bill of
5
+ // Materials from already-parsed stasis artifacts (`Bundle`/`Lockfile`
6
+ // instances). This module is deliberately free of file/transport concerns: it
7
+ // never reads disk and never touches brotli (`node:zlib`), so it can run
8
+ // wherever an artifact instance can be handed to it. The CLI glue that reads
9
+ // `stasis.code.br`/`stasis.lock.json` off disk (and so pulls in brotli) lives
10
+ // in `src/cmd/sbom.js`.
11
+
12
+ // Identity stamped into every generated SBOM's tool/creator metadata. The tool
13
+ // name is the published package name, per the command's spec.
14
+ const DEFAULT_TOOL = { name: '@exodus/stasis', version: pkg.version, vendor: 'Exodus Movement, Inc.' }
15
+
16
+ // Per-call UUID/timestamp without importing node:crypto — Web Crypto is global
17
+ // in modern Node, keeping this module dependency-light.
18
+ const newUuid = () => globalThis.crypto.randomUUID()
19
+
20
+ // purl `type`s we know how to mint. A dependency whose recorded ecosystem has
21
+ // no registered purl type (`soldeer`) gets no purl — better than a fabricated
22
+ // one — while npm/composer/cargo/github all map to official types.
23
+ const PURL_TYPES = new Set(['npm', 'composer', 'cargo', 'github'])
24
+
25
+ // Infer a whole artifact's ecosystem from its loader formats. Used for the
26
+ // first-party/workspace buckets (which never carry the per-dependency
27
+ // `ecosystem` field) and as the fallback for older artifacts that predate the
28
+ // field entirely. PHP bundles tag files with the `php` format and map to
29
+ // Composer; everything else (JS/TS, and the Solidity/Rust/Bash source bundles,
30
+ // which all bucket by `package.json`) uses npm-style names.
31
+ function detectEcosystem(artifact) {
32
+ const formats = artifact.formats
33
+ if (formats && typeof formats.values === 'function') {
34
+ for (const format of formats.values()) {
35
+ if (format === 'php') return 'composer'
36
+ }
37
+ }
38
+ return 'npm'
39
+ }
40
+
41
+ // Legacy scope classification for artifacts predating the per-dependency
42
+ // `ecosystem` field: the "." bucket and other non-`node_modules` dirs are
43
+ // first-party, except in a Composer bundle where vendor packages live outside
44
+ // `node_modules`.
45
+ function classifyScope(ecosystem, dir) {
46
+ if (dir === '.') return 'workspace'
47
+ if (ecosystem === 'composer') return 'dependency'
48
+ return dir.includes('node_modules') ? 'dependency' : 'workspace'
49
+ }
50
+
51
+ // Split a package name into its purl/CycloneDX namespace (group) and bare name.
52
+ // npm scopes (`@scope/name`), Composer vendors and GitHub owners (`a/b`) all use
53
+ // the segment before the first slash; cargo crates and bare names have none.
54
+ function splitName(ecosystem, name) {
55
+ const namespaced = ecosystem === 'composer' || ecosystem === 'github' ||
56
+ (ecosystem === 'npm' && name.startsWith('@'))
57
+ if (namespaced && name.includes('/')) {
58
+ const slash = name.indexOf('/')
59
+ return { group: name.slice(0, slash), name: name.slice(slash + 1) }
60
+ }
61
+ return { group: undefined, name }
62
+ }
63
+
64
+ // Build a Package URL (purl) — the cross-format component identifier SPDX and
65
+ // CycloneDX both understand — for a package in a known ecosystem, or null when
66
+ // the ecosystem has no purl type. Each path segment is percent-encoded (so an
67
+ // npm scope's `@` becomes `%40`), while the `/` namespace separator and the
68
+ // `@` before the version stay literal, per the purl spec. Composer and GitHub
69
+ // names are case-insensitive, so they're lowercased as the spec requires.
70
+ export function buildPurl(ecosystem, rawName, version) {
71
+ if (!PURL_TYPES.has(ecosystem)) return null
72
+ let { group, name } = splitName(ecosystem, rawName)
73
+ if (ecosystem === 'composer' || ecosystem === 'github') {
74
+ group = group?.toLowerCase()
75
+ name = name.toLowerCase()
76
+ }
77
+ const namespace = group ? `${encodeURIComponent(group)}/` : ''
78
+ return `pkg:${ecosystem}/${namespace}${encodeURIComponent(name)}@${encodeURIComponent(version)}`
79
+ }
80
+
81
+ function compareComponents(a, b) {
82
+ if (a.name !== b.name) return a.name < b.name ? -1 : 1
83
+ if (a.version !== b.version) {
84
+ if (semver.valid(a.version) && semver.valid(b.version)) return semver.compare(a.version, b.version)
85
+ return a.version < b.version ? -1 : 1
86
+ }
87
+ return a.ecosystem < b.ecosystem ? -1 : a.ecosystem > b.ecosystem ? 1 : 0
88
+ }
89
+
90
+ // Collect the deduplicated, sorted set of packages recorded across one or more
91
+ // already-parsed stasis artifacts (`Bundle`/`Lockfile` instances). Unlike
92
+ // `stasis audit` (which inventories only installed dependencies to query the
93
+ // registry), an SBOM is the *full* bill of materials, so first-party/workspace
94
+ // packages are included too — tagged `scope: 'workspace'` vs. `'dependency'` so
95
+ // the formatters can pick a primary component. Buckets without a name+version
96
+ // (legacy v0 bundles record neither) are skipped: there is nothing to attest.
97
+ //
98
+ // Each dependency's `ecosystem` (npm/composer/cargo/github/soldeer) comes from
99
+ // the per-bucket field newer artifacts record; older artifacts predate it, so
100
+ // we fall back to inferring it from the file's loader formats and install path.
101
+ // Dedup is by ecosystem+name+version; a package seen as first-party in any
102
+ // artifact stays workspace regardless of argument order, so the output doesn't
103
+ // depend on how the artifacts are listed.
104
+ export function collectComponents(artifacts) {
105
+ const seen = new Map()
106
+ for (const artifact of artifacts) {
107
+ const records = [...artifact.modules]
108
+ // The per-dependency `ecosystem` field cleanly separates deps (tagged) from
109
+ // first-party buckets (untagged). If no bucket carries it, the artifact
110
+ // predates the field and we use the format/path heuristic for everything.
111
+ const tagged = records.some(([, m]) => m.ecosystem !== undefined)
112
+ const fileEcosystem = detectEcosystem(artifact)
113
+ for (const [dir, { name, version, ecosystem }] of records) {
114
+ if (!name || !version) continue
115
+ let scope, eco
116
+ if (ecosystem !== undefined) {
117
+ scope = 'dependency'
118
+ eco = ecosystem
119
+ } else if (tagged) {
120
+ // Untagged bucket in a modern artifact: first-party workspace code. Its
121
+ // purl ecosystem is inferred (npm, or composer for a PHP project).
122
+ scope = 'workspace'
123
+ eco = fileEcosystem
124
+ } else {
125
+ scope = classifyScope(fileEcosystem, dir)
126
+ eco = fileEcosystem
127
+ }
128
+ const key = `${eco} ${name} ${version}`
129
+ const existing = seen.get(key)
130
+ if (existing) {
131
+ if (scope === 'workspace') existing.scope = 'workspace'
132
+ continue
133
+ }
134
+ seen.set(key, { name, version, scope, ecosystem: eco, purl: buildPurl(eco, name, version) })
135
+ }
136
+ }
137
+ return [...seen.values()].toSorted(compareComponents)
138
+ }
139
+
140
+ // Pick the single component the SBOM is *about* (its primary/root). That is the
141
+ // lone workspace package when there is exactly one; with zero or several
142
+ // workspace roots (a node_modules-only artifact, or several inputs/monorepo
143
+ // packages) the BOM has no single subject and every package is listed as a
144
+ // plain component. `rest` is everything that is not the primary.
145
+ function selectPrimary(components) {
146
+ const workspace = components.filter((c) => c.scope === 'workspace')
147
+ const primary = workspace.length === 1 ? workspace[0] : null
148
+ return { primary, rest: primary ? components.filter((c) => c !== primary) : components }
149
+ }
150
+
151
+ // SBOM timestamps are RFC 3339 / ISO 8601 to whole seconds (SPDX's canonical
152
+ // `YYYY-MM-DDThh:mm:ssZ`); drop the milliseconds Date.toISOString includes.
153
+ const isoSeconds = (date) => date.toISOString().replace(/\.\d{3}Z$/u, 'Z')
154
+
155
+ // A CycloneDX bom-ref must uniquely identify a component within the document;
156
+ // the purl does when present, and ecosystem+name+version is unique by
157
+ // construction (it is the dedup key) otherwise.
158
+ const bomRef = (c) => c.purl ?? `${c.ecosystem}:${c.name}@${c.version}`
159
+
160
+ // Render an SPDX 2.3 document (as a plain object) for the collected components.
161
+ // The document DESCRIBES its primary component and the primary DEPENDS_ON each
162
+ // installed dependency (a flat graph — every package in a lockfile/bundle is a
163
+ // transitive dependency of the root). With no single primary the document
164
+ // DESCRIBES the workspace packages, or every package when there are none.
165
+ export function toSpdx(components, { tool = DEFAULT_TOOL, now = new Date(), uuid = newUuid() } = {}) {
166
+ const { primary, rest } = selectPrimary(components)
167
+ const ordered = primary ? [primary, ...rest] : components
168
+ const ids = new Map(ordered.map((c, i) => [c, `SPDXRef-Package-${i}`]))
169
+
170
+ const packages = ordered.map((c) => ({
171
+ SPDXID: ids.get(c),
172
+ name: c.name,
173
+ versionInfo: c.version,
174
+ downloadLocation: 'NOASSERTION',
175
+ filesAnalyzed: false,
176
+ ...(c.purl && {
177
+ externalRefs: [{ referenceCategory: 'PACKAGE-MANAGER', referenceType: 'purl', referenceLocator: c.purl }],
178
+ }),
179
+ primaryPackagePurpose: c.scope === 'workspace' ? 'APPLICATION' : 'LIBRARY',
180
+ }))
181
+
182
+ const relationships = []
183
+ if (primary) {
184
+ relationships.push({ spdxElementId: 'SPDXRef-DOCUMENT', relatedSpdxElement: ids.get(primary), relationshipType: 'DESCRIBES' })
185
+ for (const c of rest) {
186
+ if (c.scope === 'dependency') {
187
+ relationships.push({ spdxElementId: ids.get(primary), relatedSpdxElement: ids.get(c), relationshipType: 'DEPENDS_ON' })
188
+ }
189
+ }
190
+ } else {
191
+ const workspace = ordered.filter((c) => c.scope === 'workspace')
192
+ for (const c of workspace.length > 0 ? workspace : ordered) {
193
+ relationships.push({ spdxElementId: 'SPDXRef-DOCUMENT', relatedSpdxElement: ids.get(c), relationshipType: 'DESCRIBES' })
194
+ }
195
+ }
196
+
197
+ const docName = primary ? `${primary.name}@${primary.version}` : 'stasis-sbom'
198
+ return {
199
+ spdxVersion: 'SPDX-2.3',
200
+ dataLicense: 'CC0-1.0',
201
+ SPDXID: 'SPDXRef-DOCUMENT',
202
+ name: docName,
203
+ documentNamespace: `https://spdx.org/spdxdocs/${encodeURIComponent(docName)}-${uuid}`,
204
+ creationInfo: {
205
+ created: isoSeconds(now),
206
+ creators: [`Tool: ${tool.name}-${tool.version}`, `Organization: ${tool.vendor}`],
207
+ },
208
+ packages,
209
+ relationships,
210
+ }
211
+ }
212
+
213
+ // Render a CycloneDX 1.5 document (as a plain object) for the collected
214
+ // components. The primary (workspace root) becomes `metadata.component`; the
215
+ // rest are listed in `components`, with a flat `dependencies` edge from the
216
+ // primary to each installed dependency.
217
+ export function toCyclonedx(components, { tool = DEFAULT_TOOL, now = new Date(), uuid = newUuid() } = {}) {
218
+ const { primary, rest } = selectPrimary(components)
219
+ const toComponent = (c) => {
220
+ const { group, name } = splitName(c.ecosystem, c.name)
221
+ return {
222
+ type: c.scope === 'workspace' ? 'application' : 'library',
223
+ 'bom-ref': bomRef(c),
224
+ ...(group && { group }),
225
+ name,
226
+ version: c.version,
227
+ ...(c.purl && { purl: c.purl }),
228
+ }
229
+ }
230
+
231
+ const doc = {
232
+ bomFormat: 'CycloneDX',
233
+ specVersion: '1.5',
234
+ serialNumber: `urn:uuid:${uuid}`,
235
+ version: 1,
236
+ metadata: {
237
+ timestamp: isoSeconds(now),
238
+ // CycloneDX 1.5 `tools.components` shape: the flat tools array is
239
+ // deprecated in 1.5 (removed in 1.6), and newer consumers read this form.
240
+ tools: { components: [{ type: 'application', publisher: tool.vendor, name: tool.name, version: tool.version }] },
241
+ ...(primary && { component: toComponent(primary) }),
242
+ },
243
+ components: rest.map(toComponent),
244
+ }
245
+ if (primary) {
246
+ const dependsOn = rest.filter((c) => c.scope === 'dependency').map(bomRef)
247
+ doc.dependencies = [{ ref: bomRef(primary), dependsOn }]
248
+ }
249
+ return doc
250
+ }
251
+
252
+ // Dispatch to the requested format. `components` is the output of
253
+ // `collectComponents`; `options` accepts `{ tool, now, uuid }` for custom
254
+ // tool identity / deterministic output.
255
+ export function generateSbom(format, components, options) {
256
+ if (format === 'spdx') return toSpdx(components, options)
257
+ if (format === 'cyclonedx') return toCyclonedx(components, options)
258
+ throw new Error(`sbom: format must be spdx or cyclonedx, got ${format}`)
259
+ }
260
+
261
+ // Convenience one-shot: collect the components from already-parsed artifacts and
262
+ // render them in `format`. Equivalent to
263
+ // `generateSbom(format, collectComponents(artifacts), options)`.
264
+ export function sbom(format, artifacts, options) {
265
+ return generateSbom(format, collectComponents(artifacts), options)
266
+ }
package/src/scan.js ADDED
@@ -0,0 +1,373 @@
1
+ import { readFileSync, existsSync } from 'node:fs'
2
+ import { extname, resolve as resolvePath, relative } from 'node:path'
3
+ import { fileURLToPath, pathToFileURL } from 'node:url'
4
+ import { createRequire, isBuiltin, findPackageJSON } from 'node:module'
5
+ import assert from 'node:assert/strict'
6
+
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'])
20
+ const RESOLVABLE_EXTS = new Set([...SCRIPT_EXTS, '.json'])
21
+
22
+ // oxc-parser is a regular dependency, required lazily so non-JS bundlers (which
23
+ // never reach getParser()) don't load the native parser.
24
+ let _parser
25
+ function getParser() {
26
+ _parser ??= createRequire(import.meta.url)('oxc-parser')
27
+ return _parser
28
+ }
29
+
30
+ function packageType(file) {
31
+ const pkg = findPackageJSON(pathToFileURL(file).toString())
32
+ if (!pkg) return null
33
+ try {
34
+ const type = JSON.parse(readFileSync(pkg, 'utf8')).type
35
+ return type === 'module' || type === 'commonjs' ? type : null
36
+ } catch {
37
+ return null
38
+ }
39
+ }
40
+
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
+ if (type === null) return null
56
+ const suffix = ext === '.ts' ? '-typescript' : ''
57
+ return `${type}${suffix}`
58
+ }
59
+
60
+ function literalSpec(node) {
61
+ if (!node) return null
62
+ if (node.type === 'Literal' && typeof node.value === 'string') return node.value
63
+ if (node.type === 'TemplateLiteral' && node.expressions.length === 0 && node.quasis.length === 1) {
64
+ return node.quasis[0].value.cooked
65
+ }
66
+ return null
67
+ }
68
+
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.
72
+ function findCallSpecifiers(ast, push) {
73
+ const visit = (node) => {
74
+ if (!node || typeof node !== 'object') return
75
+ if (Array.isArray(node)) {
76
+ for (const c of node) visit(c)
77
+ return
78
+ }
79
+ if (typeof node.type !== 'string') return
80
+
81
+ if (node.type === 'CallExpression') {
82
+ const { callee, arguments: args } = node
83
+ if (callee.type === 'Identifier' && callee.name === 'require' && args.length >= 1) {
84
+ const spec = literalSpec(args[0])
85
+ if (spec != null) push({ kind: 'require', spec })
86
+ else push({ kind: 'require', dynamic: true })
87
+ }
88
+ } else if (node.type === 'ImportExpression') {
89
+ const spec = literalSpec(node.source)
90
+ if (spec != null) push({ kind: 'dynamic-import', spec })
91
+ else push({ kind: 'dynamic-import', dynamic: true })
92
+ } 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
+ if (node.moduleReference?.type === 'TSExternalModuleReference') {
99
+ const spec = literalSpec(node.moduleReference.expression)
100
+ if (spec != null) push({ kind: 'require', spec })
101
+ else push({ kind: 'require', dynamic: true })
102
+ }
103
+ }
104
+
105
+ for (const key of Object.keys(node)) {
106
+ if (key === 'type' || key === 'loc' || key === 'range' || key === 'start' || key === 'end') continue
107
+ visit(node[key])
108
+ }
109
+ }
110
+ visit(ast)
111
+ }
112
+
113
+ function condKey(set) {
114
+ return [...set].join(', ')
115
+ }
116
+
117
+ export class Scan {
118
+ files = new Map()
119
+ imports = new Map()
120
+ unresolved = []
121
+ parseErrors = []
122
+ entries = new Set()
123
+
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 } = {}) {
138
+ this.extraConditions = [...conditions]
139
+ this.customResolve = resolve
140
+ }
141
+
142
+ walk(entries) {
143
+ const queue = []
144
+ for (const entry of entries) {
145
+ const abs = resolvePath(entry)
146
+ assert.ok(existsSync(abs), `entry not found: ${abs}`)
147
+ const url = pathToFileURL(abs).toString()
148
+ this.entries.add(url)
149
+ queue.push(url)
150
+ }
151
+ while (queue.length > 0) {
152
+ const url = queue.shift()
153
+ if (this.files.has(url)) continue
154
+ this.#scanFile(url, queue)
155
+ }
156
+ return this
157
+ }
158
+
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])
171
+ }
172
+
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).
177
+ #recordParseError(url, format, message, recovered) {
178
+ this.files.set(url, { format, edges: [], parseError: message })
179
+ this.parseErrors.push({ url, format, message, recovered })
180
+ }
181
+
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
188
+ }
189
+ if (!SCRIPT_EXTS.has(ext)) {
190
+ this.files.set(url, { format: null, edges: [] })
191
+ return
192
+ }
193
+
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')
198
+
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.
202
+ const parser = getParser()
203
+ let parsed
204
+ try {
205
+ parsed = parser.parseSync(file, src, {
206
+ sourceType: declared === null ? 'unambiguous'
207
+ : declared.startsWith('module') ? 'module' : 'script',
208
+ })
209
+ } catch (cause) {
210
+ this.#recordParseError(url, declared, cause.message, false)
211
+ return
212
+ }
213
+
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.
236
+ if (errors.length > 0 && declared === null && !format.startsWith('module')) {
237
+ try {
238
+ const asModule = parser.parseSync(file, src, { sourceType: 'module' })
239
+ if (nonWarning(asModule).length === 0) {
240
+ parsed = asModule
241
+ format = `module${ext === '.ts' ? '-typescript' : ''}`
242
+ errors = []
243
+ }
244
+ } catch { /* keep the script parse and its recorded errors */ }
245
+ }
246
+
247
+ let parseError
248
+ if (errors.length > 0) {
249
+ 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.
252
+ if (format.startsWith('module')) {
253
+ this.#recordParseError(url, format, parseError, true)
254
+ return
255
+ }
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.
260
+ this.parseErrors.push({ url, format, message: parseError, recovered: true })
261
+ }
262
+
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))
284
+
285
+ const conditions = this.#conditionsFor(format)
286
+ const key = condKey(conditions)
287
+ const req = createRequire(file)
288
+ const edges = []
289
+ const specMap = new Map()
290
+
291
+ for (const s of specs) {
292
+ if (s.dynamic) {
293
+ edges.push(s)
294
+ this.unresolved.push({ parentURL: url, kind: s.kind, reason: 'dynamic' })
295
+ continue
296
+ }
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).
301
+ if (this.customResolve) {
302
+ const r = this.customResolve(file, s.spec, conditions)
303
+ if (r?.builtin) {
304
+ edges.push({ ...s, builtin: true })
305
+ } else if (r?.empty) {
306
+ edges.push({ ...s, empty: true })
307
+ } 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)
311
+ } 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' })
314
+ }
315
+ continue
316
+ }
317
+ if (isBuiltin(s.spec)) {
318
+ edges.push({ ...s, builtin: true })
319
+ continue
320
+ }
321
+ 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)
328
+ } 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 })
331
+ }
332
+ }
333
+
334
+ if (specMap.size > 0) {
335
+ if (!this.imports.has(key)) this.imports.set(key, new Map())
336
+ this.imports.get(key).set(url, specMap)
337
+ }
338
+ this.files.set(url, parseError ? { format, edges, parseError } : { format, edges })
339
+ }
340
+
341
+ toRelative(root) {
342
+ const rel = (url) => {
343
+ const abs = fileURLToPath(url)
344
+ const r = relative(root, abs)
345
+ if (r.startsWith('..')) throw new Error(`Path outside root: ${abs}`)
346
+ return r
347
+ }
348
+ const relEdge = (e) => (e.child ? { ...e, child: rel(e.child) } : e)
349
+ return {
350
+ entries: new Set([...this.entries].map(rel)),
351
+ files: new Map(
352
+ [...this.files].map(([k, v]) => [rel(k), { ...v, edges: v.edges.map(relEdge) }])
353
+ ),
354
+ imports: new Map(
355
+ [...this.imports].map(([cond, byParent]) => [
356
+ cond,
357
+ new Map(
358
+ [...byParent].map(([p, specs]) => [
359
+ rel(p),
360
+ new Map([...specs].map(([spec, child]) => [spec, rel(child)])),
361
+ ])
362
+ ),
363
+ ])
364
+ ),
365
+ unresolved: this.unresolved.map(({ parentURL, ...rest }) => ({ parent: rel(parentURL), ...rest })),
366
+ parseErrors: this.parseErrors.map(({ url, ...rest }) => ({ file: rel(url), ...rest })),
367
+ }
368
+ }
369
+ }
370
+
371
+ export function scan(entries, options = {}) {
372
+ return new Scan(options).walk(entries)
373
+ }