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