@exodus/stasis 1.0.0-beta.1 → 1.0.0-beta.3
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 +16 -2
- package/bin/stasis.js +173 -161
- package/package.json +11 -5
- package/src/apis/npm/semver.cjs +0 -2
- package/src/audit.js +161 -22
- package/src/babel-worker.cjs +65 -0
- package/src/babel.js +131 -0
- package/src/bundle-from-lockfile.js +12 -18
- package/src/cmd/build.js +93 -123
- package/src/cmd/bundle.js +470 -427
- package/src/cmd/diff.js +2 -19
- package/src/cmd/sbom.js +1 -7
- package/src/diff.js +61 -118
- package/src/loader-mock.js +8 -15
- package/src/loaders/bash.js +33 -81
- package/src/loaders/php.js +143 -282
- package/src/loaders/rust.js +37 -93
- package/src/loaders/solidity.js +27 -52
- package/src/metro-resolver.js +165 -0
- package/src/metro-transformer.js +3 -2
- package/src/mock.js +54 -160
- package/src/parse.js +4 -16
- package/src/sbom.js +37 -79
- package/src/scan.js +166 -124
- package/src/cmd/extract.js +0 -193
package/src/scan.js
CHANGED
|
@@ -1,60 +1,65 @@
|
|
|
1
1
|
import { readFileSync, existsSync } from 'node:fs'
|
|
2
2
|
import { extname, resolve as resolvePath, relative } from 'node:path'
|
|
3
3
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
4
|
-
import { createRequire, isBuiltin
|
|
4
|
+
import { createRequire, isBuiltin } from 'node:module'
|
|
5
5
|
import assert from 'node:assert/strict'
|
|
6
|
+
import { packageType } from '@exodus/stasis-core/bundle-util'
|
|
7
|
+
import { classifyExtension, classifyFormat } from '@exodus/stasis-core/util'
|
|
6
8
|
|
|
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.
|
|
9
|
+
// Static require/import graph walker: parses source, never loads or executes user code.
|
|
10
|
+
// Dynamic specifiers (`require(name)`, `import('./'+x)`) are recorded as unresolved.
|
|
18
11
|
|
|
19
12
|
const SCRIPT_EXTS = new Set(['.js', '.cjs', '.mjs', '.ts', '.cts', '.mts'])
|
|
20
13
|
const RESOLVABLE_EXTS = new Set([...SCRIPT_EXTS, '.json'])
|
|
14
|
+
// Under `jsx`, these get parsed with JSX enabled. Excludes the .ts family: TypeScript's `<T>`
|
|
15
|
+
// generics collide with JSX, so tsc/oxc reserve JSX for .tsx — JSX-in-TS belongs in a .tsx file.
|
|
16
|
+
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'])
|
|
21
23
|
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
+
// Flow type syntax lives in the JS (non-TS) family; oxc parses TS natively, and running
|
|
25
|
+
// flow-remove-types over a .ts/.tsx file would corrupt TS-only constructs, so --flow only strips
|
|
26
|
+
// these. .jsx is included (it's JS + JSX, and can carry Flow types like .js); .tsx is not (it's
|
|
27
|
+
// TypeScript). flow-remove-types preserves JSX, so a stripped .jsx re-parses under lang:jsx.
|
|
28
|
+
const FLOW_EXTS = new Set(['.js', '.cjs', '.mjs', '.jsx'])
|
|
29
|
+
|
|
30
|
+
// Required lazily so non-JS bundlers don't load the native oxc parser.
|
|
24
31
|
let _parser
|
|
25
32
|
function getParser() {
|
|
26
33
|
_parser ??= createRequire(import.meta.url)('oxc-parser')
|
|
27
34
|
return _parser
|
|
28
35
|
}
|
|
29
36
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
37
|
+
// flow-remove-types is an optional dependency, loaded lazily and only under --flow: it strips
|
|
38
|
+
// Flow type syntax down to plain JS before oxc (which parses JS/TS, not Flow) sees it. A missing
|
|
39
|
+
// dep is an env error, so surface it with an actionable install hint rather than a bare resolve throw.
|
|
40
|
+
let _flowRemoveTypes
|
|
41
|
+
function getFlowRemoveTypes() {
|
|
42
|
+
if (_flowRemoveTypes) return _flowRemoveTypes
|
|
33
43
|
try {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
44
|
+
_flowRemoveTypes = createRequire(import.meta.url)('flow-remove-types')
|
|
45
|
+
} catch (cause) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
"--flow needs the optional 'flow-remove-types' dependency; install it (e.g. `npm i flow-remove-types`)",
|
|
48
|
+
{ cause }
|
|
49
|
+
)
|
|
38
50
|
}
|
|
51
|
+
return _flowRemoveTypes
|
|
39
52
|
}
|
|
40
53
|
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
// nearest package.json `type`; when that's absent (or unrecognized), Node
|
|
45
|
-
// falls back to module-syntax detection, which requires the parse — null
|
|
46
|
-
// signals "detect from syntax" to the caller.
|
|
54
|
+
// classifyFormat is the shared name->format authority (static and runtime bundles must agree); it
|
|
55
|
+
// returns null for the .js/.ts family whose module system comes from package.json `type` (null =
|
|
56
|
+
// detect from syntax, signaled up as null).
|
|
47
57
|
function formatForFile(file) {
|
|
48
|
-
const
|
|
49
|
-
if (
|
|
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'
|
|
58
|
+
const format = classifyFormat(file)
|
|
59
|
+
if (format != null) return format
|
|
54
60
|
const type = packageType(file)
|
|
55
61
|
if (type === null) return null
|
|
56
|
-
|
|
57
|
-
return `${type}${suffix}`
|
|
62
|
+
return `${type}${extname(file) === '.ts' ? '-typescript' : ''}`
|
|
58
63
|
}
|
|
59
64
|
|
|
60
65
|
function literalSpec(node) {
|
|
@@ -66,9 +71,7 @@ function literalSpec(node) {
|
|
|
66
71
|
return null
|
|
67
72
|
}
|
|
68
73
|
|
|
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.
|
|
74
|
+
// Walks require()/import() calls only; ESM static imports/re-exports come from oxc module records.
|
|
72
75
|
function findCallSpecifiers(ast, push) {
|
|
73
76
|
const visit = (node) => {
|
|
74
77
|
if (!node || typeof node !== 'object') return
|
|
@@ -90,11 +93,8 @@ function findCallSpecifiers(ast, push) {
|
|
|
90
93
|
if (spec != null) push({ kind: 'dynamic-import', spec })
|
|
91
94
|
else push({ kind: 'dynamic-import', dynamic: true })
|
|
92
95
|
} 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.
|
|
96
|
+
// `import lib = require('./dep.cts')`: TS-only CJS import; record the real edge.
|
|
97
|
+
// Qualified-name form (`import A = B.C`) carries no file edge.
|
|
98
98
|
if (node.moduleReference?.type === 'TSExternalModuleReference') {
|
|
99
99
|
const spec = literalSpec(node.moduleReference.expression)
|
|
100
100
|
if (spec != null) push({ kind: 'require', spec })
|
|
@@ -121,22 +121,33 @@ export class Scan {
|
|
|
121
121
|
parseErrors = []
|
|
122
122
|
entries = new Set()
|
|
123
123
|
|
|
124
|
-
// `conditions` is
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
// `
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
|
|
134
|
-
// browser/react-native `false` redirect (an empty-module stub the caller
|
|
135
|
-
// materialises), the rest mirror Node resolution. When set, it also handles
|
|
136
|
-
// builtins, so the built-in `isBuiltin` short-circuit is skipped.
|
|
137
|
-
constructor({ conditions = [], resolve = null } = {}) {
|
|
124
|
+
// `conditions` is additive on top of the edge-context base set. `resolve` optionally
|
|
125
|
+
// replaces Node's resolver (resolve-fields.js): returns `{ url } | { empty } | { builtin } | null`
|
|
126
|
+
// (`empty` = a browser/react-native `false` redirect) and owns builtin handling when set.
|
|
127
|
+
// `jsx` opts the .js/.cjs/.mjs family into JSX syntax (React Native's JSX-in-.js convention);
|
|
128
|
+
// off by default because oxc, like tsc, only auto-enables JSX for .jsx/.tsx by extension.
|
|
129
|
+
// `flow`: strip Flow type syntax from JS-family sources before parsing (see #scanFile).
|
|
130
|
+
// `resources` (a `parseResourcesOption` Set of extensions/filenames): reached files matching it
|
|
131
|
+
// are carried as opaque resources (bytes only) rather than rejected as un-carryable -- for graphs
|
|
132
|
+
// that aren't fully loadable in JS (e.g. Metro consuming .png/.svg assets).
|
|
133
|
+
constructor({ conditions = [], resolve = null, jsx = false, flow = false, resources = new Set() } = {}) {
|
|
138
134
|
this.extraConditions = [...conditions]
|
|
139
135
|
this.customResolve = resolve
|
|
136
|
+
this.jsx = jsx
|
|
137
|
+
this.flow = flow
|
|
138
|
+
this.resources = resources
|
|
139
|
+
// --jsx widens the script/resolvable extension sets to include .jsx/.tsx, so those files are
|
|
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
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// A reached file whose extension/filename is in the --resources allowlist: carried as an opaque
|
|
146
|
+
// resource (bytes only, no parse, no edges), not rejected as "a source bundle can't carry".
|
|
147
|
+
// classifyExtension returns 'resource' only for non-code names in the allowlist, so this never
|
|
148
|
+
// shadows a script/json extension (parseResourcesOption rejects code extensions).
|
|
149
|
+
#isResource(file) {
|
|
150
|
+
return this.resources.size > 0 && classifyExtension(file, this.resources) === 'resource'
|
|
140
151
|
}
|
|
141
152
|
|
|
142
153
|
walk(entries) {
|
|
@@ -156,29 +167,38 @@ export class Scan {
|
|
|
156
167
|
return this
|
|
157
168
|
}
|
|
158
169
|
|
|
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])
|
|
170
|
+
// Must match the runtime resolver's condition set (omitting one resolves `exports`-gated
|
|
171
|
+
// packages to a different file) AND its order: the recorded key must equal the runtime
|
|
172
|
+
// hooks' string for exact lookups. Extras go where Node puts user conditions in each set.
|
|
173
|
+
#conditionSet(context) {
|
|
174
|
+
return context === 'import'
|
|
175
|
+
? new Set(['node', 'import', 'module-sync', 'node-addons', ...this.extraConditions])
|
|
176
|
+
: new Set(['require', 'node', 'node-addons', ...this.extraConditions, 'module-sync'])
|
|
171
177
|
}
|
|
172
178
|
|
|
173
|
-
// `recovered
|
|
174
|
-
//
|
|
175
|
-
// returned, but we discarded its partial module records (module-family
|
|
176
|
-
// formats only; see #scanFile).
|
|
179
|
+
// `recovered`: false = parser crashed, nothing salvaged; true = oxc returned but we
|
|
180
|
+
// discarded its partial module records (module-family only; see #scanFile).
|
|
177
181
|
#recordParseError(url, format, message, recovered) {
|
|
178
182
|
this.files.set(url, { format, edges: [], parseError: message })
|
|
179
183
|
this.parseErrors.push({ url, format, message, recovered })
|
|
180
184
|
}
|
|
181
185
|
|
|
186
|
+
// Strip Flow type syntax to plain JS via the optional flow-remove-types dep (resolved lazily; a
|
|
187
|
+
// missing dep is an env error and propagates). Only invoked as a parse fallback (see #scanFile),
|
|
188
|
+
// so plain-JS files never reach it. Returns the rewritten source, or null when the transform
|
|
189
|
+
// itself fails on this file -- the caller then keeps oxc's original parse error.
|
|
190
|
+
#stripFlow(src, file) {
|
|
191
|
+
const flowRemoveTypes = getFlowRemoveTypes()
|
|
192
|
+
try {
|
|
193
|
+
// `all: true` ignores the @flow/@noflow pragma (RN sources mix both); the transform blanks
|
|
194
|
+
// types with whitespace, so byte offsets and every import specifier are preserved.
|
|
195
|
+
return flowRemoveTypes(src, { all: true }).toString()
|
|
196
|
+
} catch (cause) {
|
|
197
|
+
console.warn(`[stasis] --flow: could not strip Flow types from ${file}; leaving it to oxc (${cause.message})`)
|
|
198
|
+
return null
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
182
202
|
#scanFile(url, queue) {
|
|
183
203
|
const file = fileURLToPath(url)
|
|
184
204
|
const ext = extname(file)
|
|
@@ -186,56 +206,57 @@ export class Scan {
|
|
|
186
206
|
this.files.set(url, { format: 'json', edges: [] })
|
|
187
207
|
return
|
|
188
208
|
}
|
|
189
|
-
if (
|
|
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)) {
|
|
190
217
|
this.files.set(url, { format: null, edges: [] })
|
|
191
218
|
return
|
|
192
219
|
}
|
|
193
220
|
|
|
194
|
-
// declared === null: typeless package
|
|
195
|
-
// module-syntax detection — that needs the parse, so resolve it below.
|
|
221
|
+
// declared === null: typeless package; Node decides .js/.ts by syntax, resolved after parse.
|
|
196
222
|
const declared = formatForFile(file)
|
|
197
223
|
const src = readFileSync(file, 'utf8')
|
|
198
224
|
|
|
199
|
-
// Resolve the parser outside the
|
|
200
|
-
//
|
|
201
|
-
// swallowed into a silent zero-edge leaf.
|
|
225
|
+
// Resolve the parser outside the try: a missing oxc-parser is an env error, not this
|
|
226
|
+
// file's — it must not be swallowed into a silent zero-edge leaf.
|
|
202
227
|
const parser = getParser()
|
|
228
|
+
// oxc picks JS/TS + JSX from the filename; the .js family lands on plain JS (no JSX), so
|
|
229
|
+
// force the JSX variant when opted in (`--jsx`) to parse React Native's JSX-in-.js source.
|
|
230
|
+
const lang = this.jsx && JSX_EXTS.has(ext) ? 'jsx' : undefined
|
|
231
|
+
const parseOptions = (sourceType) => (lang ? { sourceType, lang } : { sourceType })
|
|
232
|
+
const baseSourceType = declared === null ? 'unambiguous'
|
|
233
|
+
: declared.startsWith('module') ? 'module' : 'script'
|
|
203
234
|
let parsed
|
|
204
235
|
try {
|
|
205
|
-
parsed = parser.parseSync(file, src,
|
|
206
|
-
sourceType: declared === null ? 'unambiguous'
|
|
207
|
-
: declared.startsWith('module') ? 'module' : 'script',
|
|
208
|
-
})
|
|
236
|
+
parsed = parser.parseSync(file, src, parseOptions(baseSourceType))
|
|
209
237
|
} catch (cause) {
|
|
210
238
|
this.#recordParseError(url, declared, cause.message, false)
|
|
211
239
|
return
|
|
212
240
|
}
|
|
213
241
|
|
|
214
|
-
// Match Node's
|
|
215
|
-
// (
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
//
|
|
242
|
+
// Match Node's syntax detection for typeless packages: ESM syntax picks the module variant
|
|
243
|
+
// (mislabeling as commonjs makes `--bundle=load` feed the wrong translator). Only .ts takes the
|
|
244
|
+
// `-typescript` suffix; .jsx/.tsx deliberately land on plain module/commonjs (like a JSX-in-.js
|
|
245
|
+
// file) — they're bundler-transformed, keyed by extension in `stasis build`, not Node-strippable,
|
|
246
|
+
// so the "needs a JSX/TS transform" signal rides the .jsx/.tsx extension, not the format tag.
|
|
219
247
|
let format = declared
|
|
220
248
|
?? `${parsed.module.hasModuleSyntax ? 'module' : 'commonjs'}${ext === '.ts' ? '-typescript' : ''}`
|
|
221
249
|
|
|
222
|
-
// oxc
|
|
223
|
-
//
|
|
224
|
-
// salvage. (Warning/advice-severity diagnostics don't imply a broken parse
|
|
225
|
-
// and are not errors.)
|
|
250
|
+
// oxc recovers from syntax errors (reports them in parsed.errors) rather than throwing;
|
|
251
|
+
// Warning/Advice severities aren't real parse errors.
|
|
226
252
|
const nonWarning = (p) => (p.errors ?? []).filter((e) => e.severity !== 'Warning' && e.severity !== 'Advice')
|
|
227
253
|
let errors = nonWarning(parsed)
|
|
228
254
|
|
|
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.
|
|
255
|
+
// oxc's hasModuleSyntax misses top-level await, so a TLA-only ESM file parses here as a
|
|
256
|
+
// broken script; mirror Node by retrying as module and preferring a clean module parse.
|
|
236
257
|
if (errors.length > 0 && declared === null && !format.startsWith('module')) {
|
|
237
258
|
try {
|
|
238
|
-
const asModule = parser.parseSync(file, src,
|
|
259
|
+
const asModule = parser.parseSync(file, src, parseOptions('module'))
|
|
239
260
|
if (nonWarning(asModule).length === 0) {
|
|
240
261
|
parsed = asModule
|
|
241
262
|
format = `module${ext === '.ts' ? '-typescript' : ''}`
|
|
@@ -244,28 +265,41 @@ export class Scan {
|
|
|
244
265
|
} catch { /* keep the script parse and its recorded errors */ }
|
|
245
266
|
}
|
|
246
267
|
|
|
268
|
+
// --flow: oxc parses JS/TS but not Flow, so a Flow-typed .js/.cjs/.mjs file lands here with
|
|
269
|
+
// parse errors. Only now -- after a raw oxc parse actually failed -- strip Flow types and
|
|
270
|
+
// re-parse, so plain-JS files never pay the transform cost. Only the parse input is rewritten;
|
|
271
|
+
// the bundle stores the pristine on-disk source (State/buildResolvedJsBundle re-read it), so
|
|
272
|
+
// attestation still covers the real bytes. A clean re-parse replaces the errored one; otherwise
|
|
273
|
+
// we keep the original result so a genuine (non-Flow) parse error still surfaces.
|
|
274
|
+
if (errors.length > 0 && this.flow && FLOW_EXTS.has(ext)) {
|
|
275
|
+
const flowSrc = this.#stripFlow(src, file)
|
|
276
|
+
if (flowSrc !== null) {
|
|
277
|
+
try {
|
|
278
|
+
const asFlow = parser.parseSync(file, flowSrc, parseOptions(baseSourceType))
|
|
279
|
+
if (nonWarning(asFlow).length === 0) {
|
|
280
|
+
parsed = asFlow
|
|
281
|
+
format = declared ?? `${parsed.module.hasModuleSyntax ? 'module' : 'commonjs'}${ext === '.ts' ? '-typescript' : ''}`
|
|
282
|
+
errors = []
|
|
283
|
+
}
|
|
284
|
+
} catch { /* keep the original parse + its errors */ }
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
247
288
|
let parseError
|
|
248
289
|
if (errors.length > 0) {
|
|
249
290
|
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.
|
|
291
|
+
// A partially-parsed module file may be missing link-time edges we can't enumerate.
|
|
252
292
|
if (format.startsWith('module')) {
|
|
253
293
|
this.#recordParseError(url, format, parseError, true)
|
|
254
294
|
return
|
|
255
295
|
}
|
|
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.
|
|
296
|
+
// Script (CJS) files: oxc's recovered AST still yields the require()/import() edges — salvage them.
|
|
260
297
|
this.parseErrors.push({ url, format, message: parseError, recovered: true })
|
|
261
298
|
}
|
|
262
299
|
|
|
263
300
|
const specs = []
|
|
264
|
-
//
|
|
265
|
-
//
|
|
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.
|
|
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.
|
|
269
303
|
if (parsed.module?.staticImports) {
|
|
270
304
|
for (const imp of parsed.module.staticImports) {
|
|
271
305
|
if (imp.entries.length > 0 && imp.entries.every((e) => e.isType)) continue
|
|
@@ -279,14 +313,20 @@ export class Scan {
|
|
|
279
313
|
}
|
|
280
314
|
}
|
|
281
315
|
}
|
|
282
|
-
// require() and dynamic import() come from the AST.
|
|
283
316
|
findCallSpecifiers(parsed.program, (s) => specs.push(s))
|
|
284
317
|
|
|
285
|
-
|
|
286
|
-
|
|
318
|
+
// The edge KIND picks the resolution context, not the parent's format: createRequire()d
|
|
319
|
+
// require() inside ESM resolves under REQUIRE conditions, literal import() inside CJS under
|
|
320
|
+
// IMPORT conditions -- the parent-format set baked the wrong `exports` branch into the bundle.
|
|
321
|
+
// The custom (legacy-field) resolver keeps the format set: bundlers don't split per edge.
|
|
322
|
+
const formatContext = ['module', 'module-typescript'].includes(format) ? 'import' : 'require'
|
|
287
323
|
const req = createRequire(file)
|
|
288
324
|
const edges = []
|
|
289
|
-
const
|
|
325
|
+
const specMaps = new Map() // condition key -> specifier -> child URL
|
|
326
|
+
const record = (key, spec, childURL) => {
|
|
327
|
+
if (!specMaps.has(key)) specMaps.set(key, new Map())
|
|
328
|
+
specMaps.get(key).set(spec, childURL)
|
|
329
|
+
}
|
|
290
330
|
|
|
291
331
|
for (const s of specs) {
|
|
292
332
|
if (s.dynamic) {
|
|
@@ -294,10 +334,11 @@ export class Scan {
|
|
|
294
334
|
this.unresolved.push({ parentURL: url, kind: s.kind, reason: 'dynamic' })
|
|
295
335
|
continue
|
|
296
336
|
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
//
|
|
337
|
+
const context = this.customResolve ? formatContext : (s.kind === 'require' ? 'require' : 'import')
|
|
338
|
+
const conditions = this.#conditionSet(context)
|
|
339
|
+
const key = condKey(conditions)
|
|
340
|
+
// Custom (legacy-field) resolver owns builtin handling and the `empty` outcome (browser/RN
|
|
341
|
+
// `false` redirect): an empty edge is recorded but neither queued nor counted as unresolved.
|
|
301
342
|
if (this.customResolve) {
|
|
302
343
|
const r = this.customResolve(file, s.spec, conditions)
|
|
303
344
|
if (r?.builtin) {
|
|
@@ -306,8 +347,9 @@ export class Scan {
|
|
|
306
347
|
edges.push({ ...s, empty: true })
|
|
307
348
|
} else if (r?.url) {
|
|
308
349
|
edges.push({ ...s, child: r.url })
|
|
309
|
-
|
|
310
|
-
|
|
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)
|
|
311
353
|
} else {
|
|
312
354
|
edges.push({ ...s, error: 'MODULE_NOT_FOUND' })
|
|
313
355
|
this.unresolved.push({ parentURL: url, kind: s.kind, spec: s.spec, reason: 'MODULE_NOT_FOUND' })
|
|
@@ -322,16 +364,16 @@ export class Scan {
|
|
|
322
364
|
const childPath = req.resolve(s.spec, { conditions })
|
|
323
365
|
const childURL = pathToFileURL(childPath).toString()
|
|
324
366
|
edges.push({ ...s, child: childURL })
|
|
325
|
-
|
|
367
|
+
record(key, s.spec, childURL)
|
|
326
368
|
const childExt = extname(childPath)
|
|
327
|
-
if (
|
|
369
|
+
if (this.resolvableExts.has(childExt) || this.#isResource(childPath)) queue.push(childURL)
|
|
328
370
|
} catch (cause) {
|
|
329
371
|
edges.push({ ...s, error: cause.code ?? cause.message })
|
|
330
372
|
this.unresolved.push({ parentURL: url, kind: s.kind, spec: s.spec, reason: cause.code ?? cause.message })
|
|
331
373
|
}
|
|
332
374
|
}
|
|
333
375
|
|
|
334
|
-
|
|
376
|
+
for (const [key, specMap] of specMaps) {
|
|
335
377
|
if (!this.imports.has(key)) this.imports.set(key, new Map())
|
|
336
378
|
this.imports.get(key).set(url, specMap)
|
|
337
379
|
}
|
package/src/cmd/extract.js
DELETED
|
@@ -1,193 +0,0 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
-
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
3
|
-
import { brotliDecompressSync } from 'node:zlib'
|
|
4
|
-
|
|
5
|
-
import { Bundle } from '@exodus/stasis-core/bundle'
|
|
6
|
-
import { Lockfile } from '@exodus/stasis-core/lockfile'
|
|
7
|
-
import { sha512integrity } from '@exodus/stasis-core/state-util'
|
|
8
|
-
import { moduleFileKey } from '@exodus/stasis-core/util'
|
|
9
|
-
|
|
10
|
-
const FILE_LOCK = 'stasis.lock.json'
|
|
11
|
-
|
|
12
|
-
// Re-derive a Lockfile from an already-parsed Bundle. A bundle records each file's
|
|
13
|
-
// content (UTF-8 source for code / 'resource', base64 for 'resource:base64'); a
|
|
14
|
-
// lockfile records each file's SRI digest -- always taken over the same UTF-8
|
|
15
|
-
// bytes the bundle stored (since the lockfile hashes the lockfile-visible content
|
|
16
|
-
// without decoding). Both
|
|
17
|
-
// share the same per-package bucket shape (name/version/files) plus the
|
|
18
|
-
// entries/config metadata, so the conversion is just "hash each file's bytes"
|
|
19
|
-
// while carrying everything else across verbatim.
|
|
20
|
-
//
|
|
21
|
-
// Requires a v1 bundle: v0 records no package name/version (Bundle.parse infers
|
|
22
|
-
// buckets with null metadata), and Lockfile.serialize must attest both for
|
|
23
|
-
// every node_modules bucket.
|
|
24
|
-
//
|
|
25
|
-
// The digest is taken over the same UTF-8 bytes we write to disk, so it equals
|
|
26
|
-
// what `stasis run --lock=add` would record for the extracted tree and what
|
|
27
|
-
// `stasis prune` recomputes when validating it.
|
|
28
|
-
// Reject a 'resource:base64' content string that isn't canonical base64. Buffer.from
|
|
29
|
-
// silently drops invalid characters and accepts non-canonical padding, so a tampered
|
|
30
|
-
// bundle could ship `XXXX_invalid_chars==` and have the decoded bytes hash to whatever
|
|
31
|
-
// it wanted -- the result would still self-consistently round-trip through `extract`
|
|
32
|
-
// (decode → write → re-hash → match), but the on-disk file would diverge from what
|
|
33
|
-
// any honest producer would have written. Fail closed at the schema-boundary by
|
|
34
|
-
// requiring decode → re-encode to be a fixed point.
|
|
35
|
-
function assertCanonicalBase64(content, file) {
|
|
36
|
-
const buf = Buffer.from(content, 'base64')
|
|
37
|
-
if (buf.toString('base64') !== content) {
|
|
38
|
-
throw new Error(`extract: non-canonical base64 in resource:base64 content for ${file}`)
|
|
39
|
-
}
|
|
40
|
-
return buf
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export function lockfileFromBundle(bundle) {
|
|
44
|
-
const modules = new Map()
|
|
45
|
-
for (const [dir, { name, version, ecosystem, files }] of bundle.modules) {
|
|
46
|
-
const hashed = Object.create(null)
|
|
47
|
-
for (const [rel, content] of Object.entries(files)) {
|
|
48
|
-
// The lockfile records sha512 of each file's raw on-disk bytes -- the same
|
|
49
|
-
// hash `stasis run` produces. The bundle stores 'resource:base64' files as
|
|
50
|
-
// base64 text, so we decode back to the raw bytes before hashing; code and
|
|
51
|
-
// 'resource' files are stored as raw UTF-8 and hash identically either way
|
|
52
|
-
// (Node hashes a string as its UTF-8 bytes). assertCanonicalBase64 closes
|
|
53
|
-
// the lying-tag hole: a non-canonical base64 string is rejected outright.
|
|
54
|
-
const file = dir === '.' ? rel : `${dir}/${rel}`
|
|
55
|
-
const bytes = bundle.formats.get(file) === 'resource:base64'
|
|
56
|
-
? assertCanonicalBase64(content, file)
|
|
57
|
-
: content
|
|
58
|
-
hashed[rel] = sha512integrity(bytes)
|
|
59
|
-
}
|
|
60
|
-
// Carry the dependency `ecosystem` across so the derived lockfile records the
|
|
61
|
-
// same ecosystem tag the bundle did.
|
|
62
|
-
modules.set(dir, ecosystem === undefined
|
|
63
|
-
? { name, version, files: hashed }
|
|
64
|
-
: { name, version, ecosystem, files: hashed })
|
|
65
|
-
}
|
|
66
|
-
// Carry the bundle's resolution + format maps across so the derived lockfile
|
|
67
|
-
// attests them too (Bundle.parse already validated the paths) -- without them
|
|
68
|
-
// the lockfile would be a legacy bytes-only one, and a later frozen run
|
|
69
|
-
// against the same bundle would skip the resolution/format cross-checks.
|
|
70
|
-
return new Lockfile({
|
|
71
|
-
config: bundle.config,
|
|
72
|
-
entries: bundle.entries,
|
|
73
|
-
modules,
|
|
74
|
-
imports: bundle.imports,
|
|
75
|
-
formats: bundle.formats,
|
|
76
|
-
})
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// Extract a brotli-compressed stasis code bundle (`stasis.code.br`) back onto
|
|
80
|
-
// disk: write every bundled source to `output` (default: cwd), preserving its
|
|
81
|
-
// project-relative path, and drop a matching `stasis.lock.json` alongside them.
|
|
82
|
-
// The lockfile is derived from the bundle's own contents, so the extracted tree
|
|
83
|
-
// validates against it out of the box (e.g. `stasis prune`). For legacy v0
|
|
84
|
-
// bundles only the sources are extracted -- see the in-body comment.
|
|
85
|
-
//
|
|
86
|
-
// Returns `{ dir, files }` where `files` is the number of sources written
|
|
87
|
-
// (the lockfile is not counted).
|
|
88
|
-
export function extractCommand({ cwd = process.cwd(), bundleFile, output } = {}) {
|
|
89
|
-
if (!bundleFile) throw new Error('extract: a bundle file is required')
|
|
90
|
-
const bundleAbs = resolve(cwd, bundleFile)
|
|
91
|
-
if (!existsSync(bundleAbs)) throw new Error(`extract: bundle file not found: ${bundleAbs}`)
|
|
92
|
-
|
|
93
|
-
let bundle
|
|
94
|
-
try {
|
|
95
|
-
bundle = Bundle.parse(brotliDecompressSync(readFileSync(bundleAbs)).toString('utf-8'))
|
|
96
|
-
} catch (cause) {
|
|
97
|
-
// Bundle.parse rejects malformed shapes (e.g. a lockfile JSON fed by mistake,
|
|
98
|
-
// or a brotli blob that isn't a stasis bundle) with bare message-less asserts;
|
|
99
|
-
// wrap so the user sees which file failed instead of an empty error.
|
|
100
|
-
throw new Error(`extract: not a valid stasis bundle: ${bundleAbs}`, { cause })
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// v0 legacy bundles record no package name/version (Bundle.parse infers buckets
|
|
104
|
-
// with null metadata), so no lockfile can be restored from them:
|
|
105
|
-
// Lockfile.serialize requires both for every node_modules bucket, and a
|
|
106
|
-
// null-metadata workspace entry would be rejected later by State.addFile's
|
|
107
|
-
// name/version cross-check. The sources themselves are still fully
|
|
108
|
-
// recoverable -- extract them and skip the lockfile, with a warning.
|
|
109
|
-
const withLockfile = bundle.version === Bundle.VERSION
|
|
110
|
-
|
|
111
|
-
const outDir = resolve(cwd, output ?? '.')
|
|
112
|
-
const outPrefix = outDir === sep ? outDir : `${outDir}${sep}`
|
|
113
|
-
const lockAbs = join(outDir, FILE_LOCK)
|
|
114
|
-
|
|
115
|
-
// Plan first, write later: validate every target path and confirm it stays
|
|
116
|
-
// inside outDir before touching disk, so a malformed bundle can't write a
|
|
117
|
-
// partial tree. Walk bundle.modules directly rather than the flattening
|
|
118
|
-
// `sources` getter, which silently last-wins when two buckets collapse to
|
|
119
|
-
// the same path. Treat the bundle as untrusted input and verify containment
|
|
120
|
-
// two ways: the resolved path must be BOTH non-escaping relative to outDir
|
|
121
|
-
// (no leading '..', not absolute -- `relative` returns an absolute path
|
|
122
|
-
// across Windows drives) AND lexically under `outDir/` (the trailing
|
|
123
|
-
// separator rejects sibling dirs that merely share outDir as a name prefix,
|
|
124
|
-
// e.g. `<outDir>-evil`). These checks are lexical only: pre-existing
|
|
125
|
-
// symlinks inside outDir are followed by the writes, so untrusted bundles
|
|
126
|
-
// belong in a fresh directory (see doc/extract.md).
|
|
127
|
-
const writes = []
|
|
128
|
-
const targets = new Set()
|
|
129
|
-
for (const [dir, { files }] of bundle.modules) {
|
|
130
|
-
for (const [rel, content] of Object.entries(files)) {
|
|
131
|
-
// A `directory` capture (`stasis run --fs`) keys a listing at the directory's
|
|
132
|
-
// OWN path; it isn't a file to write (its child files recreate the directory),
|
|
133
|
-
// and writing one would collide with those children at the file-vs-directory
|
|
134
|
-
// guard below. Skip it on disk -- the derived lockfile still attests its hash
|
|
135
|
-
// (lockfileFromBundle hashes every bundle entry, directory listings included).
|
|
136
|
-
// The format is looked up under the canonical key (moduleFileKey, so a
|
|
137
|
-
// package-ROOT listing's rel==='' resolves to the dir, not `${dir}/`); the
|
|
138
|
-
// write path below keeps the literal join, so a NON-directory empty file name
|
|
139
|
-
// still trips the non-canonical-path guard rather than being accepted here.
|
|
140
|
-
if (bundle.formats.get(moduleFileKey(dir, rel)) === 'directory') continue
|
|
141
|
-
const file = dir === '.' ? rel : `${dir}/${rel}`
|
|
142
|
-
if (typeof content !== 'string') throw new Error(`extract: bundle file content is not a string: ${file}`)
|
|
143
|
-
const abs = resolve(outDir, file)
|
|
144
|
-
const relToOut = relative(outDir, abs)
|
|
145
|
-
if (relToOut.startsWith('..') || isAbsolute(relToOut) || !abs.startsWith(outPrefix)) {
|
|
146
|
-
throw new Error(`extract: bundle path escapes output dir: ${file}`)
|
|
147
|
-
}
|
|
148
|
-
// Only canonical paths are accepted: a path that changes under the
|
|
149
|
-
// resolve+relative round-trip (mid-path '..' or '.', empty segments, an
|
|
150
|
-
// empty file name) is nothing State or the Solidity bundler ever emit,
|
|
151
|
-
// and writing it would desync the on-disk tree from the lockfile's
|
|
152
|
-
// recorded paths.
|
|
153
|
-
if (relToOut !== file) throw new Error(`extract: non-canonical bundle path: ${file}`)
|
|
154
|
-
if (targets.has(abs)) throw new Error(`extract: duplicate bundle path: ${file}`)
|
|
155
|
-
if (withLockfile && abs === lockAbs) {
|
|
156
|
-
throw new Error(`extract: bundle contains ${FILE_LOCK}, which would collide with the derived lockfile`)
|
|
157
|
-
}
|
|
158
|
-
targets.add(abs)
|
|
159
|
-
// Resources are decoded back to their original bytes: 'resource:base64' from
|
|
160
|
-
// base64 (rejected if non-canonical -- a tampered bundle can't ship lying
|
|
161
|
-
// padding/invalid-char base64), 'resource' (and code) is written as the raw
|
|
162
|
-
// UTF-8 string.
|
|
163
|
-
const data = bundle.formats.get(file) === 'resource:base64'
|
|
164
|
-
? assertCanonicalBase64(content, file)
|
|
165
|
-
: content
|
|
166
|
-
writes.push([abs, data])
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
// A planned file may not also be needed as a parent directory of another --
|
|
170
|
-
// the mkdirSync/writeFileSync pair below would otherwise throw halfway
|
|
171
|
-
// through the writes.
|
|
172
|
-
for (const [abs] of writes) {
|
|
173
|
-
for (let d = dirname(abs); d.length > outDir.length; d = dirname(d)) {
|
|
174
|
-
if (targets.has(d)) {
|
|
175
|
-
throw new Error(`extract: bundle path used as both file and directory: ${relative(outDir, d)}`)
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
const lockText = withLockfile ? lockfileFromBundle(bundle).serialize() : null
|
|
180
|
-
|
|
181
|
-
for (const [abs, content] of writes) {
|
|
182
|
-
mkdirSync(dirname(abs), { recursive: true })
|
|
183
|
-
writeFileSync(abs, content)
|
|
184
|
-
}
|
|
185
|
-
mkdirSync(outDir, { recursive: true }) // for an empty bundle, where no write created it
|
|
186
|
-
if (withLockfile) writeFileSync(lockAbs, lockText)
|
|
187
|
-
|
|
188
|
-
console.warn(`[stasis] Extracted ${writes.length} file(s)${withLockfile ? ` and ${FILE_LOCK}` : ''} to ${outDir}`)
|
|
189
|
-
if (!withLockfile) {
|
|
190
|
-
console.warn(`[stasis] Warning: legacy v0 bundle records no package name/version, so ${FILE_LOCK} can not be restored and was not written`)
|
|
191
|
-
}
|
|
192
|
-
return { dir: outDir, files: writes.length }
|
|
193
|
-
}
|