@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/cmd/bundle.js CHANGED
@@ -1,17 +1,19 @@
1
1
  import { isUtf8 } from 'node:buffer'
2
- import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, writeFileSync } from 'node:fs'
3
3
  import { dirname, extname, isAbsolute, join, posix, relative, resolve } from 'node:path'
4
4
  import { fileURLToPath } from 'node:url'
5
- import { brotliCompressSync } from 'node:zlib'
5
+ import { brotliCompressSync, brotliDecompressSync } from 'node:zlib'
6
6
 
7
7
  import { Bundle } from '@exodus/stasis-core/bundle'
8
8
  import { Lockfile } from '@exodus/stasis-core/lockfile'
9
9
  import { scan } from '../scan.js'
10
10
  import { createFieldResolver, resolveConditions } from '../resolve-fields.js'
11
+ import { createMetroResolver } from '../metro-resolver.js'
11
12
  import { State } from '@exodus/stasis-core/state'
12
13
  import { brotliOptions } from '@exodus/stasis-core/brotli'
13
14
  import { sha512integrity } from '@exodus/stasis-core/state-util'
14
- import { assertRealPathWithinBase, moduleFileKey, splitNodeModulesPath } from '@exodus/stasis-core/util'
15
+ import { findPackageMetadata, normalizeEntries, packageType, readJson, readModuleManifest } from '@exodus/stasis-core/bundle-util'
16
+ import { RN_CORE_INCLUDE_FILES, assertRealPathWithinBase, classifyNativeCapture, isExcludedNativeDir, isExecutableFile, isNativeArtifact, isNativeManifest, isPodspec, isSkippedNativeWalkDir, moduleFileKey, parseResourcesOption, refineNativeCapture, splitNodeModulesPath } from '@exodus/stasis-core/util'
15
17
  import {
16
18
  buildSolidityTree,
17
19
  collectSolidityFilesFromDisk,
@@ -31,16 +33,13 @@ const JS_EXTS = new Set(['.js', '.cjs', '.mjs', '.ts', '.cts', '.mts'])
31
33
  const BASH_EXTS = new Set(['.sh', '.bash'])
32
34
  const RUST_EXTS = new Set(['.rs'])
33
35
 
34
- // Fallback package identity for the workspace bucket when no package.json
35
- // with a name+version can be found by walking up from any of the bundled
36
- // files. Bundle.parse requires every workspace/module bucket to have
37
- // both fields, so we have to attest something.
36
+ // Fallback identity for the workspace bucket; Bundle.parse requires every bucket to attest name+version.
38
37
  const SOLIDITY_WORKSPACE_NAME = 'solidity-bundle'
39
38
  const SOLIDITY_WORKSPACE_VERSION = '0.0.0'
40
39
  const SOLIDITY_FORMAT = 'solidity'
41
40
  const BASH_WORKSPACE_NAME = 'bash-bundle'
42
41
  const BASH_WORKSPACE_VERSION = '0.0.0'
43
- const BASH_FORMAT = 'bash'
42
+ const SHELL_FORMAT = 'shell'
44
43
 
45
44
  const RUST_WORKSPACE_NAME = 'rust-bundle'
46
45
  const RUST_WORKSPACE_VERSION = '0.0.0'
@@ -50,47 +49,8 @@ const PHP_WORKSPACE_NAME = 'php-bundle'
50
49
  const PHP_WORKSPACE_VERSION = '0.0.0'
51
50
  const PHP_FORMAT = 'php'
52
51
 
53
- // Walk up from the file's directory looking for the nearest package.json
54
- // with both `name` and `version`. Returns { pkgDir, name, version }
55
- // (pkgDir relative to `baseDir`, "." for the project root) or null when no
56
- // such package.json exists at or above the file.
57
- function findPackageMetadata(baseDir, fileRelPath) {
58
- let dir = dirname(fileRelPath)
59
- while (true) {
60
- const pkgPath = join(baseDir, dir, 'package.json')
61
- if (existsSync(pkgPath)) {
62
- try {
63
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
64
- if (pkg.name && pkg.version) {
65
- return { pkgDir: dir, name: pkg.name, version: pkg.version }
66
- }
67
- } catch { /* malformed — keep walking */ }
68
- }
69
- if (dir === '.' || dir === '/' || dir === '') return null
70
- const parent = dirname(dir)
71
- if (parent === dir) return null
72
- dir = parent
73
- }
74
- }
75
-
76
- function normalizeEntries(entries, cwd) {
77
- const baseDir = resolve(cwd)
78
- return entries.map((e) => {
79
- const abs = resolve(cwd, e)
80
- const rel = relative(baseDir, abs).split(/[\\/]/u).join('/')
81
- // On Windows, `path.relative()` returns an absolute path when `abs` is on
82
- // a different drive than `baseDir` — that wouldn't start with `..` but
83
- // still escapes, so reject both forms.
84
- if (rel.startsWith('..') || isAbsolute(rel)) throw new Error(`Entry escapes baseDir: ${e}`)
85
- return rel.replace(/^\.\//u, '')
86
- })
87
- }
88
-
89
- // Deepest directory that is a parent of every file in `paths`, expressed
90
- // relative to `cwd`. `paths` may be POSIX-relative-to-cwd; entries that
91
- // escape cwd (e.g. `../deps/X.sol` via remapping) push the result above
92
- // cwd, in which case the returned path starts with `..`. Returns "." when
93
- // the outermost directory IS cwd itself.
52
+ // Deepest common parent dir of `paths`, relative to `cwd`; starts with `..` when entries
53
+ // escape cwd (e.g. via remapping), or "." when it is cwd itself.
94
54
  export function outermostDir(paths, cwd) {
95
55
  if (paths.length === 0) return '.'
96
56
  const cwdAbs = posix.resolve(cwd.replaceAll(/\\/gu, '/'))
@@ -107,35 +67,21 @@ export function outermostDir(paths, cwd) {
107
67
  return rel === '' ? '.' : rel
108
68
  }
109
69
 
110
- function readJson(file) {
111
- try {
112
- return JSON.parse(readFileSync(file, 'utf8'))
113
- } catch {
114
- return null
115
- }
116
- }
117
-
118
- // Split a Soldeer dependency dir name (`<name>-<version>`) into its parts, e.g.
119
- // `@openzeppelin-contracts-5.0.2` -> { name: '@openzeppelin-contracts',
120
- // version: '5.0.2' }. Soldeer encodes the version into the directory name, so
121
- // that's the authoritative identity for a Soldeer package. Falls back to
122
- // version 0.0.0 when the dir doesn't end in a semver (every bucket must attest
123
- // a version).
70
+ // Split a Soldeer dep dir `<name>-<version>` (its authoritative identity); falls back to
71
+ // version 0.0.0 when it doesn't end in a semver (every bucket must attest a version).
124
72
  function parseSoldeerDir(seg) {
125
73
  const m = /^(.+)-(\d+\.\d+\.\d+(?:[-+][\w.-]+)?)$/u.exec(seg)
126
74
  return m ? { name: m[1], version: m[2] } : { name: seg, version: '0.0.0' }
127
75
  }
128
76
 
129
- // Extract `owner/repo` from a github.com remote (https, ssh, or scp-style),
130
- // dropping a trailing `.git`. Returns null for non-github hosts.
77
+ // Extract `owner/repo` from a github.com remote (https/ssh/scp); null for non-github hosts.
131
78
  function githubSlug(url) {
132
79
  const m = /github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?\/?$/iu.exec(url)
133
80
  return m ? `${m[1]}/${m[2]}` : null
134
81
  }
135
82
 
136
- // Parse `.gitmodules` into Map<submodulePath, { name, branch }>, keeping only
137
- // github.com submodules (the ones we can attribute as `github`). `.gitmodules`
138
- // is git-config INI: `[submodule "<n>"]` sections with `path`/`url`/`branch`.
83
+ // Parse `.gitmodules` (git-config INI) into Map<submodulePath, { name, branch }>,
84
+ // github.com submodules only.
139
85
  function parseGithubSubmodules(baseDir) {
140
86
  const byPath = new Map()
141
87
  const text = readFileSyncOrNull(join(baseDir, '.gitmodules'))
@@ -173,13 +119,9 @@ function readFileSyncOrNull(file) {
173
119
  }
174
120
  }
175
121
 
176
- // Classify a bundled Solidity file into its dependency bucket. Solidity installs
177
- // deps three ways, each its own ecosystem: npm (`node_modules`, left to the
178
- // shared bucketizer), Soldeer (`dependencies/<name>-<version>/` → `soldeer`),
179
- // and `forge install` git submodules (`lib/<dir>/`, when `.gitmodules` records a
180
- // github.com URL → `github`, per SBOM/Package-URL `pkg:github/owner/repo`).
181
- // Returns { bucketDir, name, version, ecosystem } for a Soldeer/github dep, or
182
- // null to defer to the package.json/node_modules/workspace logic.
122
+ // Classify a Solidity file's dep bucket: Soldeer (`dependencies/<name>-<version>/`) or a
123
+ // github submodule (`lib/`, via `.gitmodules`), else null to defer to the node_modules/
124
+ // workspace logic.
183
125
  function makeSolidityClassifier(baseDir) {
184
126
  const submodules = parseGithubSubmodules(baseDir)
185
127
  return (path) => {
@@ -200,9 +142,7 @@ function makeSolidityClassifier(baseDir) {
200
142
  }
201
143
  }
202
144
 
203
- // Minimal Cargo.toml reader: pull `name`/`version` from the `[package]` table.
204
- // A full TOML parse isn't warranted — those two string keys are all we need to
205
- // identify a vendored crate. Returns null when there's no readable package name.
145
+ // Minimal Cargo.toml reader: name/version from the `[package]` table; null when no name.
206
146
  function parseCargoToml(file) {
207
147
  const text = readFileSyncOrNull(file)
208
148
  if (!text) return null
@@ -210,7 +150,7 @@ function parseCargoToml(file) {
210
150
  let body = text
211
151
  if (at !== -1) {
212
152
  const rest = text.slice(at)
213
- const next = rest.slice(1).search(/^\[/mu) // start of the table after [package]
153
+ const next = rest.slice(1).search(/^\[/mu)
214
154
  body = next === -1 ? rest : rest.slice(0, next + 1)
215
155
  }
216
156
  const name = /^\s*name\s*=\s*"([^"]+)"/mu.exec(body)?.[1]
@@ -218,10 +158,8 @@ function parseCargoToml(file) {
218
158
  return name ? { name, version: version ?? '0.0.0' } : null
219
159
  }
220
160
 
221
- // Classify a bundled Rust file. `cargo vendor` copies external crates in-tree
222
- // under `vendor/<dir>/`; each is its own crate with a Cargo.toml, so bucket it
223
- // under `vendor/<dir>` and tag it `cargo` (Package-URL `pkg:cargo/<name>`). The
224
- // workspace's own code and path/workspace members defer to the workspace logic.
161
+ // Classify a Rust file: `cargo vendor` crates under `vendor/<dir>/` (tagged `cargo`), else
162
+ // null to defer to the workspace logic.
225
163
  function makeRustClassifier(baseDir) {
226
164
  return (path) => {
227
165
  if (!path.startsWith('vendor/')) return null
@@ -233,44 +171,24 @@ function makeRustClassifier(baseDir) {
233
171
  }
234
172
  }
235
173
 
236
- // Assemble a scope=full code Bundle from a collected source set and its
237
- // resolution graph. Shared by every non-JS bundler (Solidity/Bash/Rust): the
238
- // language-specific work (collecting files, resolving the graph, deciding what
239
- // counts as "missing") happens in the caller; here every file is bucketized
240
- // the same way, tagged `format`, and the whole resolution graph is keyed under
241
- // a single `conditionKey`.
242
- //
243
- // Files are partitioned into per-package buckets by the nearest package.json
244
- // with name+version: node_modules files into `node_modules/<pkg>` buckets,
245
- // workspace files into their package dir, and anything with no discoverable
246
- // package.json into the "." bucket with the given placeholder identity (the
247
- // Bundle layout requires every bucket to attest a name+version). A file under
248
- // node_modules whose nearest package.json is the workspace root is rejected —
249
- // that hides a misconfigured dependency rather than silently mislabeling it.
250
- //
251
- // Unlike the wildcard "*" JS bundles use, these formats don't vary by Node
252
- // resolution condition, so every edge lands under the one `conditionKey`.
253
- //
254
- // Dependency buckets (those under node_modules) are tagged `ecosystem: 'npm'`:
255
- // node_modules is npm's install layout, so that's where the package came from,
256
- // regardless of the bundle's language — a Solidity or Bash import resolved out
257
- // of node_modules is an npm package all the same. This mirrors State's own
258
- // node_modules tagging (see stasis-core state.js) and lets consumers tell deps
259
- // apart from the workspace's own packages; the workspace/`.` bucket carries no
260
- // ecosystem. (PHP's Composer `vendor/` deps are tagged `composer` separately, by
261
- // the PHP bucketizer.)
262
- //
263
- // `classifyDep(path)` is an optional language-specific hook: when it returns a
264
- // { bucketDir, name, version, ecosystem } record the file is placed there
265
- // directly, which is how non-node_modules ecosystems are attributed (e.g.
266
- // Solidity's Soldeer `dependencies/` and github submodules). Returning null (or
267
- // omitting the hook) defers to the node_modules/package.json/workspace logic.
268
- // `format` tags every file uniformly (the single-language non-JS callers); pass
269
- // `formats` (a Map<path, format>) instead to tag per file, as the JS resolver path
270
- // does (a graph mixes module / commonjs / *-typescript / json). `resolutions`
271
- // values may be a target string (a flat edge) or a Map<platform, target> (a `--metro`
272
- // edge that resolves differently per platform) -- both round-trip through the bundle's
273
- // recursive serializer untouched.
174
+ // Project-relative paths in `sources` whose on-disk file carries a POSIX execute bit -- the
175
+ // `executable` list both artifacts record. The State-driven path derives this in addFile; the
176
+ // static builders never touch a State, so they stat here.
177
+ function executableSources(baseDir, sources) {
178
+ const executable = new Set()
179
+ for (const path of sources.keys()) {
180
+ if (isExecutableFile(join(baseDir, path))) executable.add(path)
181
+ }
182
+ return executable
183
+ }
184
+
185
+ // Assemble a full-scope code Bundle shared by the non-JS bundlers. Files are bucketed by
186
+ // nearest package.json (node_modules -> `npm`-tagged bucket, workspace -> its dir, none ->
187
+ // "." with the placeholder identity); a node_modules file whose nearest package.json is the
188
+ // workspace root is rejected, not mislabeled. `classifyDep(path)` optionally places a file
189
+ // directly (non-node_modules ecosystems like Soldeer/github); null defers. `format` tags
190
+ // every file, or pass `formats` (Map<path,format>) to tag per file. `resolutions` values are
191
+ // a flat target string or a Map<platform,target>; both round-trip untouched.
274
192
  function assembleCodeBundle({
275
193
  baseDir, entries, sources, resolutions, workspaceName, workspaceVersion, format, formats, conditionKey, classifyDep,
276
194
  }) {
@@ -313,19 +231,23 @@ function assembleCodeBundle({
313
231
  for (const [parent, specMap] of resolutions) importsForKey.set(parent, specMap)
314
232
  const imports = new Map([[conditionKey, importsForKey]])
315
233
 
234
+ // Executable bits, straight off disk (a synthetic source with no file there is simply not
235
+ // executable). Shell bundles lean on this most: `stasis extract` puts the +x back on the scripts.
236
+ const executable = executableSources(baseDir, sources)
237
+
238
+ // Attribute files to the `bundle` consumer (static builders skip State's per-file tagging).
316
239
  return new Bundle({
317
240
  config: { scope: 'full' },
318
241
  entries: new Set(entries),
319
242
  modules,
320
243
  formats: formatsMap,
321
244
  imports,
322
- })
245
+ executable,
246
+ }).withReason('bundle')
323
247
  }
324
248
 
325
- // Build a stasis Bundle (in-memory) from a list of entry .sol files and
326
- // an optional mapping file. The mapping file (foundry.toml or
327
- // remappings.txt) is parsed for remappings but NOT included in the
328
- // bundle's sources. Returns the constructed `Bundle`.
249
+ // Build an in-memory Bundle from entry .sol files; `mappingFile` (foundry.toml/remappings.txt)
250
+ // is parsed for remappings but not bundled.
329
251
  export async function buildSolidityBundle({ cwd = process.cwd(), entries, mappingFile } = {}) {
330
252
  if (!Array.isArray(entries) || entries.length === 0) {
331
253
  throw new Error('buildSolidityBundle: at least one entry .sol file is required')
@@ -341,10 +263,7 @@ export async function buildSolidityBundle({ cwd = process.cwd(), entries, mappin
341
263
  const sources = await collectSolidityFilesFromDisk(baseDir, normalized, remappings)
342
264
  const { resolutions, missing } = buildSolidityTree(sources, { remappings, baseDir })
343
265
 
344
- // Bundles must be self-contained. Refuse to write one when an entry
345
- // can't be loaded from disk, or when any in-bundle file has an import
346
- // we couldn't resolve — otherwise downstream consumers would silently
347
- // operate on a partial set of sources.
266
+ // Bundles must be self-contained: fail on a missing entry or unresolved import.
348
267
  const issues = []
349
268
  for (const entry of normalized) {
350
269
  if (!sources.has(entry)) issues.push(`Missing entry: ${entry}`)
@@ -369,19 +288,9 @@ export async function buildSolidityBundle({ cwd = process.cwd(), entries, mappin
369
288
  })
370
289
  }
371
290
 
372
- // Build a stasis Bundle (in-memory) from a list of entry .sh/.bash files by
373
- // walking the source/exec graph and reading every reachable script from disk.
374
- // Mirrors buildSolidityBundle's bucketizing, with a bash-specific policy:
375
- // - a .sh/.bash reference whose path lands INSIDE the bundle root
376
- // (`source ./lib.sh`, `dir/x.sh`) but resolves to no bundled script is
377
- // fatal — a self-contained bundle can't carry a dangling local source —
378
- // as is a missing entry.
379
- // - everything else is best-effort and dropped: PATH commands (`grep`,
380
- // `node`), dynamic `$VAR` paths, extensionless sources, absolute system
381
- // paths (`source /etc/profile.d/x.sh`), and `../` paths that escape the
382
- // root — none can live in the bundle.
383
- // - imports live under a dedicated "bash" condition key (like "solidity"),
384
- // not the JS-bundle wildcard "*".
291
+ // Build an in-memory Bundle from entry .sh/.bash files by walking the source/exec graph.
292
+ // A local `source` inside the bundle root that resolves to no bundled script is fatal;
293
+ // external refs (PATH commands, `$VAR`, absolute, `../`-escaping) are dropped.
385
294
  export async function buildBashBundle({ cwd = process.cwd(), entries } = {}) {
386
295
  if (!Array.isArray(entries) || entries.length === 0) {
387
296
  throw new Error('buildBashBundle: at least one entry .sh/.bash file is required')
@@ -396,11 +305,7 @@ export async function buildBashBundle({ cwd = process.cwd(), entries } = {}) {
396
305
  const sources = await collectBashFilesFromDisk(baseDir, normalized)
397
306
  const { resolutions, missing } = buildBashTree(sources)
398
307
 
399
- // Bundles must be self-contained: every entry must load, and every .sh/.bash
400
- // reference pointing inside the bundle root must resolve to a bundled script.
401
- // External/best-effort references (commands, $VAR paths, extensionless,
402
- // absolute, or `../`-escaping sources) are left out by buildBashTree and
403
- // never reach `missing`.
308
+ // Self-contained: fail on a missing entry or unresolved in-root script (external refs never reach `missing`).
404
309
  const issues = []
405
310
  for (const entry of normalized) {
406
311
  if (!sources.has(entry)) issues.push(`Missing entry: ${entry}`)
@@ -419,19 +324,14 @@ export async function buildBashBundle({ cwd = process.cwd(), entries } = {}) {
419
324
  resolutions,
420
325
  workspaceName: BASH_WORKSPACE_NAME,
421
326
  workspaceVersion: BASH_WORKSPACE_VERSION,
422
- format: BASH_FORMAT,
423
- conditionKey: 'bash',
327
+ format: SHELL_FORMAT,
328
+ conditionKey: SHELL_FORMAT,
424
329
  })
425
330
  }
426
331
 
427
- // Build a stasis Bundle (in-memory) from a list of entry .rs files by walking
428
- // `mod` declarations from each crate root and reading every reachable module
429
- // from disk. The bundle must be self-contained: an unresolvable `mod foo;`
430
- // (no backing file) is fatal, as is a missing entry. `use crate::` edges are
431
- // recorded in the import graph but stay best-effort — they never widen the
432
- // file set, and their resolution is approximate (item-vs-module, re-exports),
433
- // so they aren't gated; external crates and std are ignored outright. Imports
434
- // live under a dedicated "rust" condition key.
332
+ // Build an in-memory Bundle from entry .rs files by walking `mod` declarations. An
333
+ // unresolvable `mod` is fatal; `use crate::` edges are recorded best-effort (never widen
334
+ // the file set, not gated).
435
335
  export async function buildRustBundle({ cwd = process.cwd(), entries } = {}) {
436
336
  if (!Array.isArray(entries) || entries.length === 0) {
437
337
  throw new Error('buildRustBundle: at least one entry .rs file is required')
@@ -470,25 +370,10 @@ export async function buildRustBundle({ cwd = process.cwd(), entries } = {}) {
470
370
  })
471
371
  }
472
372
 
473
- // Build a stasis Bundle (in-memory) from a list of entry .php files by
474
- // statically scanning their `require`/`include` graph -- and, when the project
475
- // uses Composer, the class-autoload graph too -- reading every reachable file
476
- // from disk. No PHP is ever executed. Mirrors buildSolidityBundle: files are
477
- // bucketed per package.json, tagged with the `php` format, and edges are keyed
478
- // under a dedicated "php" condition bucket.
479
- //
480
- // Composer autoloading is followed automatically when a composer.json (or
481
- // generated vendor/composer/autoload_*.php map) is present: PSR-4/PSR-0/
482
- // classmap/files config is read and the classes each file references (`use`
483
- // imports, `new`/`extends`/`implements`, type hints, …) are resolved to their
484
- // files the way Composer's ClassLoader would. That resolution is best-effort
485
- // (unresolvable references are typically built-in or extension classes), so it
486
- // never blocks the bundle; explicit `require`/`include` of a literal path is
487
- // still strict.
488
- //
489
- // Refuses to write when an entry can't be loaded or any explicit include is
490
- // unresolved -- a bundle with holes would silently operate on a partial set of
491
- // sources.
373
+ // Build an in-memory Bundle from entry .php files by statically scanning the
374
+ // require/include graph (and Composer's class-autoload graph when present); no PHP is
375
+ // executed. Autoload resolution is best-effort; explicit require/include of a literal path
376
+ // is strict, and a missing entry or unresolved explicit include is fatal.
492
377
  export async function buildPhpBundle({ cwd = process.cwd(), entries } = {}) {
493
378
  if (!Array.isArray(entries) || entries.length === 0) {
494
379
  throw new Error('buildPhpBundle: at least one entry .php file is required')
@@ -501,9 +386,8 @@ export async function buildPhpBundle({ cwd = process.cwd(), entries } = {}) {
501
386
  const normalized = normalizeEntries(entries, cwd)
502
387
  const autoload = loadComposerAutoload(baseDir)
503
388
 
504
- // Laravel auto-discovers vendor/app service providers (via composer metadata
505
- // and bootstrap/providers.php) rather than referencing them statically; seed
506
- // them as extra roots so the config/route/view files they pull in get bundled.
389
+ // Laravel auto-discovers service providers rather than referencing them statically; seed
390
+ // them as extra roots so their config/route/view files get bundled.
507
391
  const providerRoots = loadLaravelProviderFiles(baseDir, autoload)
508
392
 
509
393
  const sources = await collectPhpFilesFromDisk(baseDir, [...normalized, ...providerRoots], { autoload })
@@ -520,38 +404,46 @@ export async function buildPhpBundle({ cwd = process.cwd(), entries } = {}) {
520
404
  throw new Error(`PHP bundle has unresolved imports:\n${issues.map((s) => ` ${s}`).join('\n')}`)
521
405
  }
522
406
 
523
- // Group per Composer package (vendor/<pkg> buckets with name+version from
524
- // composer.json / installed.json), not the node_modules-based bucketizer.
407
+ // Group per Composer package (vendor/<pkg>), not the node_modules bucketizer.
525
408
  const modules = bucketizePhpSources(baseDir, sources, PHP_WORKSPACE_NAME, PHP_WORKSPACE_VERSION)
526
409
 
527
410
  const formats = new Map()
528
411
  for (const path of sources.keys()) formats.set(path, PHP_FORMAT)
529
412
 
530
- // PHP includes don't depend on Node conditions; key them under a dedicated
531
- // "php" bucket rather than the wildcard "*" used for JS code bundles.
413
+ // PHP includes don't vary by Node condition; key edges under "php", not the JS wildcard "*".
532
414
  const importsForKey = new Map()
533
415
  for (const [parent, specMap] of resolutions) importsForKey.set(parent, specMap)
534
416
  const imports = new Map([['php', importsForKey]])
535
417
 
418
+ // Attribute to the `bundle` consumer, like the other static builders.
536
419
  return new Bundle({
537
420
  config: { scope: 'full' },
538
421
  entries: new Set(normalized),
539
422
  modules,
540
423
  formats,
541
424
  imports,
542
- })
425
+ executable: executableSources(baseDir, sources),
426
+ }).withReason('bundle')
427
+ }
428
+
429
+ // Render a scanned file URL for diagnostics: project-relative when inside `baseDir`, else absolute.
430
+ // The scan issue checks run on the LIVE scan, whose files/imports are keyed by absolute file: URLs
431
+ // (the resolver returns absolute paths); relativizing here matches the project-relative paths the
432
+ // bundle itself stores (toRel), rather than leaking the machine's absolute paths into the message.
433
+ function displayPath(url, baseDir) {
434
+ const abs = fileURLToPath(url)
435
+ if (!baseDir) return abs
436
+ const rel = relative(baseDir, abs)
437
+ return rel && !rel.startsWith('..') && !isAbsolute(rel) ? rel : abs
543
438
  }
544
439
 
545
- // Classify a scanner's unresolved edges + parse errors into `fatal` (the bundle
546
- // would be broken, or silently divergent from plain node, at load) vs tolerated
547
- // (a miss runtime code can catch). The rationale for each rule lives at the call
548
- // site in buildJsBundle; this is the shared computation so the plain and the
549
- // resolver-driven (`--mainFields`) JS paths gate identically.
550
- function analyzeScanner(scanner) {
551
- // Static reachability: files reached from an entry through static ESM
552
- // `import`/`export ... from` edges load eagerly, before any user code runs, so
553
- // a failure there is uncatchable; below a require()/dynamic-import() boundary it
554
- // surfaces as a catchable miss and only warns.
440
+ // Classify scanner unresolved edges + parse errors into fatal (broken/divergent at load)
441
+ // vs tolerated (a catchable runtime miss). Shared so the plain and --mainFields JS paths
442
+ // gate identically. `baseDir` relativizes the paths in the emitted messages (see displayPath).
443
+ function analyzeScanner(scanner, { baseDir } = {}) {
444
+ const show = (url) => displayPath(url, baseDir)
445
+ // Static ESM import/export-from edges load eagerly before user code, so a failure there is
446
+ // uncatchable (fatal); misses below a require()/dynamic-import() boundary are catchable (warn).
555
447
  const staticKinds = new Set(['import', 'export-from'])
556
448
  const staticReachable = new Set(scanner.entries)
557
449
  const walk = [...scanner.entries]
@@ -566,20 +458,19 @@ function analyzeScanner(scanner) {
566
458
  const fatalUnresolved = (u) => staticKinds.has(u.kind) && staticReachable.has(u.parentURL)
567
459
  const fatal = scanner.unresolved
568
460
  .filter((u) => fatalUnresolved(u))
569
- .map((u) => `unresolved ${u.kind} ${u.spec} from ${fileURLToPath(u.parentURL)} (${u.reason})`)
570
- // Parse failures whose edges are genuinely unknown: a module-family file in the
571
- // eagerly-linked set, or any file the parser couldn't process at all.
461
+ .map((u) => `unresolved ${u.kind} ${u.spec} from ${show(u.parentURL)} (${u.reason})`)
462
+ // Fatal parse: an eagerly-linked module-family file, or any file the parser couldn't process.
572
463
  const fatalParse = (p) => staticReachable.has(p.url) && (p.format?.startsWith('module') === true || !p.recovered)
573
464
  for (const p of scanner.parseErrors) {
574
- if (fatalParse(p)) fatal.push(`parse error in ${fileURLToPath(p.url)}: ${p.message}`)
465
+ if (fatalParse(p)) fatal.push(`parse error in ${show(p.url)}: ${p.message}`)
575
466
  }
576
- // Edges resolving to a file a source bundle can't carry (extensionless, .node,
577
- // .wasm): scan records the edge but never queues the child, so load would die.
467
+ // Edge resolving to a file a source bundle can't carry (.node/.wasm/extensionless): scan
468
+ // records it but never queues the child, so load would die.
578
469
  for (const [, byParent] of scanner.imports) {
579
470
  for (const [parentURL, specMap] of byParent) {
580
471
  for (const [spec, childURL] of specMap) {
581
472
  if (!scanner.files.has(childURL)) {
582
- fatal.push(`${spec} from ${fileURLToPath(parentURL)} resolves to ${fileURLToPath(childURL)}, which a source bundle can't carry`)
473
+ fatal.push(`${spec} from ${show(parentURL)} resolves to ${show(childURL)}, which a source bundle can't carry`)
583
474
  }
584
475
  }
585
476
  }
@@ -589,10 +480,11 @@ function analyzeScanner(scanner) {
589
480
  return { fatal, tolerated, toleratedParse }
590
481
  }
591
482
 
592
- // Throw on any fatal scan issue (the bundle can't be written); warn on tolerated
593
- // ones. `label`, when set, tags the scan pass in the message.
594
- function reportScanIssues({ fatal, tolerated, toleratedParse }, { label = '' } = {}) {
483
+ // Throw on fatal scan issues; warn on tolerated ones. `label` tags the scan pass; `baseDir`
484
+ // relativizes the paths in the warnings (see displayPath), matching analyzeScanner's messages.
485
+ function reportScanIssues({ fatal, tolerated, toleratedParse }, { label = '', baseDir } = {}) {
595
486
  const where = label ? ` (${label})` : ''
487
+ const show = (url) => displayPath(url, baseDir)
596
488
  if (fatal.length > 0) {
597
489
  const shown = fatal.slice(0, 10).map((s) => ` ${s}`).join('\n')
598
490
  const more = fatal.length > 10 ? `\n ... and ${fatal.length - 10} more` : ''
@@ -601,7 +493,7 @@ function reportScanIssues({ fatal, tolerated, toleratedParse }, { label = '' } =
601
493
  if (tolerated.length > 0) {
602
494
  const summary = tolerated
603
495
  .slice(0, 10)
604
- .map((u) => ` ${u.kind} ${u.spec ?? '<dynamic>'} from ${fileURLToPath(u.parentURL)} (${u.reason})`)
496
+ .map((u) => ` ${u.kind} ${u.spec ?? '<dynamic>'} from ${show(u.parentURL)} (${u.reason})`)
605
497
  .join('\n')
606
498
  const more = tolerated.length > 10 ? `\n ... and ${tolerated.length - 10} more` : ''
607
499
  console.warn(`[stasis] Bundle has ${tolerated.length} unresolved import(s)${where}; they will fall through at load time:\n${summary}${more}`)
@@ -609,47 +501,19 @@ function reportScanIssues({ fatal, tolerated, toleratedParse }, { label = '' } =
609
501
  if (toleratedParse.length > 0) {
610
502
  const summary = toleratedParse
611
503
  .slice(0, 10)
612
- .map((p) => ` ${fileURLToPath(p.url)}: ${p.message}`)
504
+ .map((p) => ` ${show(p.url)}: ${p.message}`)
613
505
  .join('\n')
614
506
  const more = toleratedParse.length > 10 ? `\n ... and ${toleratedParse.length - 10} more` : ''
615
507
  console.warn(`[stasis] Bundle has ${toleratedParse.length} file(s) with parse errors${where}; their recorded imports may be incomplete:\n${summary}${more}`)
616
508
  }
617
509
  }
618
510
 
619
- // Build a stasis Bundle (in-memory) from a list of entry .js/.cjs/.mjs (or
620
- // .ts/.cts/.mts) files by statically scanning the require/import graph and
621
- // reading reachable file contents from disk -- no user code is ever loaded or
622
- // executed. TypeScript files behave exactly like JS ones: sources are stored
623
- // verbatim with Node's type-stripping formats recorded, and Node strips the
624
- // types at load time. The bundle shape matches what @exodus/stasis-core/hooks
625
- // records at runtime, so the result loads via `stasis run --bundle=load`
626
- // interchangeably with a runtime-produced one.
627
- //
628
- // Refuses to write when the bundle is guaranteed broken (or silently divergent
629
- // from plain node) at load time: an unresolved static ESM `import`/`export ...
630
- // from` edge reachable from an entry through static edges alone (link-time,
631
- // uncatchable), a module file that can't be parsed (its static edges are
632
- // unknown), or an edge resolving to a file a source bundle can't carry
633
- // (extensionless, .node, .wasm). Failures that runtime code can catch --
634
- // unresolved require()/dynamic import() edges, including entire subtrees
635
- // behind such a boundary -- only warn (see below).
636
- //
637
- // Scope comes from the project's stasis.config.json (or `EXODUS_STASIS_SCOPE`);
638
- // pass `scope` explicitly to override.
639
- //
640
- // `conditions` are extra `exports`/`imports` resolution conditions, merged on top
641
- // of the format-derived base set Node always asserts (`node`, `import`/`require`,
642
- // `module-sync`, `node-addons`). They change which branch of a package's
643
- // conditional `exports` the static scan follows -- and therefore which file lands
644
- // in the bundle -- exactly as if Node resolved with those conditions active. Empty
645
- // (the default) reproduces plain Node resolution.
646
- //
647
- // NOTE: conditions are only one input to module resolution. On their own they do
648
- // NOT honour legacy package `mainFields` (`react-native`/`browser`/`main`) or
649
- // platform-specific file suffixes (`.ios`/`.android`/`.native`), neither of which
650
- // the `exports` field expresses; `--mainFields` (see buildResolvedJsBundle) adds
651
- // the former.
652
- export async function buildJsBundle({ cwd = process.cwd(), entries, scope, conditions = [] } = {}) {
511
+ // Build a JS/TS Bundle (in-memory) by statically scanning the require/import graph; no
512
+ // user code is executed, and TS is stored verbatim (Node strips types at load). Scope comes
513
+ // from stasis.config.json / `EXODUS_STASIS_SCOPE` unless `scope` overrides. `conditions` are
514
+ // extra `exports`/`imports` conditions; on their own they don't honour legacy mainFields or
515
+ // platform suffixes (see `--mainFields` / buildResolvedJsBundle).
516
+ export async function buildJsBundle({ cwd = process.cwd(), entries, scope, conditions = [], jsx = false, flow = false, resources = [], packageJSON = false } = {}) {
653
517
  if (!Array.isArray(entries) || entries.length === 0) {
654
518
  throw new Error('buildJsBundle: at least one entry .js/.cjs/.mjs/.ts/.cts/.mts file is required')
655
519
  }
@@ -660,84 +524,138 @@ export async function buildJsBundle({ cwd = process.cwd(), entries, scope, condi
660
524
  const baseDir = resolve(cwd)
661
525
  const absEntries = entries.map((e) => resolve(baseDir, e))
662
526
 
663
- // Normalize conditions the way the CLI already does (trim, drop empties), so the
664
- // programmatic API (`buildBundle({ conditions })`) tolerates the same sloppy input.
665
- // A '' or whitespace-only condition never matches an `exports` key, so dropping it
666
- // is a no-op for clean input -- this just stops a bogus token reaching the resolver.
527
+ // Normalize conditions (trim, drop empties) so a sloppy programmatic caller can't push a
528
+ // bogus token into the resolver.
667
529
  const scanConditions = conditions.map((c) => (typeof c === 'string' ? c.trim() : c)).filter(Boolean)
530
+ // --resources: extensions/filenames carried as opaque assets instead of failing "can't carry".
531
+ const resourceSet = parseResourcesOption('buildJsBundle', resources)
668
532
 
669
- const scanner = scan(absEntries, { conditions: scanConditions })
670
-
671
- // Fail closed exactly where the bundle is GUARANTEED broken (or silently
672
- // divergent from plain node) at load time; warn where runtime code can catch
673
- // the miss the same way it would without a bundle. Static ESM edges load
674
- // eagerly (uncatchable) so unresolved ones / unknown parse holes there are
675
- // fatal; require()/dynamic-import() misses surface at the callsite and only
676
- // warn -- the optional-dependency pattern (`try { require(x) } catch {}`) keeps
677
- // bundling, because the runtime loader never records those edges either and
678
- // state.getImport throws the same catchable MODULE_NOT_FOUND at load. Edges
679
- // resolving to a file a source bundle can't carry are always fatal. (See
680
- // analyzeScanner for the precise rules.)
681
- reportScanIssues(analyzeScanner(scanner))
682
-
683
- // Use a non-preload State to materialise the bundle: addFile bucketizes by
684
- // package.json + records hashes/sources/formats, and addImport replays the
685
- // per-edge import map. State.s `serialize` then emits the same v1 layout
686
- // the runtime loader writes — see @exodus/stasis-core/hooks for the matching pair.
687
- //
688
- // bundle=replace skips reading any pre-existing stasis.code.br on disk;
689
- // bundle=add would merge it, leaking stale formats/imports entries for
690
- // files the new bundle doesn't carry. lock=ignore tolerates a pre-existing
691
- // stasis.lock.json without loading or validating it (the bundle path
692
- // doesn't consume one), and refusing to coexist with one (lock=none) would
693
- // make `stasis bundle` brittle in any project that's also using `stasis run`.
533
+ const scanner = scan(absEntries, { conditions: scanConditions, jsx, flow, resources: resourceSet })
534
+
535
+ // Fail closed where the bundle is guaranteed broken at load; warn on catchable misses (see analyzeScanner).
536
+ reportScanIssues(analyzeScanner(scanner, { baseDir }), { baseDir })
537
+
538
+ // Materialise via a non-preload State: addFile bucketizes + records sources/formats,
539
+ // addImport replays the edge map; serialize emits the runtime loader's v1 layout.
540
+ // bundle:'replace' skips reading any on-disk stasis.code.br (bundle:'add' would leak stale
541
+ // entries); lock:'ignore' tolerates a pre-existing lockfile without consuming it.
694
542
  const state = new State(baseDir, { bundle: 'replace', lock: 'ignore', ...(scope ? { scope } : {}) })
695
543
  for (const [url, info] of scanner.files) {
544
+ // A resource carries bytes only: addFile derives resource vs resource:base64 from the content
545
+ // and stores it under `resources` (a resource can't be an entry, so no isEntry).
546
+ if (info.resource) {
547
+ state.addFile(url, { resource: true })
548
+ continue
549
+ }
696
550
  const isEntry = scanner.entries.has(url)
697
551
  state.addFile(url, { format: info.format, isEntry })
698
552
  }
699
- // Static bundles can't anticipate the full condition set Node will pass to
700
- // its resolve hook at load time (Node adds runtime conditions like
701
- // 'module-sync', 'node-addons' that aren't accept-list conditions). Store
702
- // every edge under the wildcard '*' key; State.getImport falls back to it
703
- // when the runtime-condition lookup misses.
704
- for (const [, byParent] of scanner.imports) {
705
- for (const [parentURL, specs] of byParent) {
553
+ // Edges where every context agrees keep the wildcard '*' key (getImport's fallback when a
554
+ // condition lookup misses). Where the require()- and import()-context resolutions of one
555
+ // (parent, specifier) DIVERGE, each target keeps its real condition key -- one '*' entry
556
+ // would serve one context the other's file; an unmatched set fails closed via resolveBundled.
557
+ const byParent = new Map()
558
+ for (const [key, parents] of scanner.imports) {
559
+ for (const [parentURL, specs] of parents) {
560
+ if (!byParent.has(parentURL)) byParent.set(parentURL, new Map())
561
+ const bySpec = byParent.get(parentURL)
706
562
  for (const [spec, childURL] of specs) {
707
- state.addImport(parentURL, spec, childURL, { conditions: '*' })
563
+ if (!bySpec.has(spec)) bySpec.set(spec, new Map())
564
+ bySpec.get(spec).set(key, childURL)
565
+ }
566
+ }
567
+ }
568
+ for (const [parentURL, bySpec] of byParent) {
569
+ for (const [spec, byKey] of bySpec) {
570
+ const targets = new Set(byKey.values())
571
+ if (targets.size === 1) {
572
+ state.addImport(parentURL, spec, [...targets][0], { conditions: '*' })
573
+ } else {
574
+ for (const [key, childURL] of byKey) {
575
+ state.addImport(parentURL, spec, childURL, { conditions: key.split(', ') })
576
+ }
708
577
  }
709
578
  }
710
579
  }
580
+ // --package-json: fold each bundled module's package.json into the State's buckets (idempotent --
581
+ // no-ops any manifest the scan already reached). State's bundle=replace makes writeBundle true, so addFile accepts them.
582
+ if (packageJSON) state.includePackageJson()
711
583
  return state
712
584
  }
713
585
 
714
- // Source extensions probed when a resolved entry/redirect target names no extension.
715
- // Limited to what a source bundle can actually carry (scan's RESOLVABLE_EXTS), so the
716
- // resolver never resolves a file the bundle would then reject. `--mainFields` lets a
717
- // caller pick its own fields, `--metro` presets the React Native fields, and
718
- // `--conditions` adds its own extra exports conditions.
586
+ // Extensions probed when a resolved target names none. Limited to what a source bundle can
587
+ // carry (scan's RESOLVABLE_EXTS) so the resolver never resolves a file the bundle would reject.
588
+ // --jsx widens this to the JSX/TSX extensions (mirroring scan's jsx-gated RESOLVABLE_EXTS) so an
589
+ // extensionless `import './Foo'` can land on Foo.jsx/Foo.tsx, as Metro's sourceExts do. `.js`/`.ts`
590
+ // keep their existing precedence (base order preserved as a subsequence); collisions across the
591
+ // added extensions are rare and, like the base list, don't track the project's real Metro order.
719
592
  const SOURCE_EXTS = ['js', 'json', 'ts']
720
- // React Native preset mainFields asserted by `--metro` (which sets these plus the
721
- // react-native/browser conditions and platform suffixes, so the user supplies neither
722
- // --mainFields nor --conditions alongside it).
593
+ const SOURCE_EXTS_JSX = ['js', 'jsx', 'json', 'ts', 'tsx']
594
+ // React Native preset mainFields for `--metro` (which also sets the RN conditions + platform suffixes).
723
595
  const METRO_MAIN_FIELDS = ['react-native', 'browser', 'main']
724
- // Synthetic, project-relative path for the empty module a browser/react-native
725
- // `false` redirect resolves to. Carried in the bundle as a real (empty) CJS file
726
- // so the edge points at attestable bytes rather than a sentinel.
596
+ // Synthetic path for the empty module a browser/react-native `false` redirect resolves to,
597
+ // carried as a real empty CJS file so the edge points at attestable bytes.
727
598
  const EMPTY_MODULE_PATH = '.stasis/empty-module.js'
728
599
 
729
- // Build a JS/TS Bundle (and companion Lockfile) through the legacy-field resolver
730
- // (src/resolve-fields.js) rather than Node's resolver -- the `--mainFields` and
731
- // `--metro` paths. The graph is scanned once PER platform (just `[null]` in
732
- // `--mainFields` mode, where there are no platform suffixes); the file set is the union
733
- // across platforms, and each `(parent, specifier)` edge is recorded FLAT (a single
734
- // target) when every platform that has it agrees, and UNFLATTENED to a
735
- // `{ platform: target }` map only where platforms genuinely diverge. A single platform
736
- // therefore never unflattens; platform keys are exactly the supplied platforms (no
737
- // wildcard). A browser/react-native `false` redirect resolves to a synthetic empty
738
- // module carried in the bundle. Returns { bundle, lockfile } (the lockfile attests the
739
- // same files + the same -- possibly per-platform -- edges, by integrity).
740
- async function buildResolvedJsBundle({ cwd = process.cwd(), entries, mainFields, platforms, conditions = [], metro = false }) {
600
+ // Recursively collect files under a native ios/android dir, skipping build output and symlinks
601
+ // (cycle/escape hazard). Absolute paths into `out`.
602
+ function walkNativeDir(dirAbs, out) {
603
+ let entries
604
+ try {
605
+ entries = readdirSync(dirAbs, { withFileTypes: true })
606
+ } catch {
607
+ return // absent -- nothing for this platform
608
+ }
609
+ for (const ent of entries) {
610
+ if (ent.isSymbolicLink()) continue
611
+ const full = join(dirAbs, ent.name)
612
+ if (ent.isDirectory()) {
613
+ if (!isSkippedNativeWalkDir(ent.name)) walkNativeDir(full, out)
614
+ } else if (ent.isFile() && !isNativeArtifact(ent.name)) {
615
+ out.push(full)
616
+ }
617
+ }
618
+ }
619
+
620
+ // Recursively collect podspec-load manifests (isNativeManifest) under `dirAbs`. RN's own
621
+ // podspecs live in scattered subdirs a root-only scan would miss, so recurse fully.
622
+ function collectNativeManifests(dirAbs, out, atRoot = false) {
623
+ let entries
624
+ try {
625
+ entries = readdirSync(dirAbs, { withFileTypes: true })
626
+ } catch {
627
+ return
628
+ }
629
+ for (const ent of entries) {
630
+ if (ent.isSymbolicLink()) continue
631
+ const full = join(dirAbs, ent.name)
632
+ if (ent.isDirectory()) {
633
+ if (!isSkippedNativeWalkDir(ent.name) && !(atRoot && isExcludedNativeDir(ent.name))) collectNativeManifests(full, out)
634
+ } else if (ent.isFile() && isNativeManifest(ent.name)) {
635
+ out.push(full)
636
+ }
637
+ }
638
+ }
639
+
640
+ // Native source files a bundled RN dep contributes to the app's native build (podspecs +
641
+ // ios/android sources). Manifests are kept only when the package is actually native (podspec
642
+ // or ios/android dir), else a JS-only dep's package.json would be pulled in. Deduped absolute paths.
643
+ function nativeModuleFiles(pkgAbs) {
644
+ const manifests = []
645
+ collectNativeManifests(pkgAbs, manifests, true)
646
+ const hasIos = existsSync(join(pkgAbs, 'ios'))
647
+ const hasAndroid = existsSync(join(pkgAbs, 'android'))
648
+ if (!hasIos && !hasAndroid && !manifests.some((f) => isPodspec(f))) return []
649
+ const out = [...manifests]
650
+ if (hasIos) walkNativeDir(join(pkgAbs, 'ios'), out)
651
+ if (hasAndroid) walkNativeDir(join(pkgAbs, 'android'), out)
652
+ return [...new Set(out)]
653
+ }
654
+
655
+ // Build a JS/TS Bundle + companion Lockfile via the legacy-field resolver (`--mainFields`/
656
+ // `--metro`). Scanned once per platform; each edge is recorded flat when the platforms that
657
+ // have it agree, or as a `{ platform: target }` map where they diverge. Returns { bundle, lockfile }.
658
+ async function buildResolvedJsBundle({ cwd = process.cwd(), entries, mainFields, platforms, conditions = [], metro = false, metroResolver = false, jsx = false, flow = false, resources = [], packageJSON = false }) {
741
659
  const baseDir = resolve(cwd)
742
660
  const absEntries = entries.map((e) => resolve(baseDir, e))
743
661
  const normalized = normalizeEntries(entries, cwd)
@@ -750,36 +668,48 @@ async function buildResolvedJsBundle({ cwd = process.cwd(), entries, mainFields,
750
668
  return rel
751
669
  }
752
670
 
753
- // Normalize conditions like buildJsBundle does, so a sloppy programmatic caller
754
- // (stray '' / whitespace) can't push a bogus token into the resolver.
671
+ // Normalize conditions (drop stray ''/whitespace) so a sloppy caller can't push a bogus token into the resolver.
755
672
  const scanConditions = conditions.map((c) => (typeof c === 'string' ? c.trim() : c)).filter(Boolean)
756
673
 
674
+ // Under --jsx the resolver probes .jsx/.tsx too, matching scan's jsx-widened carryable set.
675
+ const sourceExts = jsx ? SOURCE_EXTS_JSX : SOURCE_EXTS
676
+ // --resources: extensions/filenames carried as opaque assets instead of failing "can't carry".
677
+ const resourceSet = parseResourcesOption('buildResolvedJsBundle', resources)
678
+
757
679
  const formatsByRel = new Map()
758
- // parentRel -> specifier -> Map<platformKey, targetRel>. Collapsed into flat /
759
- // platform-map edges after every platform has been scanned.
680
+ // Reached files the scanner tagged as resources (rel keys); read as bytes below, not UTF-8 source.
681
+ const resourceRels = new Set()
682
+ // parentRel -> specifier -> Map<platformKey, targetRel>; collapsed after all platforms scanned.
760
683
  const edges = new Map()
761
- const reached = new Set() // absolute paths of every real file reached on any platform
684
+ const reached = new Set() // absolute paths reached on any platform
762
685
  let usesEmpty = false
763
686
 
764
687
  for (const platform of platforms) {
765
- // `--metro` asserts the React Native preset's conditions (react-native, plus
766
- // browser for the web platform); `--mainFields` (platform === null) carries the
767
- // user's own `--conditions`. scan layers these on the format-derived base.
688
+ // --metro asserts the RN conditions (+ browser on web); --mainFields (platform null) carries the user's --conditions.
768
689
  const extras = metro ? ['react-native', ...(platform === 'web' ? ['browser'] : [])] : scanConditions
769
- const resolver = createFieldResolver({
770
- mainFields,
771
- platform,
772
- preferNative: platform !== null && platform !== 'web',
773
- sourceExts: SOURCE_EXTS,
774
- conditions: resolveConditions('commonjs', extras),
775
- })
776
- const scanner = scan(absEntries, { conditions: extras, resolve: resolver })
777
- reportScanIssues(analyzeScanner(scanner), { label: platform ?? 'mainFields' })
690
+ // --metro --metro-resolver delegates to the project's own metro-resolver for byte-for-byte Metro
691
+ // fidelity; otherwise the built-in field/suffix resolver (resolve-fields.js) reproduces it.
692
+ // metro-resolver derives default/require|import/platform conditions itself, so it takes only the
693
+ // extra `react-native` condition (browser comes from its per-platform map, keyed on `web`).
694
+ const resolver = metroResolver
695
+ ? createMetroResolver({ projectDir: baseDir, platform, sourceExts, mainFields, conditionNames: ['react-native'] })
696
+ : createFieldResolver({
697
+ mainFields,
698
+ platform,
699
+ preferNative: platform !== null && platform !== 'web',
700
+ sourceExts,
701
+ conditions: resolveConditions('commonjs', extras),
702
+ // Opt into Metro's package-entry browser-field quirks only on the --metro path.
703
+ metro,
704
+ })
705
+ const scanner = scan(absEntries, { conditions: extras, resolve: resolver, jsx, flow, resources: resourceSet })
706
+ reportScanIssues(analyzeScanner(scanner, { baseDir }), { baseDir, label: platform ?? 'mainFields' })
778
707
 
779
708
  const platformKey = platform ?? '*' // '*' is a private placeholder for the single mainFields pass; it never unflattens
780
709
  for (const [url, info] of scanner.files) {
781
710
  const rel = toRel(fileURLToPath(url))
782
711
  reached.add(fileURLToPath(url))
712
+ if (info.resource) resourceRels.add(rel)
783
713
  formatsByRel.set(rel, info.format)
784
714
  for (const e of info.edges) {
785
715
  if (e.builtin || e.dynamic) continue
@@ -795,28 +725,31 @@ async function buildResolvedJsBundle({ cwd = process.cwd(), entries, mainFields,
795
725
  }
796
726
  }
797
727
 
798
- // Read every reached file's bytes once: content for the bundle, integrity for the
799
- // lockfile (hash the raw bytes so it matches `stasis run --lock=frozen`). The stored
800
- // source is UTF-8 text, so the bytes must BE valid UTF-8 -- otherwise toString('utf8')
801
- // would lossily diverge from the hashed bytes.
728
+ // Read each reached file once: bytes for the bundle, integrity for the lockfile. Source is
729
+ // stored as UTF-8 text, so non-UTF-8 bytes would diverge from the hashed bytes -- reject them.
802
730
  const sources = new Map()
803
731
  const integrities = new Map()
804
732
  const realBase = realpathSync(baseDir)
805
733
  for (const abs of reached) {
806
734
  const rel = toRel(abs)
807
- // A bundle must carry only in-tree, attestable bytes. The field resolver returns the
808
- // lexical path it probed, so an in-tree-named symlink whose real target escapes the
809
- // project root would slip past toRel's textual check -- realpath it and fail closed,
810
- // the same way the State-based JS path and the non-JS loaders do.
735
+ // Security: the field resolver returns the lexical path, so an in-tree-named symlink
736
+ // escaping the root would slip past toRel's textual check -- realpath and fail closed.
811
737
  assertRealPathWithinBase(realBase, baseDir, rel)
812
738
  const buf = readFileSync(abs)
813
- if (!isUtf8(buf)) throw new Error(`JS bundle source is not valid UTF-8: ${rel}`)
814
- sources.set(rel, buf.toString('utf8'))
739
+ if (resourceRels.has(rel)) {
740
+ // A resource carries bytes, not JS source: store UTF-8 verbatim or base64 when binary, and
741
+ // tag the byte-derived format (same shape as the --metro native capture below).
742
+ const utf8 = isUtf8(buf)
743
+ sources.set(rel, utf8 ? buf.toString('utf8') : buf.toString('base64'))
744
+ formatsByRel.set(rel, utf8 ? 'resource' : 'resource:base64')
745
+ } else {
746
+ if (!isUtf8(buf)) throw new Error(`JS bundle source is not valid UTF-8: ${rel}`)
747
+ sources.set(rel, buf.toString('utf8'))
748
+ }
815
749
  integrities.set(rel, sha512integrity(buf))
816
750
  }
817
751
  if (usesEmpty) {
818
- // The empty module is synthetic; refuse to shadow a real reached file that
819
- // happens to sit at the reserved path rather than silently clobber it.
752
+ // Refuse to shadow a real reached file sitting at the reserved empty-module path.
820
753
  if (formatsByRel.has(EMPTY_MODULE_PATH)) {
821
754
  throw new Error(`Bundle needs the reserved empty-module path ${EMPTY_MODULE_PATH}, but the project has a real file there`)
822
755
  }
@@ -825,8 +758,96 @@ async function buildResolvedJsBundle({ cwd = process.cwd(), entries, mainFields,
825
758
  integrities.set(EMPTY_MODULE_PATH, sha512integrity(Buffer.alloc(0)))
826
759
  }
827
760
 
828
- // Collapse each edge: one distinct target across the platforms that have it -> flat
829
- // string; otherwise a Map<platform, target> (platform keys only, sorted).
761
+ // --metro also carries each bundled dependency's native build-input surface (ios/android
762
+ // sources + podspecs), scoped to the node_modules packages actually in the bundle. Native
763
+ // source is stored as code under a language tag; other assets as 'resource'/'resource:base64'.
764
+ if (metro) {
765
+ const pkgDirs = new Set()
766
+ for (const abs of reached) {
767
+ // Follow the CODE/module graph only: a package reached solely for an asset (--resources) is
768
+ // not a linked native dependency, so it must not drag in its ios/android surface.
769
+ if (resourceRels.has(toRel(abs))) continue
770
+ const nm = splitNodeModulesPath(toRel(abs))
771
+ if (nm) pkgDirs.add(nm.dir)
772
+ }
773
+ for (const pkgDir of [...pkgDirs].toSorted()) {
774
+ const pkgAbs = join(baseDir, pkgDir)
775
+ // react-native core isn't a Pod (config reports it via reactNativePath, not `dependencies`),
776
+ // so walk its whole tree for native source (React/, ReactCommon/, ReactAndroid/, ...) the same
777
+ // way a native dep's ios/android surface is walked; every other dep gets its ios/android + podspecs.
778
+ const isRnCore = pkgDir.slice(pkgDir.lastIndexOf('node_modules/') + 'node_modules/'.length) === 'react-native'
779
+ const files = isRnCore ? [] : nativeModuleFiles(pkgAbs)
780
+ if (isRnCore) walkNativeDir(pkgAbs, files)
781
+ for (const abs of files) {
782
+ const rel = toRel(abs)
783
+ if (sources.has(rel)) continue
784
+ assertRealPathWithinBase(realBase, baseDir, rel)
785
+ // classifyNativeCapture (shared with the StasisMetro plugin) returns action skip/code/resource with a format tag.
786
+ const byName = classifyNativeCapture(rel)
787
+ if (byName.action === 'skip') continue
788
+ const buf = readFileSync(abs)
789
+ // Byte-level rules (prebuilt binaries, binary plists) -- see refineNativeCapture.
790
+ const { action, format } = refineNativeCapture(byName, rel, buf, resourceSet)
791
+ if (action === 'skip') continue
792
+ const utf8 = isUtf8(buf)
793
+ if (action === 'code') {
794
+ if (!utf8) throw new Error(`native source is not valid UTF-8: ${rel}`)
795
+ sources.set(rel, buf.toString('utf8'))
796
+ formatsByRel.set(rel, format)
797
+ } else {
798
+ sources.set(rel, utf8 ? buf.toString('utf8') : buf.toString('base64'))
799
+ formatsByRel.set(rel, utf8 ? 'resource' : 'resource:base64')
800
+ }
801
+ integrities.set(rel, sha512integrity(buf))
802
+ }
803
+ // RN core's `.js` build scripts the classify loop skips (Metro-owned by extension, but never in
804
+ // the graph): force-include as code so the podspec/Ruby that invokes them at pod-install resolves.
805
+ if (isRnCore) {
806
+ for (const file of RN_CORE_INCLUDE_FILES) {
807
+ const abs = join(pkgAbs, file)
808
+ if (!existsSync(abs)) continue
809
+ const rel = toRel(abs)
810
+ if (sources.has(rel)) continue
811
+ assertRealPathWithinBase(realBase, baseDir, rel)
812
+ const buf = readFileSync(abs)
813
+ if (!isUtf8(buf)) throw new Error(`native source is not valid UTF-8: ${rel}`)
814
+ sources.set(rel, buf.toString('utf8'))
815
+ formatsByRel.set(rel, packageType(abs) === 'module' ? 'module' : 'commonjs')
816
+ integrities.set(rel, sha512integrity(buf))
817
+ }
818
+ }
819
+ }
820
+ }
821
+
822
+ // --package-json: fold each bundled module's package.json into `sources` (and its integrity into
823
+ // the companion lockfile) even when the scan never reached it. Buckets are the same ones
824
+ // assembleCodeBundle derives (findPackageMetadata -> pkgDir, else the '.' workspace bucket);
825
+ // findPackageMetadata is memoized per directory so a package's many files don't each re-walk to the
826
+ // same manifest. readModuleManifest applies the read/validate rules shared with the State path
827
+ // (containment, UTF-8-aborts); no identity check here -- these buckets are all fresh from disk.
828
+ if (packageJSON) {
829
+ const metaByDir = new Map()
830
+ const pkgDirs = new Set()
831
+ for (const abs of reached) {
832
+ const rel = toRel(abs)
833
+ const dir = dirname(rel)
834
+ if (!metaByDir.has(dir)) metaByDir.set(dir, findPackageMetadata(baseDir, rel))
835
+ const meta = metaByDir.get(dir)
836
+ if (meta) pkgDirs.add(meta.pkgDir)
837
+ else if (!splitNodeModulesPath(rel)) pkgDirs.add('.')
838
+ }
839
+ for (const pkgDir of pkgDirs) {
840
+ const rel = moduleFileKey(pkgDir, 'package.json')
841
+ if (sources.has(rel)) continue
842
+ const buf = readModuleManifest({ baseDir, realBase, rel })
843
+ if (!buf) continue
844
+ sources.set(rel, buf.toString('utf8'))
845
+ formatsByRel.set(rel, 'json')
846
+ integrities.set(rel, sha512integrity(buf))
847
+ }
848
+ }
849
+
850
+ // Collapse each edge: one distinct target across platforms -> flat string; else a sorted Map<platform, target>.
830
851
  const resolutions = new Map()
831
852
  for (const [parent, bySpec] of edges) {
832
853
  const specMap = new Map()
@@ -853,9 +874,7 @@ async function buildResolvedJsBundle({ cwd = process.cwd(), entries, mainFields,
853
874
  conditionKey: '*',
854
875
  })
855
876
 
856
- // The companion lockfile mirrors the bundle exactly, swapping file content for
857
- // its integrity. modules/imports/formats/entries are shared shapes, so a frozen
858
- // lockfile attests the same resolution graph the bundle carries.
877
+ // The companion lockfile mirrors the bundle, swapping file content for its integrity.
859
878
  const lockModules = new Map()
860
879
  for (const [dir, m] of bundle.modules) {
861
880
  const files = Object.create(null)
@@ -868,15 +887,15 @@ async function buildResolvedJsBundle({ cwd = process.cwd(), entries, mainFields,
868
887
  modules: lockModules,
869
888
  imports: bundle.imports,
870
889
  formats: bundle.formats,
890
+ // Same file set as the bundle, so the same executable list attests it.
891
+ executable: bundle.executable,
871
892
  })
872
893
 
873
894
  return { bundle, lockfile }
874
895
  }
875
896
 
876
- // Classify entries into the single bundle language they all share and check
877
- // option applicability. Shared by buildBundle and bundleCommand; `name`
878
- // prefixes error messages with the caller.
879
- function classifyEntries(name, { entries, mappingFile, scope, lockfile, conditions, mainFields, platforms, metro }) {
897
+ // Classify entries into their single shared language and check option applicability; `name` prefixes errors.
898
+ function classifyEntries(name, { entries, mappingFile, scope, lockfile, conditions, mainFields, platforms, metro, metroResolver, jsx, flow, resources, packageJSON }) {
880
899
  if (!Array.isArray(entries) || entries.length === 0) {
881
900
  throw new Error(`${name}: at least one entry file is required`)
882
901
  }
@@ -898,22 +917,39 @@ function classifyEntries(name, { entries, mappingFile, scope, lockfile, conditio
898
917
  if (lockfile !== undefined && kind !== 'js') {
899
918
  throw new Error(`${name}: --lockfile is only valid for JS bundles`)
900
919
  }
901
- // Conditions only steer Node's `exports`/`imports` resolution, which the other
902
- // languages' resolvers don't consult -- reject them rather than accept a flag
903
- // that would silently do nothing.
920
+ // Reject --conditions for non-JS: their resolvers don't consult exports/imports conditions, so it would silently do nothing.
904
921
  if (Array.isArray(conditions) && conditions.length > 0 && kind !== 'js') {
905
922
  throw new Error(`${name}: --conditions is only valid for JS bundles`)
906
923
  }
924
+ // --flow strips Flow type syntax before the JS parser; the non-JS loaders never parse with oxc.
925
+ if (flow && kind !== 'js') {
926
+ throw new Error(`${name}: --flow is only valid for JS bundles`)
927
+ }
928
+ // --resources carries allowlisted assets reached through the JS import graph; JS-only. The list
929
+ // is validated (parseResourcesOption) in the builders, but reject a code extension here too so a
930
+ // typo surfaces with the command name rather than deep in the scan.
931
+ if (Array.isArray(resources) && resources.length > 0) {
932
+ if (kind !== 'js') throw new Error(`${name}: --resources is only valid for JS bundles`)
933
+ parseResourcesOption(name, resources)
934
+ }
907
935
  // --mainFields and --metro/--platforms are legacy-/platform-resolution knobs; JS-only.
908
936
  if (mainFields !== undefined && kind !== 'js') {
909
937
  throw new Error(`${name}: --mainFields is only valid for JS bundles`)
910
938
  }
939
+ // --jsx toggles the scanner's JSX parsing for the .js family; only JS entries are scanned.
940
+ if (jsx && kind !== 'js') {
941
+ throw new Error(`${name}: --jsx is only valid for JS bundles`)
942
+ }
943
+ // --package-json folds each bundled module's npm package.json in; JS-only (the sol/php/bash/rust
944
+ // bucketizers group by their own ecosystems' manifests, not an npm package.json).
945
+ if (packageJSON && kind !== 'js') {
946
+ throw new Error(`${name}: --package-json is only valid for JS bundles`)
947
+ }
911
948
  if ((metro || (Array.isArray(platforms) && platforms.length > 0)) && kind !== 'js') {
912
949
  throw new Error(`${name}: --metro is only valid for JS bundles`)
913
950
  }
914
- // `--metro` is a preset for conditions + mainFields + platform suffixes, so it can't
915
- // be combined with explicit --conditions/--mainFields (they'd conflict), and it needs
916
- // --platforms to know which suffixes to resolve. `--platforms` is meaningless without it.
951
+ // --metro presets conditions + mainFields + platform suffixes, so it can't combine with
952
+ // explicit --conditions/--mainFields and requires --platforms; --platforms is meaningless without it.
917
953
  if (metro) {
918
954
  if (Array.isArray(conditions) && conditions.length > 0) {
919
955
  throw new Error(`${name}: --conditions can't be combined with --metro (it sets its own conditions)`)
@@ -924,9 +960,8 @@ function classifyEntries(name, { entries, mappingFile, scope, lockfile, conditio
924
960
  if (!Array.isArray(platforms) || platforms.length === 0) {
925
961
  throw new Error(`${name}: --metro requires --platforms (e.g. --platforms=ios,android)`)
926
962
  }
927
- // A platform name becomes a bundle/lockfile edge key (Bundle/Lockfile parse reject
928
- // a '/'); '*' is the reserved private placeholder. Reject both so the writer can
929
- // never emit an edge map the readers refuse -- a divergent-edge, data-dependent break.
963
+ // A platform name becomes an edge key: reject '/' (Bundle/Lockfile parse refuse it) and
964
+ // the reserved '*' placeholder, so the writer never emits a map the readers reject.
930
965
  for (const p of platforms) {
931
966
  if (typeof p !== 'string' || p.length === 0 || p === '*' || p.includes('/')) {
932
967
  throw new Error(`${name}: invalid platform '${p}' (a platform name can't be empty, contain '/', or be '*')`)
@@ -935,36 +970,22 @@ function classifyEntries(name, { entries, mappingFile, scope, lockfile, conditio
935
970
  } else if (Array.isArray(platforms) && platforms.length > 0) {
936
971
  throw new Error(`${name}: --platforms is only valid with --metro`)
937
972
  }
938
- // The field resolver always emits a full-scope bundle (no node_modules-only mode), so
939
- // --scope alongside --mainFields/--metro would be silently ignored -- reject it.
973
+ // --metro-resolver swaps in the project's real metro-resolver; it only means something under --metro.
974
+ if (metroResolver && !metro) {
975
+ throw new Error(`${name}: --metro-resolver is only valid with --metro`)
976
+ }
977
+ // The field resolver always emits full-scope, so --scope with --mainFields/--metro would be silently ignored -- reject it.
940
978
  if (scope !== undefined && (mainFields !== undefined || metro)) {
941
979
  throw new Error(`${name}: --scope is not supported with --mainFields or --metro`)
942
980
  }
943
981
  return kind
944
982
  }
945
983
 
946
- // Programmatic equivalent of `stasis bundle`: build and return an in-memory
947
- // Bundle from a list of entry files, without writing anything to disk
948
- // (serialize with bundle.serialize() to get the JSON that `stasis bundle`
949
- // brotli-compresses into stasis.code.br).
950
- //
951
- // Dispatch by extension matches the CLI: all entries must be one language —
952
- // .sol (Solidity), .php (PHP), .js/.cjs/.mjs/.ts/.cts/.mts (JS/TS, via static
953
- // scan), .sh/.bash (Bash), or .rs (Rust); mixing fails fast. `mappingFile` is
954
- // the optional Solidity remappings file (foundry.toml or remappings.txt) and
955
- // is only valid for .sol entries; `scope` overrides the configured bundle
956
- // scope and is only valid for JS/TS entries; `conditions` are extra `exports`
957
- // resolution conditions (e.g. `['react-native']`) and are JS/TS-only too.
958
- //
959
- // `mainFields` (an array of legacy package fields, e.g. `['react-native','browser','main']`)
960
- // selects the legacy-field resolver -- see buildResolvedJsBundle. It resolves
961
- // non-`exports` packages via those fields, including the browser-field object
962
- // redirection, and composes with `conditions`. `metro: true` is instead a preset that
963
- // sets its OWN React Native fields + conditions + platform suffixes (so it is not
964
- // combined with `mainFields`/`conditions`) and requires `platforms` (e.g.
965
- // `['ios','android']`); the edge graph is then per-platform. JS/TS-only.
966
- export async function buildBundle({ cwd = process.cwd(), entries, mappingFile, scope, conditions, mainFields, platforms, metro } = {}) {
967
- const kind = classifyEntries('buildBundle', { entries, mappingFile, scope, conditions, mainFields, platforms, metro })
984
+ // Programmatic equivalent of `stasis bundle`: build and return an in-memory Bundle without
985
+ // writing to disk. Files are attributed to the `bundle` consumer. Option applicability
986
+ // (--mapping/.sol, --scope|--conditions|--mainFields|--metro|--jsx|--flow/JS) is enforced by classifyEntries.
987
+ export async function buildBundle({ cwd = process.cwd(), entries, mappingFile, scope, conditions, mainFields, platforms, metro, metroResolver, jsx = false, flow = false, resources = [], packageJSON = false } = {}) {
988
+ const kind = classifyEntries('buildBundle', { entries, mappingFile, scope, conditions, mainFields, platforms, metro, metroResolver, jsx, flow, resources, packageJSON })
968
989
  if (kind === 'sol') return buildSolidityBundle({ cwd, entries, mappingFile })
969
990
  if (kind === 'php') return buildPhpBundle({ cwd, entries })
970
991
  if (kind === 'bash') return buildBashBundle({ cwd, entries })
@@ -977,59 +998,43 @@ export async function buildBundle({ cwd = process.cwd(), entries, mappingFile, s
977
998
  platforms: metro ? platforms : [null],
978
999
  conditions,
979
1000
  metro: Boolean(metro),
1001
+ metroResolver: Boolean(metroResolver),
1002
+ jsx,
1003
+ flow,
1004
+ resources,
1005
+ packageJSON,
980
1006
  })
981
1007
  return bundle
982
1008
  }
983
- const state = await buildJsBundle({ cwd, entries, scope, conditions })
984
- return state.sourceBundle
1009
+ const state = await buildJsBundle({ cwd, entries, scope, conditions, jsx, flow, resources, packageJSON })
1010
+ // Stamp the `bundle` consumer (the static build carries none).
1011
+ return state.sourceBundle.withReason('bundle')
985
1012
  }
986
1013
 
987
- // Where `stasis bundle` writes when no --output is given: stasis.code.br, the
988
- // same name `stasis run --bundle=load` discovers by default, so the two commands
989
- // round-trip with no flags. A file (not stdout) is the common case; pass
990
- // --output=- to stream the raw brotli bytes to stdout.
1014
+ // Default output: stasis.code.br, the same name `stasis run --bundle=load` discovers, so the two round-trip with no flags.
991
1015
  const DEFAULT_BUNDLE_FILE = 'stasis.code.br'
992
1016
 
993
- // Run the bundle CLI command end-to-end. Always produces a brotli-compressed
994
- // stasis bundle (matching the on-disk format of `stasis.code.br`). Writes to
995
- // `output` when given, to stasis.code.br in `cwd` by default, or to stdout
996
- // when `output` is `-`. Prints a one-line `[stasis] Bundled <n> files in <p>
997
- // packages from <dir> to <dest>` summary to stderr so it doesn't interleave
998
- // with binary output written to stdout.
999
- //
1000
- // Dispatch by extension: all entries must be one language — .sol (Solidity),
1001
- // .php (PHP), .js/.cjs/.mjs/.ts/.cts/.mts (JS/TS, via static scan), .sh/.bash
1002
- // (Bash, via source/exec graph walk), or .rs (Rust, via mod-declaration walk).
1003
- // Mixing fails fast; --mapping is .sol only, --scope/--lockfile/--conditions are
1004
- // JS/TS only.
1005
- //
1006
- // `conditions` are extra Node `exports`/`imports` resolution conditions to assert
1007
- // during the static scan (e.g. `['react-native']` or `['browser']`). They only
1008
- // affect `exports`/`imports` condition matching -- not legacy mainFields -- so on
1009
- // their own they don't honour a package's legacy entry fields. See buildJsBundle.
1010
- //
1011
- // For JS bundles, an optional `lockfile` path writes a stasis.lock.json that
1012
- // attests every file in the bundle. The static scan walks both branches of
1013
- // conditional requires (e.g. debug/src/index.js' `if (browser) require('./browser')
1014
- // else require('./node')`), so the runtime lockfile from `stasis run --lock=add`
1015
- // won't attest the unused branch -- if the user wants `lock=frozen` against a
1016
- // statically-built bundle, they need this companion lockfile.
1017
- //
1018
- // With `--conditions`, that companion lockfile attests the conditions-SELECTED
1019
- // resolution graph. A plain `stasis run --lock=frozen` doesn't replay `--conditions`,
1020
- // so it resolves different files on disk and fails closed (correctly -- the lockfile
1021
- // genuinely attests a different graph). Pair such a lockfile with `--bundle=load`, or
1022
- // replay the same `--conditions` at run time, so the attested and observed graphs agree.
1023
- export async function bundleCommand({ cwd = process.cwd(), entries, mappingFile, output, scope, lockfile, conditions, mainFields, platforms, metro } = {}) {
1024
- const kind = classifyEntries('bundleCommand', { entries, mappingFile, scope, lockfile, conditions, mainFields, platforms, metro })
1017
+ // Run `stasis bundle`: build a brotli-compressed bundle and write it to `output`
1018
+ // (stasis.code.br by default, `-` for stdout; the summary goes to stderr so it never
1019
+ // interleaves with binary stdout). An optional JS `lockfile` attests every bundled file;
1020
+ // with `--conditions` it attests the conditions-selected graph, so a plain
1021
+ // `stasis run --lock=frozen` (which doesn't replay them) fails closed -- pair it with
1022
+ // `--bundle=load` or replay the conditions. `add` unions the fresh build into the bundle
1023
+ // already on disk (strict; a conflicting file throws) and can't target stdout.
1024
+ export async function bundleCommand({ cwd = process.cwd(), entries, mappingFile, output, scope, lockfile, conditions, mainFields, platforms, metro, metroResolver, jsx = false, flow = false, resources = [], packageJSON = false, brotliQuality, add = false } = {}) {
1025
+ const kind = classifyEntries('bundleCommand', { entries, mappingFile, scope, lockfile, conditions, mainFields, platforms, metro, metroResolver, jsx, flow, resources, packageJSON })
1026
+
1027
+ const target = output ?? DEFAULT_BUNDLE_FILE
1028
+ // --add has nothing to merge into on stdout (write-only).
1029
+ if (add && target === '-') {
1030
+ throw new Error('bundleCommand: --add cannot be combined with --output=- (nothing to merge into on stdout)')
1031
+ }
1025
1032
 
1026
1033
  let bundle
1027
1034
  let lockData
1028
1035
  if (kind === 'js' && (metro || mainFields !== undefined)) {
1029
- // The legacy-field resolver path builds the Bundle (and the matching Lockfile)
1030
- // directly -- it carries per-platform edges and a synthetic empty module (for a
1031
- // browser/react-native `false` redirect) that State's disk-reading addFile can't
1032
- // represent.
1036
+ // The legacy-field resolver builds Bundle + Lockfile directly (per-platform edges + a
1037
+ // synthetic empty module State's addFile can't represent).
1033
1038
  const built = await buildResolvedJsBundle({
1034
1039
  cwd,
1035
1040
  entries,
@@ -1037,30 +1042,63 @@ export async function bundleCommand({ cwd = process.cwd(), entries, mappingFile,
1037
1042
  platforms: metro ? platforms : [null],
1038
1043
  conditions,
1039
1044
  metro: Boolean(metro),
1045
+ metroResolver: Boolean(metroResolver),
1046
+ jsx,
1047
+ flow,
1048
+ resources,
1049
+ packageJSON,
1040
1050
  })
1041
1051
  bundle = built.bundle
1042
1052
  if (lockfile) lockData = built.lockfile.serialize()
1043
1053
  } else if (kind === 'js' && lockfile) {
1044
- // The companion lockfile attests file hashes, which only State carries —
1045
- // a Bundle holds sources, not digests — so this path keeps the State.
1046
- const state = await buildJsBundle({ cwd, entries, scope, conditions })
1047
- bundle = state.sourceBundle
1054
+ // Keep the State: only it carries the file hashes the companion lockfile needs (a Bundle holds sources, not digests).
1055
+ const state = await buildJsBundle({ cwd, entries, scope, conditions, jsx, flow, resources, packageJSON })
1056
+ // Stamp the `bundle` consumer (this branch bypasses buildBundle to keep the State).
1057
+ bundle = state.sourceBundle.withReason('bundle')
1048
1058
  lockData = state.lockData
1049
1059
  } else {
1050
- bundle = await buildBundle({ cwd, entries, mappingFile, scope, conditions })
1060
+ bundle = await buildBundle({ cwd, entries, mappingFile, scope, conditions, jsx, flow, resources, packageJSON })
1051
1061
  }
1062
+
1063
+ // --add: union the fresh build into the existing on-disk bundle; a conflicting file throws. Skipped when nothing is on disk.
1064
+ const outAbs = target === '-' ? undefined : resolve(cwd, target)
1065
+ // Files the pre-existing bundle carried; undefined when no merge happened (the summary's merged sentinel).
1066
+ let mergedFrom
1067
+ if (add && existsSync(outAbs)) {
1068
+ let existing
1069
+ try {
1070
+ existing = Bundle.parse(brotliDecompressSync(readFileSync(outAbs)).toString('utf8'))
1071
+ } catch (cause) {
1072
+ throw new Error(`bundleCommand: --add failed to read the existing bundle at ${target}`, { cause })
1073
+ }
1074
+ mergedFrom = existing.sources.size
1075
+ bundle = existing.merge(bundle)
1076
+ // Keep the companion lockfile consistent with the merged bundle: union an existing one.
1077
+ // With none on disk we can't attest the pre-existing bundle's files (their hashes live
1078
+ // only in that missing lockfile), so refuse rather than write an under-attesting lockfile.
1079
+ if (lockData && lockfile) {
1080
+ const lockAbs = resolve(cwd, lockfile)
1081
+ if (!existsSync(lockAbs)) {
1082
+ throw new Error(`bundleCommand: --add can't write a complete lockfile at ${lockfile}: the bundle at ${target} already carries files a fresh lockfile wouldn't attest, and there's no existing lockfile to merge into. Write --lockfile alongside the bundle from the first build, or drop --lockfile.`)
1083
+ }
1084
+ let existingLock
1085
+ try {
1086
+ existingLock = Lockfile.parse(readFileSync(lockAbs, 'utf8'))
1087
+ } catch (cause) {
1088
+ throw new Error(`bundleCommand: --add failed to read the existing lockfile at ${lockfile}`, { cause })
1089
+ }
1090
+ lockData = existingLock.merge(Lockfile.parse(lockData)).serialize()
1091
+ }
1092
+ }
1093
+
1052
1094
  const serialized = bundle.serialize()
1053
1095
  const files = [...bundle.sources.keys()]
1054
1096
  const modules = bundle.modules
1055
1097
 
1056
- const data = brotliCompressSync(serialized, brotliOptions())
1057
- // Default to writing stasis.code.br in cwd; `-` is the conventional opt-in
1058
- // for streaming the raw brotli bytes to stdout (e.g. to pipe somewhere else).
1059
- const target = output ?? DEFAULT_BUNDLE_FILE
1098
+ const data = brotliCompressSync(serialized, brotliOptions(brotliQuality))
1060
1099
  if (target === '-') {
1061
1100
  process.stdout.write(data)
1062
1101
  } else {
1063
- const outAbs = resolve(cwd, target)
1064
1102
  mkdirSync(dirname(outAbs), { recursive: true })
1065
1103
  writeFileSync(outAbs, data)
1066
1104
  }
@@ -1071,9 +1109,14 @@ export async function bundleCommand({ cwd = process.cwd(), entries, mappingFile,
1071
1109
  }
1072
1110
  const fromDir = outermostDir(files, resolve(cwd))
1073
1111
  const dest = target === '-' ? '<stdout>' : target
1074
- // Count packages: every non-empty bucket, with the project's own source (the
1075
- // "." workspace bucket) counting as one package alongside each dependency.
1112
+ // Count packages: every non-empty bucket (the "." workspace bucket counts as one).
1076
1113
  const packages = [...modules.values()].filter((m) => Object.keys(m.files).length > 0).length
1077
1114
  const pkgLabel = `${packages} package${packages === 1 ? '' : 's'}`
1078
- console.warn(`[stasis] Bundled ${files.length} files in ${pkgLabel} from ${fromDir} to ${dest}`)
1115
+ // On a merge, report newly-added files alongside the totals; a fresh write keeps the plain line.
1116
+ if (mergedFrom !== undefined) {
1117
+ const added = files.length - mergedFrom
1118
+ console.warn(`[stasis] Added ${added} file${added === 1 ? '' : 's'} (${files.length} total in ${pkgLabel}) from ${fromDir} to ${dest}`)
1119
+ } else {
1120
+ console.warn(`[stasis] Bundled ${files.length} files in ${pkgLabel} from ${fromDir} to ${dest}`)
1121
+ }
1079
1122
  }