@exodus/stasis 1.0.0-beta.2 → 1.0.0-beta.5
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 +23 -3
- package/bin/stasis.js +375 -170
- package/package.json +44 -11
- package/src/audit-corrections.js +68 -0
- package/src/audit.js +243 -59
- package/src/babel-worker.cjs +65 -0
- package/src/babel.js +131 -0
- package/src/bundle-from-lockfile.js +12 -18
- package/src/bundle-reader.js +77 -0
- package/src/cmd/build.js +95 -128
- package/src/cmd/bundle.js +877 -641
- 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 +4 -18
- package/src/diff.js +67 -124
- 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 +1699 -0
- package/src/loaders/foundry.js +923 -0
- package/src/loaders/paths.js +33 -0
- package/src/loaders/php.js +339 -394
- 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/mock.js +57 -165
- package/src/parse.js +4 -16
- package/src/resolve-fields.js +331 -0
- package/src/resolve-node.js +416 -0
- package/src/resolve-typescript.js +427 -0
- package/src/rollup.js +5 -0
- package/src/sbom.js +64 -92
- package/src/scan.js +234 -156
- package/src/vfs-bundle/entries.js +140 -0
- package/src/vfs-bundle/github.js +193 -0
- package/src/vfs-bundle/soldeer.js +125 -0
- package/src/vfs-bundle/tree.js +606 -0
- package/src/vfs-bundle.js +74 -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,60 +1,66 @@
|
|
|
1
|
-
import { readFileSync, existsSync } from 'node:fs'
|
|
2
1
|
import { 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, relativeEscapes } from '@exodus/stasis-core/util'
|
|
7
|
+
import { resolveTypescriptFallback } from './resolve-typescript.js'
|
|
8
|
+
import { diskHost } from '@exodus/stasis-core/host'
|
|
6
9
|
|
|
7
|
-
// Static require/import graph walker
|
|
8
|
-
//
|
|
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.
|
|
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.
|
|
18
12
|
|
|
19
13
|
const SCRIPT_EXTS = new Set(['.js', '.cjs', '.mjs', '.ts', '.cts', '.mts'])
|
|
20
14
|
const RESOLVABLE_EXTS = new Set([...SCRIPT_EXTS, '.json'])
|
|
15
|
+
// Under `jsx`, these get parsed with JSX enabled. Excludes the .ts family: TypeScript's `<T>`
|
|
16
|
+
// generics collide with JSX, so tsc/oxc reserve JSX for .tsx — JSX-in-TS belongs in a .tsx file.
|
|
17
|
+
const JSX_EXTS = new Set(['.js', '.cjs', '.mjs'])
|
|
18
|
+
// The inherently-JSX extensions (oxc parses them as JSX/TSX purely from the filename, no flag).
|
|
19
|
+
// Scanned + carried only under `--jsx`: off by default the scanner treats them as opaque leaves,
|
|
20
|
+
// so a bundle whose toolchain can't build JSX never silently ships .jsx/.tsx source. Under `--jsx`
|
|
21
|
+
// they join SCRIPT_EXTS/RESOLVABLE_EXTS (see the constructor) so a reached .jsx/.tsx dependency
|
|
22
|
+
// — e.g. a package whose React Native entry is src/index.tsx — is parsed and carried like any .ts.
|
|
23
|
+
const JSX_FILE_EXTS = new Set(['.jsx', '.tsx'])
|
|
21
24
|
|
|
22
|
-
//
|
|
23
|
-
//
|
|
25
|
+
// Flow type syntax lives in the JS (non-TS) family; oxc parses TS natively, and running
|
|
26
|
+
// flow-remove-types over a .ts/.tsx file would corrupt TS-only constructs, so --flow only strips
|
|
27
|
+
// these. .jsx is included (it's JS + JSX, and can carry Flow types like .js); .tsx is not (it's
|
|
28
|
+
// TypeScript). flow-remove-types preserves JSX, so a stripped .jsx re-parses under lang:jsx.
|
|
29
|
+
const FLOW_EXTS = new Set(['.js', '.cjs', '.mjs', '.jsx'])
|
|
30
|
+
|
|
31
|
+
// Required lazily so non-JS bundlers don't load the native oxc parser.
|
|
24
32
|
let _parser
|
|
25
33
|
function getParser() {
|
|
26
34
|
_parser ??= createRequire(import.meta.url)('oxc-parser')
|
|
27
35
|
return _parser
|
|
28
36
|
}
|
|
29
37
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
38
|
+
// flow-remove-types is an optional dependency, loaded lazily and only under --flow: it strips
|
|
39
|
+
// Flow type syntax down to plain JS before oxc (which parses JS/TS, not Flow) sees it. A missing
|
|
40
|
+
// dep is an env error, so surface it with an actionable install hint rather than a bare resolve throw.
|
|
41
|
+
let _flowRemoveTypes
|
|
42
|
+
function getFlowRemoveTypes() {
|
|
43
|
+
if (_flowRemoveTypes) return _flowRemoveTypes
|
|
33
44
|
try {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
45
|
+
_flowRemoveTypes = createRequire(import.meta.url)('flow-remove-types')
|
|
46
|
+
} catch (cause) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
"--flow needs the optional 'flow-remove-types' dependency; install it (e.g. `npm i flow-remove-types`)",
|
|
49
|
+
{ cause }
|
|
50
|
+
)
|
|
38
51
|
}
|
|
52
|
+
return _flowRemoveTypes
|
|
39
53
|
}
|
|
40
54
|
|
|
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)
|
|
55
|
+
// classifyFormat is the shared name->format authority (static and runtime bundles must agree); it
|
|
56
|
+
// returns null for the .js/.ts family whose module system comes from package.json `type` (null =
|
|
57
|
+
// detect from syntax, signaled up as null).
|
|
58
|
+
function formatForFile(file, host) {
|
|
59
|
+
const format = classifyFormat(file)
|
|
60
|
+
if (format != null) return format
|
|
61
|
+
const type = packageType(file, host)
|
|
55
62
|
if (type === null) return null
|
|
56
|
-
|
|
57
|
-
return `${type}${suffix}`
|
|
63
|
+
return `${type}${extname(file) === '.ts' ? '-typescript' : ''}`
|
|
58
64
|
}
|
|
59
65
|
|
|
60
66
|
function literalSpec(node) {
|
|
@@ -66,9 +72,7 @@ function literalSpec(node) {
|
|
|
66
72
|
return null
|
|
67
73
|
}
|
|
68
74
|
|
|
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.
|
|
75
|
+
// Walks require()/import() calls only; ESM static imports/re-exports come from oxc module records.
|
|
72
76
|
function findCallSpecifiers(ast, push) {
|
|
73
77
|
const visit = (node) => {
|
|
74
78
|
if (!node || typeof node !== 'object') return
|
|
@@ -90,11 +94,8 @@ function findCallSpecifiers(ast, push) {
|
|
|
90
94
|
if (spec != null) push({ kind: 'dynamic-import', spec })
|
|
91
95
|
else push({ kind: 'dynamic-import', dynamic: true })
|
|
92
96
|
} 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.
|
|
97
|
+
// `import lib = require('./dep.cts')`: TS-only CJS import; record the real edge.
|
|
98
|
+
// Qualified-name form (`import A = B.C`) carries no file edge.
|
|
98
99
|
if (node.moduleReference?.type === 'TSExternalModuleReference') {
|
|
99
100
|
const spec = literalSpec(node.moduleReference.expression)
|
|
100
101
|
if (spec != null) push({ kind: 'require', spec })
|
|
@@ -121,29 +122,47 @@ export class Scan {
|
|
|
121
122
|
parseErrors = []
|
|
122
123
|
entries = new Set()
|
|
123
124
|
|
|
124
|
-
// `conditions` is
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
// `
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
// `
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
constructor({ conditions = [], resolve = null } = {}) {
|
|
125
|
+
// `conditions` is additive on top of the edge-context base set. `resolve` optionally
|
|
126
|
+
// replaces Node's resolver (resolve-fields.js): returns `{ url } | { empty } | { builtin } | null`
|
|
127
|
+
// (`empty` = a browser/react-native `false` redirect) and owns builtin handling when set.
|
|
128
|
+
// `jsx` opts the .js/.cjs/.mjs family into JSX syntax (React Native's JSX-in-.js convention);
|
|
129
|
+
// off by default because oxc, like tsc, only auto-enables JSX for .jsx/.tsx by extension.
|
|
130
|
+
// `flow`: strip Flow type syntax from JS-family sources before parsing (see #scanFile).
|
|
131
|
+
// `typescript`: retry a failed resolution with tsc's mapping (see #typescriptResolve), with
|
|
132
|
+
// `typescriptPaths` (a loadTsconfigPaths matcher) adding tsconfig alias support; only consulted
|
|
133
|
+
// on the built-in (Node) resolver -- a custom `resolve` owns its own TS handling.
|
|
134
|
+
// `resources` (a `parseResourcesOption` Set of extensions/filenames): reached files matching it
|
|
135
|
+
// are carried as opaque resources (bytes only) rather than rejected as un-carryable -- for graphs
|
|
136
|
+
// that aren't fully loadable in JS (e.g. Metro consuming .png/.svg assets).
|
|
137
|
+
// `host`: the filesystem the walk reads and resolves through (@exodus/stasis-core/host).
|
|
138
|
+
constructor({ conditions = [], resolve = null, jsx = false, flow = false, typescript = false, typescriptPaths = null, resources = new Set(), host = diskHost } = {}) {
|
|
138
139
|
this.extraConditions = [...conditions]
|
|
139
140
|
this.customResolve = resolve
|
|
141
|
+
this.jsx = jsx
|
|
142
|
+
this.flow = flow
|
|
143
|
+
this.typescript = typescript
|
|
144
|
+
this.typescriptPaths = typescriptPaths
|
|
145
|
+
this.resources = resources
|
|
146
|
+
this.host = host
|
|
147
|
+
// --jsx widens the script/resolvable extension sets to include .jsx/.tsx, so those files are
|
|
148
|
+
// parsed (not left as opaque leaves) and queued when reached. Off by default the base sets apply.
|
|
149
|
+
this.scriptExts = jsx ? new Set([...SCRIPT_EXTS, ...JSX_FILE_EXTS]) : SCRIPT_EXTS
|
|
150
|
+
this.resolvableExts = jsx ? new Set([...RESOLVABLE_EXTS, ...JSX_FILE_EXTS]) : RESOLVABLE_EXTS
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// A reached file whose extension/filename is in the --resources allowlist: carried as an opaque
|
|
154
|
+
// resource (bytes only, no parse, no edges), not rejected as "a source bundle can't carry".
|
|
155
|
+
// classifyExtension returns 'resource' only for non-code names in the allowlist, so this never
|
|
156
|
+
// shadows a script/json extension (parseResourcesOption rejects code extensions).
|
|
157
|
+
#isResource(file) {
|
|
158
|
+
return this.resources.size > 0 && classifyExtension(file, this.resources) === 'resource'
|
|
140
159
|
}
|
|
141
160
|
|
|
142
161
|
walk(entries) {
|
|
143
162
|
const queue = []
|
|
144
163
|
for (const entry of entries) {
|
|
145
164
|
const abs = resolvePath(entry)
|
|
146
|
-
assert.ok(
|
|
165
|
+
assert.ok(this.host.stat(abs) !== null, `entry not found: ${abs}`)
|
|
147
166
|
const url = pathToFileURL(abs).toString()
|
|
148
167
|
this.entries.add(url)
|
|
149
168
|
queue.push(url)
|
|
@@ -156,29 +175,60 @@ export class Scan {
|
|
|
156
175
|
return this
|
|
157
176
|
}
|
|
158
177
|
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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])
|
|
178
|
+
// Must match the runtime resolver's condition set (omitting one resolves `exports`-gated
|
|
179
|
+
// packages to a different file) AND its order: the recorded key must equal the runtime
|
|
180
|
+
// hooks' string for exact lookups. Extras go where Node puts user conditions in each set.
|
|
181
|
+
#conditionSet(context) {
|
|
182
|
+
return context === 'import'
|
|
183
|
+
? new Set(['node', 'import', 'module-sync', 'node-addons', ...this.extraConditions])
|
|
184
|
+
: new Set(['require', 'node', 'node-addons', ...this.extraConditions, 'module-sync'])
|
|
171
185
|
}
|
|
172
186
|
|
|
173
|
-
// `recovered
|
|
174
|
-
//
|
|
175
|
-
// returned, but we discarded its partial module records (module-family
|
|
176
|
-
// formats only; see #scanFile).
|
|
187
|
+
// `recovered`: false = parser crashed, nothing salvaged; true = oxc returned but we
|
|
188
|
+
// discarded its partial module records (module-family only; see #scanFile).
|
|
177
189
|
#recordParseError(url, format, message, recovered) {
|
|
178
190
|
this.files.set(url, { format, edges: [], parseError: message })
|
|
179
191
|
this.parseErrors.push({ url, format, message, recovered })
|
|
180
192
|
}
|
|
181
193
|
|
|
194
|
+
// --typescript: resolve `spec` the way tsc maps TS sources, invoked only AFTER Node's own
|
|
195
|
+
// resolution missed -- so an on-disk `.js` (or any Node-resolvable target) always wins, and the
|
|
196
|
+
// mapping only completes a failed resolution, never rewrites a successful one. The rules
|
|
197
|
+
// (extension substitution, extensionless/index completion, manifest-target and tsconfig-paths
|
|
198
|
+
// mapping) live in the shared dispatcher (resolve-typescript.js), so this resolver and the
|
|
199
|
+
// legacy-field one cannot drift. The hit is realpathed to match req.resolve's canonical output
|
|
200
|
+
// (every other recorded edge is a realpath).
|
|
201
|
+
#typescriptResolve(parentFile, spec, conditions) {
|
|
202
|
+
const hit = resolveTypescriptFallback(parentFile, spec, {
|
|
203
|
+
conditions,
|
|
204
|
+
tsx: this.jsx,
|
|
205
|
+
paths: this.typescriptPaths,
|
|
206
|
+
host: this.host,
|
|
207
|
+
})
|
|
208
|
+
if (hit == null) return null
|
|
209
|
+
try {
|
|
210
|
+
return this.host.realpath(hit)
|
|
211
|
+
} catch {
|
|
212
|
+
return null
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Strip Flow type syntax to plain JS via the optional flow-remove-types dep (resolved lazily; a
|
|
217
|
+
// missing dep is an env error and propagates). Only invoked as a parse fallback (see #scanFile),
|
|
218
|
+
// so plain-JS files never reach it. Returns the rewritten source, or null when the transform
|
|
219
|
+
// itself fails on this file -- the caller then keeps oxc's original parse error.
|
|
220
|
+
#stripFlow(src, file) {
|
|
221
|
+
const flowRemoveTypes = getFlowRemoveTypes()
|
|
222
|
+
try {
|
|
223
|
+
// `all: true` ignores the @flow/@noflow pragma (RN sources mix both); the transform blanks
|
|
224
|
+
// types with whitespace, so byte offsets and every import specifier are preserved.
|
|
225
|
+
return flowRemoveTypes(src, { all: true }).toString()
|
|
226
|
+
} catch (cause) {
|
|
227
|
+
console.warn(`[stasis] --flow: could not strip Flow types from ${file}; leaving it to oxc (${cause.message})`)
|
|
228
|
+
return null
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
182
232
|
#scanFile(url, queue) {
|
|
183
233
|
const file = fileURLToPath(url)
|
|
184
234
|
const ext = extname(file)
|
|
@@ -186,107 +236,135 @@ export class Scan {
|
|
|
186
236
|
this.files.set(url, { format: 'json', edges: [] })
|
|
187
237
|
return
|
|
188
238
|
}
|
|
189
|
-
if (
|
|
239
|
+
if (this.#isResource(file)) {
|
|
240
|
+
// Allowlisted asset: carry it as a resource with no parse and no edges. `resource: true` is
|
|
241
|
+
// the authoritative signal (both bundle builders key off it); the concrete format
|
|
242
|
+
// (resource vs resource:base64) is byte-derived by the builder, so leave it unset here.
|
|
243
|
+
this.files.set(url, { format: null, edges: [], resource: true })
|
|
244
|
+
return
|
|
245
|
+
}
|
|
246
|
+
if (!this.scriptExts.has(ext)) {
|
|
190
247
|
this.files.set(url, { format: null, edges: [] })
|
|
191
248
|
return
|
|
192
249
|
}
|
|
193
250
|
|
|
194
|
-
// declared === null: typeless package
|
|
195
|
-
|
|
196
|
-
const
|
|
197
|
-
const src = readFileSync(file, 'utf8')
|
|
251
|
+
// declared === null: typeless package; Node decides .js/.ts by syntax, resolved after parse.
|
|
252
|
+
const declared = formatForFile(file, this.host)
|
|
253
|
+
const src = this.host.readFile(file).toString('utf8')
|
|
198
254
|
|
|
199
|
-
// Resolve the parser outside the
|
|
200
|
-
//
|
|
201
|
-
// swallowed into a silent zero-edge leaf.
|
|
255
|
+
// Resolve the parser outside the try: a missing oxc-parser is an env error, not this
|
|
256
|
+
// file's — it must not be swallowed into a silent zero-edge leaf.
|
|
202
257
|
const parser = getParser()
|
|
258
|
+
// oxc picks JS/TS + JSX from the filename; the .js family lands on plain JS (no JSX), so
|
|
259
|
+
// force the JSX variant when opted in (`--jsx`) to parse React Native's JSX-in-.js source.
|
|
260
|
+
const lang = this.jsx && JSX_EXTS.has(ext) ? 'jsx' : undefined
|
|
261
|
+
const parseOptions = (sourceType) => (lang ? { sourceType, lang } : { sourceType })
|
|
262
|
+
const baseSourceType = declared === null ? 'unambiguous'
|
|
263
|
+
: declared.startsWith('module') ? 'module' : 'script'
|
|
203
264
|
let parsed
|
|
204
265
|
try {
|
|
205
|
-
parsed = parser.parseSync(file, src,
|
|
206
|
-
sourceType: declared === null ? 'unambiguous'
|
|
207
|
-
: declared.startsWith('module') ? 'module' : 'script',
|
|
208
|
-
})
|
|
266
|
+
parsed = parser.parseSync(file, src, parseOptions(baseSourceType))
|
|
209
267
|
} catch (cause) {
|
|
210
268
|
this.#recordParseError(url, declared, cause.message, false)
|
|
211
269
|
return
|
|
212
270
|
}
|
|
213
271
|
|
|
214
|
-
// Match Node's
|
|
215
|
-
// (
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
// them in
|
|
224
|
-
//
|
|
225
|
-
// and are not errors.)
|
|
272
|
+
// Match Node's syntax detection for typeless packages: ESM syntax picks the module variant
|
|
273
|
+
// (mislabeling as commonjs makes `--bundle=load` feed the wrong translator). Only .ts takes the
|
|
274
|
+
// `-typescript` suffix; .jsx/.tsx deliberately land on plain module/commonjs (like a JSX-in-.js
|
|
275
|
+
// file) — they're bundler-transformed, keyed by extension in `stasis build`, not Node-strippable,
|
|
276
|
+
// so the "needs a JSX/TS transform" signal rides the .jsx/.tsx extension, not the format tag.
|
|
277
|
+
const tsSuffix = ext === '.ts' ? '-typescript' : ''
|
|
278
|
+
const detectedFormat = (p) => `${p.module.hasModuleSyntax ? 'module' : 'commonjs'}${tsSuffix}`
|
|
279
|
+
let format = declared ?? detectedFormat(parsed)
|
|
280
|
+
|
|
281
|
+
// oxc recovers from syntax errors (reports them in parsed.errors) rather than throwing;
|
|
282
|
+
// Warning/Advice severities aren't real parse errors.
|
|
226
283
|
const nonWarning = (p) => (p.errors ?? []).filter((e) => e.severity !== 'Warning' && e.severity !== 'Advice')
|
|
227
284
|
let errors = nonWarning(parsed)
|
|
228
285
|
|
|
229
|
-
//
|
|
230
|
-
//
|
|
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.
|
|
286
|
+
// oxc's hasModuleSyntax misses top-level await, so a TLA-only ESM file parses here as a
|
|
287
|
+
// broken script; mirror Node by retrying as module and preferring a clean module parse.
|
|
236
288
|
if (errors.length > 0 && declared === null && !format.startsWith('module')) {
|
|
237
289
|
try {
|
|
238
|
-
const asModule = parser.parseSync(file, src,
|
|
290
|
+
const asModule = parser.parseSync(file, src, parseOptions('module'))
|
|
239
291
|
if (nonWarning(asModule).length === 0) {
|
|
240
292
|
parsed = asModule
|
|
241
|
-
format = `module${
|
|
293
|
+
format = `module${tsSuffix}`
|
|
242
294
|
errors = []
|
|
243
295
|
}
|
|
244
296
|
} catch { /* keep the script parse and its recorded errors */ }
|
|
245
297
|
}
|
|
246
298
|
|
|
299
|
+
// --flow: oxc parses JS/TS but not Flow, so a Flow-typed .js/.cjs/.mjs file lands here with
|
|
300
|
+
// parse errors. Only now -- after a raw oxc parse actually failed -- strip Flow types and
|
|
301
|
+
// re-parse, so plain-JS files never pay the transform cost. Only the parse input is rewritten;
|
|
302
|
+
// the bundle stores the pristine on-disk source (State/buildResolvedJsBundle re-read it), so
|
|
303
|
+
// attestation still covers the real bytes. A clean re-parse replaces the errored one; otherwise
|
|
304
|
+
// we keep the original result so a genuine (non-Flow) parse error still surfaces.
|
|
305
|
+
if (errors.length > 0 && this.flow && FLOW_EXTS.has(ext)) {
|
|
306
|
+
const flowSrc = this.#stripFlow(src, file)
|
|
307
|
+
if (flowSrc !== null) {
|
|
308
|
+
try {
|
|
309
|
+
const asFlow = parser.parseSync(file, flowSrc, parseOptions(baseSourceType))
|
|
310
|
+
if (nonWarning(asFlow).length === 0) {
|
|
311
|
+
parsed = asFlow
|
|
312
|
+
format = declared ?? detectedFormat(parsed)
|
|
313
|
+
errors = []
|
|
314
|
+
}
|
|
315
|
+
} catch { /* keep the original parse + its errors */ }
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
247
319
|
let parseError
|
|
248
320
|
if (errors.length > 0) {
|
|
249
321
|
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.
|
|
322
|
+
// A partially-parsed module file may be missing link-time edges we can't enumerate.
|
|
252
323
|
if (format.startsWith('module')) {
|
|
253
324
|
this.#recordParseError(url, format, parseError, true)
|
|
254
325
|
return
|
|
255
326
|
}
|
|
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.
|
|
327
|
+
// Script (CJS) files: oxc's recovered AST still yields the require()/import() edges — salvage them.
|
|
260
328
|
this.parseErrors.push({ url, format, message: parseError, recovered: true })
|
|
261
329
|
}
|
|
262
330
|
|
|
263
331
|
const specs = []
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
// erased
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
332
|
+
// STATEMENT-level type-ness decides an edge, matching what survives type erasure at runtime
|
|
333
|
+
// (verbatimModuleSyntax / Node's own type stripping): an `import type` / `export type`
|
|
334
|
+
// statement is erased whole, so no edge; a statement that merely lists inline `type`
|
|
335
|
+
// specifiers (`import { type A }`, `export { type B } from`) -- or none at all (`import {}`,
|
|
336
|
+
// `export {} from`) -- still loads its module, so its edge is real. oxc's module records
|
|
337
|
+
// can't draw that line (`import type { A }` and `import { type A }` yield identical
|
|
338
|
+
// all-isType entries, and `export {} from` yields no entry at all), so the statements are
|
|
339
|
+
// read off the AST, where importKind/exportKind carry it.
|
|
340
|
+
for (const node of parsed.program.body) {
|
|
341
|
+
if (node.type === 'ImportDeclaration') {
|
|
342
|
+
if (node.importKind !== 'type') specs.push({ kind: 'import', spec: node.source.value })
|
|
343
|
+
} else if (node.type === 'ExportNamedDeclaration') {
|
|
344
|
+
if (node.source != null && node.exportKind !== 'type') specs.push({ kind: 'export-from', spec: node.source.value })
|
|
345
|
+
} else if (node.type === 'ExportAllDeclaration') {
|
|
346
|
+
if (node.exportKind !== 'type') specs.push({ kind: 'export-from', spec: node.source.value })
|
|
273
347
|
}
|
|
274
348
|
}
|
|
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
349
|
findCallSpecifiers(parsed.program, (s) => specs.push(s))
|
|
284
350
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
351
|
+
// The edge KIND picks the resolution context, not the parent's format: createRequire()d
|
|
352
|
+
// require() inside ESM resolves under REQUIRE conditions, literal import() inside CJS under
|
|
353
|
+
// IMPORT conditions -- the parent-format set baked the wrong `exports` branch into the bundle.
|
|
354
|
+
// The custom (legacy-field) resolver keeps the format set: bundlers don't split per edge.
|
|
355
|
+
const formatContext = ['module', 'module-typescript'].includes(format) ? 'import' : 'require'
|
|
288
356
|
const edges = []
|
|
289
|
-
const
|
|
357
|
+
const specMaps = new Map() // condition key -> specifier -> child URL
|
|
358
|
+
const addChild = (s, key, childURL, childPath) => {
|
|
359
|
+
edges.push({ ...s, child: childURL })
|
|
360
|
+
if (!specMaps.has(key)) specMaps.set(key, new Map())
|
|
361
|
+
specMaps.get(key).set(s.spec, childURL)
|
|
362
|
+
if (this.resolvableExts.has(extname(childPath)) || this.#isResource(childPath)) queue.push(childURL)
|
|
363
|
+
}
|
|
364
|
+
const addUnresolved = (s, reason) => {
|
|
365
|
+
edges.push({ ...s, error: reason })
|
|
366
|
+
this.unresolved.push({ parentURL: url, kind: s.kind, spec: s.spec, reason })
|
|
367
|
+
}
|
|
290
368
|
|
|
291
369
|
for (const s of specs) {
|
|
292
370
|
if (s.dynamic) {
|
|
@@ -294,10 +372,11 @@ export class Scan {
|
|
|
294
372
|
this.unresolved.push({ parentURL: url, kind: s.kind, reason: 'dynamic' })
|
|
295
373
|
continue
|
|
296
374
|
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
//
|
|
375
|
+
const context = this.customResolve ? formatContext : (s.kind === 'require' ? 'require' : 'import')
|
|
376
|
+
const conditions = this.#conditionSet(context)
|
|
377
|
+
const key = condKey(conditions)
|
|
378
|
+
// Custom (legacy-field) resolver owns builtin handling and the `empty` outcome (browser/RN
|
|
379
|
+
// `false` redirect): an empty edge is recorded but neither queued nor counted as unresolved.
|
|
301
380
|
if (this.customResolve) {
|
|
302
381
|
const r = this.customResolve(file, s.spec, conditions)
|
|
303
382
|
if (r?.builtin) {
|
|
@@ -305,12 +384,9 @@ export class Scan {
|
|
|
305
384
|
} else if (r?.empty) {
|
|
306
385
|
edges.push({ ...s, empty: true })
|
|
307
386
|
} 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)
|
|
387
|
+
addChild(s, key, r.url, fileURLToPath(r.url))
|
|
311
388
|
} else {
|
|
312
|
-
|
|
313
|
-
this.unresolved.push({ parentURL: url, kind: s.kind, spec: s.spec, reason: 'MODULE_NOT_FOUND' })
|
|
389
|
+
addUnresolved(s, 'MODULE_NOT_FOUND')
|
|
314
390
|
}
|
|
315
391
|
continue
|
|
316
392
|
}
|
|
@@ -318,20 +394,22 @@ export class Scan {
|
|
|
318
394
|
edges.push({ ...s, builtin: true })
|
|
319
395
|
continue
|
|
320
396
|
}
|
|
397
|
+
let childPath
|
|
321
398
|
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)
|
|
399
|
+
childPath = this.host.resolve(file, s.spec, conditions)
|
|
328
400
|
} catch (cause) {
|
|
329
|
-
|
|
330
|
-
|
|
401
|
+
// --typescript: complete the miss with tsc's mapping (see #typescriptResolve); the edge
|
|
402
|
+
// stays keyed by the ORIGINAL specifier -- only the target is the mapped file.
|
|
403
|
+
if (this.typescript) childPath = this.#typescriptResolve(file, s.spec, conditions)
|
|
404
|
+
if (childPath == null) {
|
|
405
|
+
addUnresolved(s, cause.code ?? cause.message)
|
|
406
|
+
continue
|
|
407
|
+
}
|
|
331
408
|
}
|
|
409
|
+
addChild(s, key, pathToFileURL(childPath).toString(), childPath)
|
|
332
410
|
}
|
|
333
411
|
|
|
334
|
-
|
|
412
|
+
for (const [key, specMap] of specMaps) {
|
|
335
413
|
if (!this.imports.has(key)) this.imports.set(key, new Map())
|
|
336
414
|
this.imports.get(key).set(url, specMap)
|
|
337
415
|
}
|
|
@@ -342,7 +420,7 @@ export class Scan {
|
|
|
342
420
|
const rel = (url) => {
|
|
343
421
|
const abs = fileURLToPath(url)
|
|
344
422
|
const r = relative(root, abs)
|
|
345
|
-
if (r
|
|
423
|
+
if (relativeEscapes(r)) throw new Error(`Path outside root: ${abs}`)
|
|
346
424
|
return r
|
|
347
425
|
}
|
|
348
426
|
const relEdge = (e) => (e.child ? { ...e, child: rel(e.child) } : e)
|