@exodus/stasis 1.0.0-alpha.3 → 1.0.0-beta.1

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,22 +1,54 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
- import { dirname, isAbsolute, join, posix, relative, resolve } from 'node:path'
1
+ import { isUtf8 } from 'node:buffer'
2
+ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
3
+ import { dirname, extname, isAbsolute, join, posix, relative, resolve } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
3
5
  import { brotliCompressSync } from 'node:zlib'
4
6
 
5
- import { Bundle } from '../bundle.js'
6
- import { splitNodeModulesPath } from '../util.js'
7
+ import { Bundle } from '@exodus/stasis-core/bundle'
8
+ import { Lockfile } from '@exodus/stasis-core/lockfile'
9
+ import { scan } from '../scan.js'
10
+ import { createFieldResolver, resolveConditions } from '../resolve-fields.js'
11
+ import { State } from '@exodus/stasis-core/state'
12
+ import { brotliOptions } from '@exodus/stasis-core/brotli'
13
+ import { sha512integrity } from '@exodus/stasis-core/state-util'
14
+ import { assertRealPathWithinBase, moduleFileKey, splitNodeModulesPath } from '@exodus/stasis-core/util'
7
15
  import {
8
16
  buildSolidityTree,
9
17
  collectSolidityFilesFromDisk,
10
18
  readRemappingsFile,
11
19
  } from '../loaders/solidity.js'
20
+ import { buildBashTree, collectBashFilesFromDisk } from '../loaders/bash.js'
21
+ import { buildRustTree, collectRustFilesFromDisk } from '../loaders/rust.js'
22
+ import {
23
+ bucketizePhpSources,
24
+ buildPhpTree,
25
+ collectPhpFilesFromDisk,
26
+ loadComposerAutoload,
27
+ loadLaravelProviderFiles,
28
+ } from '../loaders/php.js'
29
+
30
+ const JS_EXTS = new Set(['.js', '.cjs', '.mjs', '.ts', '.cts', '.mts'])
31
+ const BASH_EXTS = new Set(['.sh', '.bash'])
32
+ const RUST_EXTS = new Set(['.rs'])
12
33
 
13
34
  // Fallback package identity for the workspace bucket when no package.json
14
35
  // with a name+version can be found by walking up from any of the bundled
15
- // files. Bundle.parseCode requires every workspace/module bucket to have
36
+ // files. Bundle.parse requires every workspace/module bucket to have
16
37
  // both fields, so we have to attest something.
17
38
  const SOLIDITY_WORKSPACE_NAME = 'solidity-bundle'
18
39
  const SOLIDITY_WORKSPACE_VERSION = '0.0.0'
19
40
  const SOLIDITY_FORMAT = 'solidity'
41
+ const BASH_WORKSPACE_NAME = 'bash-bundle'
42
+ const BASH_WORKSPACE_VERSION = '0.0.0'
43
+ const BASH_FORMAT = 'bash'
44
+
45
+ const RUST_WORKSPACE_NAME = 'rust-bundle'
46
+ const RUST_WORKSPACE_VERSION = '0.0.0'
47
+ const RUST_FORMAT = 'rust'
48
+
49
+ const PHP_WORKSPACE_NAME = 'php-bundle'
50
+ const PHP_WORKSPACE_VERSION = '0.0.0'
51
+ const PHP_FORMAT = 'php'
20
52
 
21
53
  // Walk up from the file's directory looking for the nearest package.json
22
54
  // with both `name` and `version`. Returns { pkgDir, name, version }
@@ -75,6 +107,221 @@ export function outermostDir(paths, cwd) {
75
107
  return rel === '' ? '.' : rel
76
108
  }
77
109
 
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).
124
+ function parseSoldeerDir(seg) {
125
+ const m = /^(.+)-(\d+\.\d+\.\d+(?:[-+][\w.-]+)?)$/u.exec(seg)
126
+ return m ? { name: m[1], version: m[2] } : { name: seg, version: '0.0.0' }
127
+ }
128
+
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.
131
+ function githubSlug(url) {
132
+ const m = /github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?\/?$/iu.exec(url)
133
+ return m ? `${m[1]}/${m[2]}` : null
134
+ }
135
+
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`.
139
+ function parseGithubSubmodules(baseDir) {
140
+ const byPath = new Map()
141
+ const text = readFileSyncOrNull(join(baseDir, '.gitmodules'))
142
+ if (!text) return byPath
143
+ let cur = null
144
+ const flush = () => {
145
+ if (cur?.path && cur?.url) {
146
+ const name = githubSlug(cur.url)
147
+ if (name) byPath.set(cur.path.replace(/\/+$/u, ''), { name, branch: cur.branch })
148
+ }
149
+ cur = null
150
+ }
151
+ for (const raw of text.split('\n')) {
152
+ const line = raw.trim()
153
+ if (line.startsWith('[')) {
154
+ flush()
155
+ cur = line.startsWith('[submodule') ? {} : null
156
+ continue
157
+ }
158
+ if (!cur) continue
159
+ const eq = line.indexOf('=')
160
+ if (eq === -1) continue
161
+ const key = line.slice(0, eq).trim()
162
+ if (key === 'path' || key === 'url' || key === 'branch') cur[key] = line.slice(eq + 1).trim()
163
+ }
164
+ flush()
165
+ return byPath
166
+ }
167
+
168
+ function readFileSyncOrNull(file) {
169
+ try {
170
+ return readFileSync(file, 'utf8')
171
+ } catch {
172
+ return null
173
+ }
174
+ }
175
+
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.
183
+ function makeSolidityClassifier(baseDir) {
184
+ const submodules = parseGithubSubmodules(baseDir)
185
+ return (path) => {
186
+ if (path.startsWith('dependencies/')) {
187
+ const seg = path.slice('dependencies/'.length).split('/')[0]
188
+ if (seg) {
189
+ const { name, version } = parseSoldeerDir(seg)
190
+ return { bucketDir: `dependencies/${seg}`, name, version, ecosystem: 'soldeer' }
191
+ }
192
+ }
193
+ for (const [sub, { name, branch }] of submodules) {
194
+ if (path === sub || path.startsWith(`${sub}/`)) {
195
+ const pkg = readJson(join(baseDir, sub, 'package.json'))
196
+ return { bucketDir: sub, name, version: pkg?.version ?? branch ?? '0.0.0', ecosystem: 'github' }
197
+ }
198
+ }
199
+ return null
200
+ }
201
+ }
202
+
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.
206
+ function parseCargoToml(file) {
207
+ const text = readFileSyncOrNull(file)
208
+ if (!text) return null
209
+ const at = text.search(/^\[package\]\s*$/mu)
210
+ let body = text
211
+ if (at !== -1) {
212
+ const rest = text.slice(at)
213
+ const next = rest.slice(1).search(/^\[/mu) // start of the table after [package]
214
+ body = next === -1 ? rest : rest.slice(0, next + 1)
215
+ }
216
+ const name = /^\s*name\s*=\s*"([^"]+)"/mu.exec(body)?.[1]
217
+ const version = /^\s*version\s*=\s*"([^"]+)"/mu.exec(body)?.[1]
218
+ return name ? { name, version: version ?? '0.0.0' } : null
219
+ }
220
+
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.
225
+ function makeRustClassifier(baseDir) {
226
+ return (path) => {
227
+ if (!path.startsWith('vendor/')) return null
228
+ const dir = path.slice('vendor/'.length).split('/')[0]
229
+ if (!dir) return null
230
+ const bucketDir = `vendor/${dir}`
231
+ const pkg = parseCargoToml(join(baseDir, bucketDir, 'Cargo.toml'))
232
+ return pkg ? { bucketDir, name: pkg.name, version: pkg.version, ecosystem: 'cargo' } : null
233
+ }
234
+ }
235
+
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.
274
+ function assembleCodeBundle({
275
+ baseDir, entries, sources, resolutions, workspaceName, workspaceVersion, format, formats, conditionKey, classifyDep,
276
+ }) {
277
+ const modules = new Map()
278
+ const ensureBucket = (dir, name, version, bucketEcosystem) => {
279
+ if (!modules.has(dir)) {
280
+ modules.set(dir, bucketEcosystem === undefined
281
+ ? { name, version, files: Object.create(null) }
282
+ : { name, version, ecosystem: bucketEcosystem, files: Object.create(null) })
283
+ }
284
+ return modules.get(dir)
285
+ }
286
+
287
+ for (const [path, content] of sources) {
288
+ const dep = classifyDep?.(path)
289
+ if (dep) {
290
+ ensureBucket(dep.bucketDir, dep.name, dep.version, dep.ecosystem)
291
+ .files[path.slice(dep.bucketDir.length + 1)] = content
292
+ continue
293
+ }
294
+ const meta = findPackageMetadata(baseDir, path)
295
+ const inNodeModules = splitNodeModulesPath(path) !== null
296
+ if (meta) {
297
+ if (inNodeModules && !meta.pkgDir.includes('node_modules')) {
298
+ throw new Error(`No package.json with name+version found for ${path}`)
299
+ }
300
+ const rel = meta.pkgDir === '.' ? path : path.slice(meta.pkgDir.length + 1)
301
+ const bucketEcosystem = meta.pkgDir.includes('node_modules') ? 'npm' : undefined
302
+ ensureBucket(meta.pkgDir, meta.name, meta.version, bucketEcosystem).files[rel] = content
303
+ } else {
304
+ if (inNodeModules) throw new Error(`No package.json with name+version found for ${path}`)
305
+ ensureBucket('.', workspaceName, workspaceVersion).files[path] = content
306
+ }
307
+ }
308
+
309
+ const formatsMap = new Map()
310
+ for (const path of sources.keys()) formatsMap.set(path, formats?.get(path) ?? format)
311
+
312
+ const importsForKey = new Map()
313
+ for (const [parent, specMap] of resolutions) importsForKey.set(parent, specMap)
314
+ const imports = new Map([[conditionKey, importsForKey]])
315
+
316
+ return new Bundle({
317
+ config: { scope: 'full' },
318
+ entries: new Set(entries),
319
+ modules,
320
+ formats: formatsMap,
321
+ imports,
322
+ })
323
+ }
324
+
78
325
  // Build a stasis Bundle (in-memory) from a list of entry .sol files and
79
326
  // an optional mapping file. The mapping file (foundry.toml or
80
327
  // remappings.txt) is parsed for remappings but NOT included in the
@@ -109,49 +356,182 @@ export async function buildSolidityBundle({ cwd = process.cwd(), entries, mappin
109
356
  throw new Error(`Solidity bundle has unresolved imports:\n${issues.map((s) => ` ${s}`).join('\n')}`)
110
357
  }
111
358
 
112
- // Partition every loaded .sol file into a per-package bucket. The
113
- // bucket is the directory holding the nearest package.json with a
114
- // name+version. node_modules files land in `node_modules/<pkg>` buckets
115
- // (or deeper, when a nested package.json claims the file); workspace
116
- // files land in their nearest workspace package.json's dir, or in the
117
- // fallback "." bucket with placeholder metadata when no package.json
118
- // could be found.
119
- const modules = new Map()
120
- const ensureBucket = (dir, name, version) => {
121
- if (!modules.has(dir)) modules.set(dir, { name, version, files: Object.create(null) })
122
- return modules.get(dir)
359
+ return assembleCodeBundle({
360
+ baseDir,
361
+ entries: normalized,
362
+ sources,
363
+ resolutions,
364
+ workspaceName: SOLIDITY_WORKSPACE_NAME,
365
+ workspaceVersion: SOLIDITY_WORKSPACE_VERSION,
366
+ format: SOLIDITY_FORMAT,
367
+ conditionKey: 'solidity',
368
+ classifyDep: makeSolidityClassifier(baseDir),
369
+ })
370
+ }
371
+
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 "*".
385
+ export async function buildBashBundle({ cwd = process.cwd(), entries } = {}) {
386
+ if (!Array.isArray(entries) || entries.length === 0) {
387
+ throw new Error('buildBashBundle: at least one entry .sh/.bash file is required')
388
+ }
389
+ for (const e of entries) {
390
+ if (!BASH_EXTS.has(extname(e))) throw new Error(`buildBashBundle: not a .sh/.bash file: ${e}`)
123
391
  }
124
392
 
125
- for (const [path, content] of sources) {
126
- const meta = findPackageMetadata(baseDir, path)
127
- const inNodeModules = splitNodeModulesPath(path) !== null
128
- if (meta) {
129
- // A file inside node_modules whose nearest package.json is the
130
- // workspace root would silently land in the workspace bucket and
131
- // hide the misconfigured dependency — refuse instead.
132
- if (inNodeModules && !meta.pkgDir.includes('node_modules')) {
133
- throw new Error(`No package.json with name+version found for ${path}`)
134
- }
135
- const rel = meta.pkgDir === '.' ? path : path.slice(meta.pkgDir.length + 1)
136
- ensureBucket(meta.pkgDir, meta.name, meta.version).files[rel] = content
137
- } else {
138
- // Files under node_modules without any discoverable package.json
139
- // would otherwise leak into the workspace bucket; that's a project
140
- // misconfiguration, surface it loudly.
141
- if (inNodeModules) throw new Error(`No package.json with name+version found for ${path}`)
142
- ensureBucket('.', SOLIDITY_WORKSPACE_NAME, SOLIDITY_WORKSPACE_VERSION).files[path] = content
143
- }
393
+ const baseDir = resolve(cwd)
394
+ const normalized = normalizeEntries(entries, cwd)
395
+
396
+ const sources = await collectBashFilesFromDisk(baseDir, normalized)
397
+ const { resolutions, missing } = buildBashTree(sources)
398
+
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`.
404
+ const issues = []
405
+ for (const entry of normalized) {
406
+ if (!sources.has(entry)) issues.push(`Missing entry: ${entry}`)
407
+ }
408
+ for (const { spec, from } of missing) {
409
+ issues.push(`Unresolved script: ${spec} from ${from}`)
410
+ }
411
+ if (issues.length > 0) {
412
+ throw new Error(`Bash bundle has unresolved scripts:\n${issues.map((s) => ` ${s}`).join('\n')}`)
413
+ }
414
+
415
+ return assembleCodeBundle({
416
+ baseDir,
417
+ entries: normalized,
418
+ sources,
419
+ resolutions,
420
+ workspaceName: BASH_WORKSPACE_NAME,
421
+ workspaceVersion: BASH_WORKSPACE_VERSION,
422
+ format: BASH_FORMAT,
423
+ conditionKey: 'bash',
424
+ })
425
+ }
426
+
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.
435
+ export async function buildRustBundle({ cwd = process.cwd(), entries } = {}) {
436
+ if (!Array.isArray(entries) || entries.length === 0) {
437
+ throw new Error('buildRustBundle: at least one entry .rs file is required')
438
+ }
439
+ for (const e of entries) {
440
+ if (!RUST_EXTS.has(extname(e))) throw new Error(`buildRustBundle: not a .rs file: ${e}`)
441
+ }
442
+
443
+ const baseDir = resolve(cwd)
444
+ const normalized = normalizeEntries(entries, cwd)
445
+
446
+ const sources = await collectRustFilesFromDisk(baseDir, normalized)
447
+ const { resolutions, missing } = buildRustTree(sources)
448
+
449
+ const issues = []
450
+ for (const entry of normalized) {
451
+ if (!sources.has(entry)) issues.push(`Missing entry: ${entry}`)
452
+ }
453
+ for (const { spec, from } of missing) {
454
+ issues.push(`Unresolved module: ${spec} from ${from}`)
455
+ }
456
+ if (issues.length > 0) {
457
+ throw new Error(`Rust bundle has unresolved modules:\n${issues.map((s) => ` ${s}`).join('\n')}`)
458
+ }
459
+
460
+ return assembleCodeBundle({
461
+ baseDir,
462
+ entries: normalized,
463
+ sources,
464
+ resolutions,
465
+ workspaceName: RUST_WORKSPACE_NAME,
466
+ workspaceVersion: RUST_WORKSPACE_VERSION,
467
+ format: RUST_FORMAT,
468
+ conditionKey: 'rust',
469
+ classifyDep: makeRustClassifier(baseDir),
470
+ })
471
+ }
472
+
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.
492
+ export async function buildPhpBundle({ cwd = process.cwd(), entries } = {}) {
493
+ if (!Array.isArray(entries) || entries.length === 0) {
494
+ throw new Error('buildPhpBundle: at least one entry .php file is required')
495
+ }
496
+ for (const e of entries) {
497
+ if (!e.endsWith('.php')) throw new Error(`buildPhpBundle: not a .php file: ${e}`)
144
498
  }
145
499
 
500
+ const baseDir = resolve(cwd)
501
+ const normalized = normalizeEntries(entries, cwd)
502
+ const autoload = loadComposerAutoload(baseDir)
503
+
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.
507
+ const providerRoots = loadLaravelProviderFiles(baseDir, autoload)
508
+
509
+ const sources = await collectPhpFilesFromDisk(baseDir, [...normalized, ...providerRoots], { autoload })
510
+ const { resolutions, missing } = buildPhpTree(sources, { baseDir, autoload })
511
+
512
+ const issues = []
513
+ for (const entry of normalized) {
514
+ if (!sources.has(entry)) issues.push(`Missing entry: ${entry}`)
515
+ }
516
+ for (const { spec, from } of missing) {
517
+ issues.push(`Unresolved import: ${spec} from ${from}`)
518
+ }
519
+ if (issues.length > 0) {
520
+ throw new Error(`PHP bundle has unresolved imports:\n${issues.map((s) => ` ${s}`).join('\n')}`)
521
+ }
522
+
523
+ // Group per Composer package (vendor/<pkg> buckets with name+version from
524
+ // composer.json / installed.json), not the node_modules-based bucketizer.
525
+ const modules = bucketizePhpSources(baseDir, sources, PHP_WORKSPACE_NAME, PHP_WORKSPACE_VERSION)
526
+
146
527
  const formats = new Map()
147
- for (const path of sources.keys()) formats.set(path, SOLIDITY_FORMAT)
528
+ for (const path of sources.keys()) formats.set(path, PHP_FORMAT)
148
529
 
149
- // Solidity imports don't depend on Node conditions; key them under a
150
- // dedicated "solidity" bucket rather than the wildcard "*" used for
151
- // JS code bundles.
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.
152
532
  const importsForKey = new Map()
153
533
  for (const [parent, specMap] of resolutions) importsForKey.set(parent, specMap)
154
- const imports = new Map([['solidity', importsForKey]])
534
+ const imports = new Map([['php', importsForKey]])
155
535
 
156
536
  return new Bundle({
157
537
  config: { scope: 'full' },
@@ -162,27 +542,538 @@ export async function buildSolidityBundle({ cwd = process.cwd(), entries, mappin
162
542
  })
163
543
  }
164
544
 
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.
555
+ const staticKinds = new Set(['import', 'export-from'])
556
+ const staticReachable = new Set(scanner.entries)
557
+ const walk = [...scanner.entries]
558
+ while (walk.length > 0) {
559
+ for (const e of scanner.files.get(walk.shift())?.edges ?? []) {
560
+ if (staticKinds.has(e.kind) && e.child && !staticReachable.has(e.child)) {
561
+ staticReachable.add(e.child)
562
+ walk.push(e.child)
563
+ }
564
+ }
565
+ }
566
+ const fatalUnresolved = (u) => staticKinds.has(u.kind) && staticReachable.has(u.parentURL)
567
+ const fatal = scanner.unresolved
568
+ .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.
572
+ const fatalParse = (p) => staticReachable.has(p.url) && (p.format?.startsWith('module') === true || !p.recovered)
573
+ for (const p of scanner.parseErrors) {
574
+ if (fatalParse(p)) fatal.push(`parse error in ${fileURLToPath(p.url)}: ${p.message}`)
575
+ }
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.
578
+ for (const [, byParent] of scanner.imports) {
579
+ for (const [parentURL, specMap] of byParent) {
580
+ for (const [spec, childURL] of specMap) {
581
+ if (!scanner.files.has(childURL)) {
582
+ fatal.push(`${spec} from ${fileURLToPath(parentURL)} resolves to ${fileURLToPath(childURL)}, which a source bundle can't carry`)
583
+ }
584
+ }
585
+ }
586
+ }
587
+ const tolerated = scanner.unresolved.filter((u) => !fatalUnresolved(u))
588
+ const toleratedParse = scanner.parseErrors.filter((p) => !fatalParse(p))
589
+ return { fatal, tolerated, toleratedParse }
590
+ }
591
+
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 = '' } = {}) {
595
+ const where = label ? ` (${label})` : ''
596
+ if (fatal.length > 0) {
597
+ const shown = fatal.slice(0, 10).map((s) => ` ${s}`).join('\n')
598
+ const more = fatal.length > 10 ? `\n ... and ${fatal.length - 10} more` : ''
599
+ throw new Error(`JS bundle would be broken at load time${where}:\n${shown}${more}`)
600
+ }
601
+ if (tolerated.length > 0) {
602
+ const summary = tolerated
603
+ .slice(0, 10)
604
+ .map((u) => ` ${u.kind} ${u.spec ?? '<dynamic>'} from ${fileURLToPath(u.parentURL)} (${u.reason})`)
605
+ .join('\n')
606
+ const more = tolerated.length > 10 ? `\n ... and ${tolerated.length - 10} more` : ''
607
+ console.warn(`[stasis] Bundle has ${tolerated.length} unresolved import(s)${where}; they will fall through at load time:\n${summary}${more}`)
608
+ }
609
+ if (toleratedParse.length > 0) {
610
+ const summary = toleratedParse
611
+ .slice(0, 10)
612
+ .map((p) => ` ${fileURLToPath(p.url)}: ${p.message}`)
613
+ .join('\n')
614
+ const more = toleratedParse.length > 10 ? `\n ... and ${toleratedParse.length - 10} more` : ''
615
+ console.warn(`[stasis] Bundle has ${toleratedParse.length} file(s) with parse errors${where}; their recorded imports may be incomplete:\n${summary}${more}`)
616
+ }
617
+ }
618
+
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 = [] } = {}) {
653
+ if (!Array.isArray(entries) || entries.length === 0) {
654
+ throw new Error('buildJsBundle: at least one entry .js/.cjs/.mjs/.ts/.cts/.mts file is required')
655
+ }
656
+ for (const e of entries) {
657
+ if (!JS_EXTS.has(extname(e))) throw new Error(`buildJsBundle: not a JS/TS file: ${e}`)
658
+ }
659
+
660
+ const baseDir = resolve(cwd)
661
+ const absEntries = entries.map((e) => resolve(baseDir, e))
662
+
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.
667
+ const scanConditions = conditions.map((c) => (typeof c === 'string' ? c.trim() : c)).filter(Boolean)
668
+
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`.
694
+ const state = new State(baseDir, { bundle: 'replace', lock: 'ignore', ...(scope ? { scope } : {}) })
695
+ for (const [url, info] of scanner.files) {
696
+ const isEntry = scanner.entries.has(url)
697
+ state.addFile(url, { format: info.format, isEntry })
698
+ }
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) {
706
+ for (const [spec, childURL] of specs) {
707
+ state.addImport(parentURL, spec, childURL, { conditions: '*' })
708
+ }
709
+ }
710
+ }
711
+ return state
712
+ }
713
+
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.
719
+ 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).
723
+ 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.
727
+ const EMPTY_MODULE_PATH = '.stasis/empty-module.js'
728
+
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 }) {
741
+ const baseDir = resolve(cwd)
742
+ const absEntries = entries.map((e) => resolve(baseDir, e))
743
+ const normalized = normalizeEntries(entries, cwd)
744
+
745
+ const toRel = (abs) => {
746
+ const rel = relative(baseDir, abs).split(/[\\/]/u).join('/')
747
+ if (rel.startsWith('..') || isAbsolute(rel)) {
748
+ throw new Error(`Bundle would reach a file outside the project root: ${abs}`)
749
+ }
750
+ return rel
751
+ }
752
+
753
+ // Normalize conditions like buildJsBundle does, so a sloppy programmatic caller
754
+ // (stray '' / whitespace) can't push a bogus token into the resolver.
755
+ const scanConditions = conditions.map((c) => (typeof c === 'string' ? c.trim() : c)).filter(Boolean)
756
+
757
+ const formatsByRel = new Map()
758
+ // parentRel -> specifier -> Map<platformKey, targetRel>. Collapsed into flat /
759
+ // platform-map edges after every platform has been scanned.
760
+ const edges = new Map()
761
+ const reached = new Set() // absolute paths of every real file reached on any platform
762
+ let usesEmpty = false
763
+
764
+ 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.
768
+ 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' })
778
+
779
+ const platformKey = platform ?? '*' // '*' is a private placeholder for the single mainFields pass; it never unflattens
780
+ for (const [url, info] of scanner.files) {
781
+ const rel = toRel(fileURLToPath(url))
782
+ reached.add(fileURLToPath(url))
783
+ formatsByRel.set(rel, info.format)
784
+ for (const e of info.edges) {
785
+ if (e.builtin || e.dynamic) continue
786
+ let target
787
+ if (e.empty) { usesEmpty = true; target = EMPTY_MODULE_PATH }
788
+ else if (e.child) target = toRel(fileURLToPath(e.child))
789
+ else continue // unresolved (already warned/thrown by reportScanIssues)
790
+ if (!edges.has(rel)) edges.set(rel, new Map())
791
+ const bySpec = edges.get(rel)
792
+ if (!bySpec.has(e.spec)) bySpec.set(e.spec, new Map())
793
+ bySpec.get(e.spec).set(platformKey, target)
794
+ }
795
+ }
796
+ }
797
+
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.
802
+ const sources = new Map()
803
+ const integrities = new Map()
804
+ const realBase = realpathSync(baseDir)
805
+ for (const abs of reached) {
806
+ 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.
811
+ assertRealPathWithinBase(realBase, baseDir, rel)
812
+ 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'))
815
+ integrities.set(rel, sha512integrity(buf))
816
+ }
817
+ 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.
820
+ if (formatsByRel.has(EMPTY_MODULE_PATH)) {
821
+ throw new Error(`Bundle needs the reserved empty-module path ${EMPTY_MODULE_PATH}, but the project has a real file there`)
822
+ }
823
+ sources.set(EMPTY_MODULE_PATH, '')
824
+ formatsByRel.set(EMPTY_MODULE_PATH, 'commonjs')
825
+ integrities.set(EMPTY_MODULE_PATH, sha512integrity(Buffer.alloc(0)))
826
+ }
827
+
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).
830
+ const resolutions = new Map()
831
+ for (const [parent, bySpec] of edges) {
832
+ const specMap = new Map()
833
+ for (const [spec, byPlatform] of bySpec) {
834
+ const distinct = new Set(byPlatform.values())
835
+ if (distinct.size === 1) {
836
+ specMap.set(spec, [...distinct][0])
837
+ } else {
838
+ specMap.set(spec, new Map([...byPlatform].toSorted((a, b) => (a[0] < b[0] ? -1 : 1))))
839
+ }
840
+ }
841
+ resolutions.set(parent, specMap)
842
+ }
843
+
844
+ const rootPkg = readJson(join(baseDir, 'package.json')) ?? {}
845
+ const bundle = assembleCodeBundle({
846
+ baseDir,
847
+ entries: normalized,
848
+ sources,
849
+ resolutions,
850
+ formats: formatsByRel,
851
+ workspaceName: rootPkg.name ?? 'workspace',
852
+ workspaceVersion: rootPkg.version ?? '0.0.0',
853
+ conditionKey: '*',
854
+ })
855
+
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.
859
+ const lockModules = new Map()
860
+ for (const [dir, m] of bundle.modules) {
861
+ const files = Object.create(null)
862
+ for (const rel of Object.keys(m.files)) files[rel] = integrities.get(moduleFileKey(dir, rel))
863
+ lockModules.set(dir, { name: m.name, version: m.version, ...(m.ecosystem === undefined ? {} : { ecosystem: m.ecosystem }), files })
864
+ }
865
+ const lockfile = new Lockfile({
866
+ config: bundle.config,
867
+ entries: bundle.entries,
868
+ modules: lockModules,
869
+ imports: bundle.imports,
870
+ formats: bundle.formats,
871
+ })
872
+
873
+ return { bundle, lockfile }
874
+ }
875
+
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 }) {
880
+ if (!Array.isArray(entries) || entries.length === 0) {
881
+ throw new Error(`${name}: at least one entry file is required`)
882
+ }
883
+ let kind
884
+ if (entries.every((e) => e.endsWith('.sol'))) kind = 'sol'
885
+ else if (entries.every((e) => e.endsWith('.php'))) kind = 'php'
886
+ else if (entries.every((e) => JS_EXTS.has(extname(e)))) kind = 'js'
887
+ else if (entries.every((e) => BASH_EXTS.has(extname(e)))) kind = 'bash'
888
+ else if (entries.every((e) => RUST_EXTS.has(extname(e)))) kind = 'rust'
889
+ else {
890
+ throw new Error(`${name}: entries must all be .sol, all be .php, all be .js/.cjs/.mjs/.ts/.cts/.mts, all be .sh/.bash, or all be .rs (no mixing)`)
891
+ }
892
+ if (mappingFile && kind !== 'sol') {
893
+ throw new Error(`${name}: --mapping is only valid for .sol bundles`)
894
+ }
895
+ if (scope !== undefined && kind !== 'js') {
896
+ throw new Error(`${name}: --scope is only valid for JS bundles`)
897
+ }
898
+ if (lockfile !== undefined && kind !== 'js') {
899
+ throw new Error(`${name}: --lockfile is only valid for JS bundles`)
900
+ }
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.
904
+ if (Array.isArray(conditions) && conditions.length > 0 && kind !== 'js') {
905
+ throw new Error(`${name}: --conditions is only valid for JS bundles`)
906
+ }
907
+ // --mainFields and --metro/--platforms are legacy-/platform-resolution knobs; JS-only.
908
+ if (mainFields !== undefined && kind !== 'js') {
909
+ throw new Error(`${name}: --mainFields is only valid for JS bundles`)
910
+ }
911
+ if ((metro || (Array.isArray(platforms) && platforms.length > 0)) && kind !== 'js') {
912
+ throw new Error(`${name}: --metro is only valid for JS bundles`)
913
+ }
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.
917
+ if (metro) {
918
+ if (Array.isArray(conditions) && conditions.length > 0) {
919
+ throw new Error(`${name}: --conditions can't be combined with --metro (it sets its own conditions)`)
920
+ }
921
+ if (mainFields !== undefined) {
922
+ throw new Error(`${name}: --mainFields can't be combined with --metro (it sets its own mainFields)`)
923
+ }
924
+ if (!Array.isArray(platforms) || platforms.length === 0) {
925
+ throw new Error(`${name}: --metro requires --platforms (e.g. --platforms=ios,android)`)
926
+ }
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.
930
+ for (const p of platforms) {
931
+ if (typeof p !== 'string' || p.length === 0 || p === '*' || p.includes('/')) {
932
+ throw new Error(`${name}: invalid platform '${p}' (a platform name can't be empty, contain '/', or be '*')`)
933
+ }
934
+ }
935
+ } else if (Array.isArray(platforms) && platforms.length > 0) {
936
+ throw new Error(`${name}: --platforms is only valid with --metro`)
937
+ }
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.
940
+ if (scope !== undefined && (mainFields !== undefined || metro)) {
941
+ throw new Error(`${name}: --scope is not supported with --mainFields or --metro`)
942
+ }
943
+ return kind
944
+ }
945
+
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 })
968
+ if (kind === 'sol') return buildSolidityBundle({ cwd, entries, mappingFile })
969
+ if (kind === 'php') return buildPhpBundle({ cwd, entries })
970
+ if (kind === 'bash') return buildBashBundle({ cwd, entries })
971
+ if (kind === 'rust') return buildRustBundle({ cwd, entries })
972
+ if (metro || mainFields !== undefined) {
973
+ const { bundle } = await buildResolvedJsBundle({
974
+ cwd,
975
+ entries,
976
+ mainFields: metro ? METRO_MAIN_FIELDS : mainFields,
977
+ platforms: metro ? platforms : [null],
978
+ conditions,
979
+ metro: Boolean(metro),
980
+ })
981
+ return bundle
982
+ }
983
+ const state = await buildJsBundle({ cwd, entries, scope, conditions })
984
+ return state.sourceBundle
985
+ }
986
+
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.
991
+ const DEFAULT_BUNDLE_FILE = 'stasis.code.br'
992
+
165
993
  // Run the bundle CLI command end-to-end. Always produces a brotli-compressed
166
- // stasis bundle (matching the on-disk format of `stasis.code.br`). When
167
- // `output` is provided, writes to that path; otherwise writes to stdout.
168
- // Prints a one-line `[stasis] Bundled <n> files from <dir> to <dest>` summary
169
- // to stderr so it doesn't interleave with the binary output on stdout.
170
- export async function bundleCommand({ cwd = process.cwd(), entries, mappingFile, output } = {}) {
171
- const bundle = await buildSolidityBundle({ cwd, entries, mappingFile })
172
- const data = brotliCompressSync(bundle.serializeCode())
173
- if (output) {
174
- const outAbs = resolve(cwd, output)
175
- mkdirSync(dirname(outAbs), { recursive: true })
176
- writeFileSync(outAbs, data)
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 })
1025
+
1026
+ let bundle
1027
+ let lockData
1028
+ 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.
1033
+ const built = await buildResolvedJsBundle({
1034
+ cwd,
1035
+ entries,
1036
+ mainFields: metro ? METRO_MAIN_FIELDS : mainFields,
1037
+ platforms: metro ? platforms : [null],
1038
+ conditions,
1039
+ metro: Boolean(metro),
1040
+ })
1041
+ bundle = built.bundle
1042
+ if (lockfile) lockData = built.lockfile.serialize()
1043
+ } 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
1048
+ lockData = state.lockData
177
1049
  } else {
178
- process.stdout.write(data)
1050
+ bundle = await buildBundle({ cwd, entries, mappingFile, scope, conditions })
179
1051
  }
180
- // Bundle.sources flattens every per-package bucket into project-relative
181
- // paths, so the summary reflects every file we wrote — workspace and
182
- // node_modules alike — not just the workspace bucket.
1052
+ const serialized = bundle.serialize()
183
1053
  const files = [...bundle.sources.keys()]
1054
+ const modules = bundle.modules
1055
+
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
1060
+ if (target === '-') {
1061
+ process.stdout.write(data)
1062
+ } else {
1063
+ const outAbs = resolve(cwd, target)
1064
+ mkdirSync(dirname(outAbs), { recursive: true })
1065
+ writeFileSync(outAbs, data)
1066
+ }
1067
+ if (lockData) {
1068
+ const lockAbs = resolve(cwd, lockfile)
1069
+ mkdirSync(dirname(lockAbs), { recursive: true })
1070
+ writeFileSync(lockAbs, lockData)
1071
+ }
184
1072
  const fromDir = outermostDir(files, resolve(cwd))
185
- const dest = output ?? '<stdout>'
186
- console.warn(`[stasis] Bundled ${files.length} files from ${fromDir} to ${dest}`)
187
- return bundle
1073
+ 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.
1076
+ const packages = [...modules.values()].filter((m) => Object.keys(m.files).length > 0).length
1077
+ const pkgLabel = `${packages} package${packages === 1 ? '' : 's'}`
1078
+ console.warn(`[stasis] Bundled ${files.length} files in ${pkgLabel} from ${fromDir} to ${dest}`)
188
1079
  }