@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/README.md +9 -2
- package/bin/stasis.js +253 -22
- package/package.json +42 -10
- package/src/audit-corrections.js +72 -0
- package/src/audit.js +181 -112
- package/src/bundle-from-lockfile.js +3 -5
- package/src/bundle-reader.js +77 -0
- package/src/cmd/build.js +3 -5
- package/src/cmd/bundle.js +677 -402
- package/src/cmd/github-bundle.js +39 -0
- package/src/cmd/output.js +36 -0
- package/src/cmd/sbom.js +3 -11
- package/src/diff.js +22 -14
- package/src/json-stream.js +283 -0
- package/src/loaders/bash.js +17 -36
- package/src/loaders/cargo.js +1716 -0
- package/src/loaders/foundry.js +923 -0
- package/src/loaders/paths.js +33 -0
- package/src/loaders/php.js +212 -127
- package/src/loaders/rust.js +3234 -166
- package/src/loaders/solidity-ownership.js +531 -0
- package/src/loaders/solidity.js +412 -137
- package/src/loaders/toml.js +68 -0
- package/src/metro-resolver.js +9 -24
- package/src/mock.js +3 -5
- package/src/resolve-fields.js +338 -0
- package/src/resolve-node.js +416 -0
- package/src/resolve-typescript.js +541 -0
- package/src/rollup.js +5 -0
- package/src/sbom.js +35 -19
- package/src/scan.js +211 -90
- package/src/vfs-bundle/entries.js +140 -0
- package/src/vfs-bundle/github.js +199 -0
- package/src/vfs-bundle/prisma.js +439 -0
- package/src/vfs-bundle/soldeer.js +125 -0
- package/src/vfs-bundle/tree.js +613 -0
- package/src/vfs-bundle.js +75 -0
- package/src/why.js +434 -0
- package/src/apis/npm/index.js +0 -36
- package/src/apis/npm/semver.cjs +0 -42
package/src/sbom.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
75
|
-
//
|
|
76
|
-
//
|
|
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
|
|
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
|
-
|
|
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}
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
203
|
-
|
|
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 =
|
|
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' : '
|
|
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
|
-
|
|
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
|
-
|
|
248
|
-
|
|
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
|
-
|
|
253
|
-
let errors = nonWarning(parsed)
|
|
342
|
+
let errors = syntaxErrors(parsed)
|
|
254
343
|
|
|
255
|
-
// oxc's
|
|
256
|
-
//
|
|
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 (
|
|
350
|
+
if (syntaxErrors(asModule).length === 0) {
|
|
261
351
|
parsed = asModule
|
|
262
|
-
format = `module${
|
|
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 (
|
|
382
|
+
if (syntaxErrors(asFlow).length === 0) {
|
|
280
383
|
parsed = asFlow
|
|
281
|
-
format = declared ??
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
372
|
-
|
|
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
|
|
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
|
}
|