@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/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, findPackageJSON } from 'node:module'
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. Reads source files and parses them; never
8
- // loads or executes any user code. Resolves specifiers through Node's own
9
- // resolver (`createRequire(parent).resolve(spec, { conditions: Set })`), which
10
- // reads package.json manifests and applies `exports` conditions but never runs
11
- // the target module. Dynamic specifiers (`require(name)`, `import('./'+x)`)
12
- // are recorded as unresolved -- a fundamental limit of static analysis for
13
- // CJS, not a tooling shortcoming. Files that fail to parse are recorded in
14
- // `parseErrors` (and flagged with `parseError` in `files`): for script (CJS)
15
- // files oxc's recovered AST still yields the edges (Node's module wrapper
16
- // accepts code oxc rejects, e.g. top-level `return`), while module files keep
17
- // no edges -- a missed static edge is a hole consumers can't enumerate.
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
- // oxc-parser is a regular dependency, required lazily so non-JS bundlers (which
23
- // never reach getParser()) don't load the native parser.
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
- function packageType(file) {
31
- const pkg = findPackageJSON(pathToFileURL(file).toString())
32
- if (!pkg) return null
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
- const type = JSON.parse(readFileSync(pkg, 'utf8')).type
35
- return type === 'module' || type === 'commonjs' ? type : null
36
- } catch {
37
- return null
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
- // Format per Node's own rules. TypeScript files get Node's type-stripping
42
- // formats ('module-typescript' / 'commonjs-typescript'), matching what Node's
43
- // load hook reports for them. For .js/.ts the module system comes from the
44
- // nearest package.json `type`; when that's absent (or unrecognized), Node
45
- // falls back to module-syntax detection, which requires the parse — null
46
- // signals "detect from syntax" to the caller.
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 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'
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
- const suffix = ext === '.ts' ? '-typescript' : ''
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
- // Walk the AST for require() and dynamic import() calls. ESM static imports and
70
- // re-exports are picked up separately from oxc-parser's `module.staticImports` /
71
- // `module.staticExports` -- much cheaper than walking the whole tree for them.
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 form. Node's
94
- // type stripping refuses to run it (non-erasable syntax), but the
95
- // dependency edge is real — record it like a require() so the bundle
96
- // stays self-contained for transform-types users and external analysis.
97
- // Qualified-name references (`import A = B.C`) carry no file edge.
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 *additive* on top of the format-derived base set: ESM parents
125
- // get ['node', 'import'] and CJS parents get ['node', 'require'], with any
126
- // user-supplied conditions (e.g. 'browser', 'development') merged in. This
127
- // matches what the runtime loader records when Node reports conditions for an
128
- // edge -- different edges in the same scan can end up under different keys.
129
- //
130
- // `resolve` optionally replaces Node's own resolver with a custom one (the
131
- // legacy-field resolver in resolve-fields.js drives `stasis bundle --mainFields`).
132
- // It is called `(parentFile, specifier, conditions)` and returns
133
- // `{ url } | { empty: true } | { builtin: true } | null` -- `empty` marks a
134
- // browser/react-native `false` redirect (an empty-module stub the caller
135
- // materialises), the rest mirror Node resolution. When set, it also handles
136
- // builtins, so the built-in `isBuiltin` short-circuit is skipped.
137
- constructor({ conditions = [], resolve = null } = {}) {
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
- // Match the runtime resolver's full condition set. Node 22 passes
160
- // `['node', import|require, 'module-sync', 'node-addons']` to the resolve
161
- // hook; if we omit any of these, a package whose `exports` map gates on the
162
- // runtime-only conditions (e.g. `"module-sync": "./a.js", "import": "./b.js"`)
163
- // resolves to a different file under static scan than under plain Node.
164
- // Confirmed by reproducer with module-sync: plain node hits sync.js, static
165
- // bundle loads with async.js -- silently runs different code.
166
- #conditionsFor(format) {
167
- const base = ['module', 'module-typescript'].includes(format)
168
- ? ['node', 'import', 'module-sync', 'node-addons']
169
- : ['node', 'require', 'module-sync', 'node-addons']
170
- return new Set([...base, ...this.extraConditions])
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: false` means the parser crashed on this file and nothing was
174
- // salvaged -- its edges are entirely unknown. `recovered: true` means oxc
175
- // returned, but we discarded its partial module records (module-family
176
- // formats only; see #scanFile).
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 (!SCRIPT_EXTS.has(ext)) {
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, where Node decides .js/.ts by
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 per-file try below: a missing oxc-parser is
200
- // an environment error, not a property of this file, so it must not be
201
- // swallowed into a silent zero-edge leaf.
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 module-syntax detection for typeless packages: ESM syntax
215
- // (import/export/import.meta) selects the module variant. Recording the
216
- // commonjs variant here would make `--bundle=load` feed the source to the
217
- // wrong translator, which throws instead of reparsing as ESM the way
218
- // Node's on-disk loader does.
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-parser recovers from syntax errors instead of throwing: it reports
223
- // them in `parsed.errors` and returns whatever AST/module records it could
224
- // salvage. (Warning/advice-severity diagnostics don't imply a broken parse
225
- // and are not errors.)
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
- // Node's detector also counts top-level await as module syntax, which
230
- // oxc's hasModuleSyntax does not flag: a typeless file whose ESM-ness
231
- // rests on TLA alone parses here as a broken script while plain node runs
232
- // it as a clean module. Mirror Node's parse-as-CJS-retry-as-ESM: when the
233
- // detected-commonjs parse has errors but a module parse is clean, the
234
- // module parse wins. Genuine CJS salvage cases (e.g. a top-level `return`
235
- // guard) error under BOTH source types and stay on the script path.
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, { sourceType: 'module' })
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 static link-time edges
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: Node's module wrapper accepts code oxc rejects
257
- // (e.g. a top-level `return` guard), and oxc's recovered AST keeps the
258
- // require()/import() calls -- salvage the edges, record the error, and
259
- // let callers decide how loud to be.
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
- // 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.
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
- const conditions = this.#conditionsFor(format)
286
- const key = condKey(conditions)
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 specMap = new Map()
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
- // Custom (legacy-field) resolver: it owns builtin handling and adds the
298
- // `empty` outcome (a browser/react-native `false` redirect). An empty edge
299
- // is recorded but neither queued (an empty module has no edges) nor counted
300
- // as unresolved (it resolved -- to nothing).
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
- specMap.set(s.spec, r.url)
310
- if (RESOLVABLE_EXTS.has(extname(fileURLToPath(r.url)))) queue.push(r.url)
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
- specMap.set(s.spec, childURL)
367
+ record(key, s.spec, childURL)
326
368
  const childExt = extname(childPath)
327
- if (RESOLVABLE_EXTS.has(childExt)) queue.push(childURL)
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
- if (specMap.size > 0) {
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
  }
@@ -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
- }