@exodus/stasis 1.0.0-alpha.2 → 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/README.md +67 -3
- package/bin/stasis.js +322 -17
- package/logo.svg +12 -3
- package/package.json +39 -18
- package/src/audit.js +112 -0
- package/src/bundle-from-lockfile.js +65 -0
- package/src/bundle.js +4 -217
- package/src/cmd/build.js +321 -0
- package/src/cmd/bundle.js +950 -59
- package/src/cmd/diff.js +36 -0
- package/src/cmd/extract.js +193 -0
- package/src/cmd/sbom.js +35 -0
- package/src/diff.js +333 -0
- package/src/esbuild.js +5 -56
- package/src/loader-mock.js +19 -0
- package/src/loader.js +6 -132
- package/src/loaders/bash.js +257 -0
- package/src/loaders/php.js +1058 -0
- package/src/loaders/rust.js +336 -0
- package/src/loaders/solidity.js +70 -49
- package/src/lockfile.js +4 -80
- package/src/metro-transformer.js +6 -0
- package/src/metro.js +5 -0
- package/src/mock.js +310 -0
- package/src/parse.js +53 -0
- package/src/prune.js +4 -162
- package/src/sbom.js +266 -0
- package/src/scan.js +373 -0
- package/src/webpack.js +5 -48
- package/src/config.js +0 -205
- package/src/state.js +0 -440
- package/src/state.util.js +0 -25
- package/src/util.js +0 -60
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
|
+
}
|