@exodus/stasis 1.0.0-beta.1 → 1.0.0-beta.10

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.
Files changed (48) hide show
  1. package/README.md +32 -8
  2. package/bin/stasis.js +386 -213
  3. package/package.json +52 -12
  4. package/src/audit-corrections.js +96 -0
  5. package/src/audit.js +289 -64
  6. package/src/babel-worker.cjs +65 -0
  7. package/src/babel.js +131 -0
  8. package/src/bundle-from-lockfile.js +14 -22
  9. package/src/bundle-reader.js +77 -0
  10. package/src/cmd/build.js +96 -128
  11. package/src/cmd/bundle.js +1121 -654
  12. package/src/cmd/diff.js +2 -19
  13. package/src/cmd/github-bundle.js +39 -0
  14. package/src/cmd/output.js +36 -0
  15. package/src/cmd/sbom.js +7 -19
  16. package/src/diff.js +80 -129
  17. package/src/installed-commits.js +182 -0
  18. package/src/json-stream.js +283 -0
  19. package/src/loader-mock.js +8 -15
  20. package/src/loaders/bash.js +49 -116
  21. package/src/loaders/cargo.js +1716 -0
  22. package/src/loaders/foundry.js +923 -0
  23. package/src/loaders/paths.js +33 -0
  24. package/src/loaders/php.js +375 -402
  25. package/src/loaders/rust.js +3254 -242
  26. package/src/loaders/solidity-ownership.js +531 -0
  27. package/src/loaders/solidity.js +412 -162
  28. package/src/loaders/toml.js +68 -0
  29. package/src/metro-resolver.js +150 -0
  30. package/src/metro-transformer.js +3 -2
  31. package/src/mock.js +57 -165
  32. package/src/parse.js +4 -16
  33. package/src/resolve-fields.js +370 -0
  34. package/src/resolve-node.js +419 -0
  35. package/src/resolve-typescript.js +541 -0
  36. package/src/rollup.js +5 -0
  37. package/src/sbom.js +120 -97
  38. package/src/scan.js +348 -180
  39. package/src/vfs-bundle/entries.js +144 -0
  40. package/src/vfs-bundle/github.js +202 -0
  41. package/src/vfs-bundle/prisma.js +439 -0
  42. package/src/vfs-bundle/soldeer.js +125 -0
  43. package/src/vfs-bundle/tree.js +647 -0
  44. package/src/vfs-bundle.js +80 -0
  45. package/src/why.js +434 -0
  46. package/src/apis/npm/index.js +0 -36
  47. package/src/apis/npm/semver.cjs +0 -44
  48. package/src/cmd/extract.js +0 -193
package/src/cmd/bundle.js CHANGED
@@ -1,46 +1,55 @@
1
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'
2
+ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'
3
+ import { basename, dirname, extname, join, posix, relative, resolve } from 'node:path'
4
4
  import { fileURLToPath } from 'node:url'
5
- import { brotliCompressSync } from 'node:zlib'
5
+ import { brotliDecompressSync } from 'node:zlib'
6
6
 
7
- import { Bundle } from '@exodus/stasis-core/bundle'
7
+ import { Bundle, isValidRepoField } from '@exodus/stasis-core/bundle'
8
8
  import { Lockfile } from '@exodus/stasis-core/lockfile'
9
- import { scan } from '../scan.js'
9
+ import { importsTypescriptByOutputName, scan } from '../scan.js'
10
10
  import { createFieldResolver, resolveConditions } from '../resolve-fields.js'
11
+ import { discoverTsconfig, isDir, loadTsconfigPaths, packageTsconfigPaths } from '../resolve-typescript.js'
12
+ import { createMetroResolver } from '../metro-resolver.js'
13
+ import { pinInstalledCommits } from '../installed-commits.js'
11
14
  import { State } from '@exodus/stasis-core/state'
12
- import { brotliOptions } from '@exodus/stasis-core/brotli'
13
15
  import { sha512integrity } from '@exodus/stasis-core/state-util'
14
- import { assertRealPathWithinBase, moduleFileKey, splitNodeModulesPath } from '@exodus/stasis-core/util'
16
+ import { checkoutCommit, detectRepo, findPackageMetadata, normalizeEntries, packageType, parseGithubRepository, readJson, readModuleManifest, readPackageJson, readRegularFileOrNull } from '@exodus/stasis-core/bundle-util'
17
+ import { EMPTY_MODULE_PATH, RN_CORE_INCLUDE_FILES, assertRealPathWithinBase, classifyNativeCapture, hasNodeModulesSegment, isDotEnvFile, isExcludedNativeDir, isExecutableFile, isNativeArtifact, isNativeManifest, isPathWithin, isPodspec, isSkippedNativeWalkDir, moduleFileKey, moduleInfo, parseResourcesOption, posixPathEscapes, refineNativeCapture, relativeEscapes, splitNodeModulesPath, toPosix, withMetadataOf } from '@exodus/stasis-core/util'
18
+ import { diskHost } from '@exodus/stasis-core/host'
19
+ import { parseSoldeerLockfile } from '@preventive/lockfile/soldeer.js'
15
20
  import {
21
+ SOLIDITY_PACKAGE_MANIFESTS,
22
+ SOLIDITY_ROOT_MANIFESTS,
16
23
  buildSolidityTree,
17
24
  collectSolidityFilesFromDisk,
18
- readRemappingsFile,
25
+ discoverSolidityConfig,
26
+ expandSolidityEntries,
19
27
  } from '../loaders/solidity.js'
28
+ import { decodeUtf8 } from '../loaders/solidity-ownership.js'
20
29
  import { buildBashTree, collectBashFilesFromDisk } from '../loaders/bash.js'
21
- import { buildRustTree, collectRustFilesFromDisk } from '../loaders/rust.js'
30
+ import { boundaryOf, collectRustBundle, withinRealDir } from '../loaders/rust.js'
31
+ import { createCargoContext } from '../loaders/cargo.js'
22
32
  import {
23
33
  bucketizePhpSources,
24
34
  buildPhpTree,
25
35
  collectPhpFilesFromDisk,
26
36
  loadComposerAutoload,
37
+ loadComposerPackages,
27
38
  loadLaravelProviderFiles,
28
39
  } from '../loaders/php.js'
40
+ import { DEFAULT_BUNDLE_FILE, bundledSummary, packagesLabel, writeBundle, writeFile } from './output.js'
29
41
 
30
- const JS_EXTS = new Set(['.js', '.cjs', '.mjs', '.ts', '.cts', '.mts'])
42
+ const JS_EXTS = new Set(['.js', '.cjs', '.mjs', '.ts', '.cts', '.mts', '.jsx', '.tsx'])
31
43
  const BASH_EXTS = new Set(['.sh', '.bash'])
32
44
  const RUST_EXTS = new Set(['.rs'])
33
45
 
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.
46
+ // Fallback identity for the workspace bucket; Bundle.parse requires every bucket to attest name+version.
38
47
  const SOLIDITY_WORKSPACE_NAME = 'solidity-bundle'
39
48
  const SOLIDITY_WORKSPACE_VERSION = '0.0.0'
40
49
  const SOLIDITY_FORMAT = 'solidity'
41
50
  const BASH_WORKSPACE_NAME = 'bash-bundle'
42
51
  const BASH_WORKSPACE_VERSION = '0.0.0'
43
- const BASH_FORMAT = 'bash'
52
+ const SHELL_FORMAT = 'shell'
44
53
 
45
54
  const RUST_WORKSPACE_NAME = 'rust-bundle'
46
55
  const RUST_WORKSPACE_VERSION = '0.0.0'
@@ -50,50 +59,11 @@ const PHP_WORKSPACE_NAME = 'php-bundle'
50
59
  const PHP_WORKSPACE_VERSION = '0.0.0'
51
60
  const PHP_FORMAT = 'php'
52
61
 
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.
62
+ // Deepest common parent dir of `paths`, relative to `cwd`; starts with `..` when entries
63
+ // escape cwd (e.g. via remapping), or "." when it is cwd itself.
94
64
  export function outermostDir(paths, cwd) {
95
65
  if (paths.length === 0) return '.'
96
- const cwdAbs = posix.resolve(cwd.replaceAll(/\\/gu, '/'))
66
+ const cwdAbs = posix.resolve(toPosix(cwd))
97
67
  const absDirs = paths.map((p) => posix.dirname(posix.resolve(cwdAbs, p)))
98
68
  const partsList = absDirs.map((d) => d.split('/'))
99
69
  const common = []
@@ -107,205 +77,232 @@ export function outermostDir(paths, cwd) {
107
77
  return rel === '' ? '.' : rel
108
78
  }
109
79
 
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).
80
+ // Split a Soldeer dep dir `<name>-<version>` (its authoritative identity); falls back to
81
+ // version 0.0.0 when it doesn't end in a semver (every bucket must attest a version).
124
82
  function parseSoldeerDir(seg) {
125
83
  const m = /^(.+)-(\d+\.\d+\.\d+(?:[-+][\w.-]+)?)$/u.exec(seg)
126
84
  return m ? { name: m[1], version: m[2] } : { name: seg, version: '0.0.0' }
127
85
  }
128
86
 
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) {
87
+ // A url as git fetches from a host: scp's `[user@]host:path`, with no scheme and a colon before its
88
+ // first slash, or a URL of a transport git has itself (`http`, `https`, `git`, `ssh` and its
89
+ // `git+ssh`/`ssh+git` aliases, lowercase, as git spells them), its authority, `[userinfo@]host[:port]`,
90
+ // ending at the first slash after it. Not, then, what a package.json spells GitHub's that git reads
91
+ // another way or not at all: `ssh://git@github.com:owner/name`, whose host is `github.com:owner` to
92
+ // git; `git+https://` or `HTTPS://`, transports git doesn't have; nor `owner/name`, a path, as is any
93
+ // url with no colon before its first slash.
94
+ const GIT_URL = /^(?:[^/]*:(?!\/\/)|(?:https?|git|ssh|git\+ssh|ssh\+git):\/\/(?:[^/@]*@)?[^/:@]*(?::\d*)?\/)/u
95
+
96
+ // The GitHub repository, `owner/name`, a .gitmodules `url` names (parseGithubRepository), else null:
97
+ // none for a host merely holding `github.com` (`https://notgithub.com/o/n`), nor for a url git
98
+ // doesn't fetch as one (GIT_URL), nor for npm's `github:owner/name`, the host `github`'s path to git,
99
+ // whatever an ssh alias or `insteadOf` makes of it, nor one with a `#`, which git keeps in the path
100
+ // it asks for (`owner/name.git#v1`) where npm takes a committish. git's `ssh+git://`, which a
101
+ // package.json doesn't spell, is its `ssh://`.
102
+ const submoduleGithub = (url) => (GIT_URL.test(url) && !/^github:/iu.test(url) && !url.includes('#') ? parseGithubRepository(url.replace(/^ssh\+git:/u, 'ssh:')) : null)
103
+
104
+ // The GitHub ones of `submodules` (readGitmodules), as Map<submodulePath, { name, branch }>.
105
+ function githubSubmodules(submodules) {
140
106
  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
107
+ for (const { path, url, branch } of submodules) {
108
+ const name = url && submoduleGithub(url)
109
+ if (name) byPath.set(path, { name, branch })
150
110
  }
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
111
  return byPath
166
112
  }
167
113
 
168
- function readFileSyncOrNull(file) {
114
+ // The root of the GitHub repository `github` at `commit`, where both are valid; else undefined.
115
+ const repoRootAt = (github, commit) => (isValidRepoField('github', github) && isValidRepoField('commit', commit) ? { github, directory: '', commit } : undefined)
116
+
117
+ // The text of `rel`, a file of the project root, read as `ownership` vouches for it; null where it
118
+ // refuses it, nothing is there, or it isn't UTF-8.
119
+ function ownedText(baseDir, rel, ownership, host) {
120
+ const { reason, real, outside } = ownership.of(rel)
121
+ if (reason || real === null || outside) return null
169
122
  try {
170
- return readFileSync(file, 'utf8')
123
+ const buf = readRegularFileOrNull(join(host.realpath(baseDir), real), rel, host)
124
+ return buf === null ? null : decodeUtf8(buf, rel)
171
125
  } catch {
172
126
  return null
173
127
  }
174
128
  }
175
129
 
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)
130
+ // Each git dependency the root's soldeer.lock installs from GitHub, by its folder under
131
+ // `dependencies/` (`<name>-<version>`, as Soldeer names it): the repository's root at the lockfile's
132
+ // `rev`, which Soldeer checks out there. None where the lockfile can't be read or parsed: it is
133
+ // metadata, which never stops a build.
134
+ function soldeerGitRepos(baseDir, ownership, host) {
135
+ const text = ownedText(baseDir, 'soldeer.lock', ownership, host)
136
+ let lock = null
137
+ try {
138
+ lock = text === null ? null : parseSoldeerLockfile(text)
139
+ } catch {
140
+ // Not one Soldeer writes: nothing is recorded from it.
141
+ }
142
+ const repos = new Map()
143
+ for (const dep of Object.values(lock?.dependencies ?? {})) {
144
+ const repo = dep.type === 'git' ? repoRootAt(parseGithubRepository(dep.git), dep.rev) : undefined
145
+ if (repo !== undefined) repos.set(`${dep.name}-${dep.version}`, repo)
146
+ }
147
+ return repos
148
+ }
149
+
150
+ // Classify a Solidity file's dep bucket: Soldeer (`dependencies/<name>-<version>/`) or a
151
+ // github submodule (`lib/`, via the `.gitmodules` `ownership` read), else null to defer to the
152
+ // node_modules/workspace logic. `ownership.assert` vets a package.json path before it is read
153
+ // through `host`. A bucket's `repo` is the GitHub repository it is checked out from, at its root, at
154
+ // the commit its checkout's HEAD is at, as the bundle's own `repo` reads one: a submodule's, from
155
+ // the GitHub repository its .gitmodules url names; a Soldeer git dependency's, where it is its
156
+ // soldeer.lock `rev` (soldeerGitRepos), as Soldeer holds a checkout to the lockfile before taking it
157
+ // for installed: a folder left at another commit since the lockfile changed, or no checkout, records
158
+ // none.
159
+ function makeSolidityClassifier(baseDir, ownership, host) {
160
+ const submodules = githubSubmodules(ownership.submodules)
161
+ const check = ownership.assert
162
+ const versions = new Map() // a submodule's package.json version, read once
163
+ const commits = new Map() // a bucket's checkout's commit, read once
164
+ const commitOf = (dir) => {
165
+ if (!commits.has(dir)) commits.set(dir, checkoutCommit(join(baseDir, dir), host))
166
+ return commits.get(dir)
167
+ }
168
+ let soldeer // soldeerGitRepos, read at the first Soldeer file
185
169
  return (path) => {
186
170
  if (path.startsWith('dependencies/')) {
187
171
  const seg = path.slice('dependencies/'.length).split('/')[0]
188
172
  if (seg) {
189
173
  const { name, version } = parseSoldeerDir(seg)
190
- return { bucketDir: `dependencies/${seg}`, name, version, ecosystem: 'soldeer' }
174
+ const bucketDir = `dependencies/${seg}`
175
+ soldeer ??= soldeerGitRepos(baseDir, ownership, host)
176
+ const locked = soldeer.get(seg)
177
+ const repo = locked !== undefined && commitOf(bucketDir) === locked.commit ? locked : undefined
178
+ return { bucketDir, name, version, ecosystem: 'soldeer', ...(repo === undefined ? {} : { repo }) }
191
179
  }
192
180
  }
193
181
  for (const [sub, { name, branch }] of submodules) {
194
182
  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' }
183
+ if (!versions.has(sub)) versions.set(sub, readPackageJson(baseDir, moduleFileKey(sub, 'package.json'), { strict: true, check, host })?.version)
184
+ const repo = repoRootAt(name, commitOf(sub))
185
+ return { bucketDir: sub, name, version: versions.get(sub) ?? branch ?? '0.0.0', ecosystem: 'github', ...(repo === undefined ? {} : { repo }) }
197
186
  }
198
187
  }
199
188
  return null
200
189
  }
201
190
  }
202
191
 
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
192
+ // A vendored crate's ecosystem, by where `cargo vendor` copied it from (vendoredFrom): a registry's
193
+ // copy is `cargo`, which `stasis audit` asks OSV about as a crates.io crate's; a git checkout's is
194
+ // `cargo-git`, and a copy with no `.cargo-checksum.json`, which could be either, `cargo-unknown`.
195
+ // Neither is ever sent to a public advisory database: a private git dependency's name has no place
196
+ // there, and a crates.io crate of the same name isn't it.
197
+ const vendoredEcosystem = (from) => {
198
+ if (from === 'registry') return 'cargo'
199
+ return from === 'git' ? 'cargo-git' : 'cargo-unknown'
219
200
  }
220
201
 
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) {
202
+ // Classify a Rust file by the nearest Cargo.toml `[package]`: a `cargo vendor`ed crate under the
203
+ // vendor dir (`vendor/<dir>/`, or where .cargo/config.toml points) is a dependency (tagged
204
+ // `cargo`, `cargo-git` or `cargo-unknown`: see vendoredEcosystem); any other package (the crate
205
+ // itself, a workspace member reached through a `path` dependency) is first-party, so no
206
+ // ecosystem. Null (no manifest above the file) defers to the package.json/placeholder logic.
207
+ function makeRustClassifier(cargo) {
226
208
  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
209
+ const pkg = cargo.packageInfo(path)
210
+ if (!pkg) return null
211
+ return { bucketDir: pkg.dir, name: pkg.name, version: pkg.version, ecosystem: cargo.isVendored(path) ? vendoredEcosystem(cargo.vendoredFrom(path)) : undefined }
212
+ }
213
+ }
214
+
215
+ // A file's key inside its bucket: the inverse of moduleFileKey.
216
+ const fileInBucket = (bucketDir, path) => (bucketDir === '.' ? path : path.slice(bucketDir.length + 1))
217
+
218
+ // A file of the bundle root (`realBase` its real path), its bytes, by project-relative path, or
219
+ // null when it isn't a regular file there (a manifest the context saw may have gone since; a FIFO
220
+ // would hang a read) or, with `within` a directory, doesn't really lie in it (a vendored crate's
221
+ // manifest linked to a project file).
222
+ function readFileWithinBase(baseDir, realBase, rel, within) {
223
+ try {
224
+ assertRealPathWithinBase(realBase, baseDir, rel)
225
+ if (!withinRealDir(baseDir, rel, within, { who: 'loader.cargo' })) return null
226
+ if (!statSync(join(baseDir, rel)).isFile()) return null
227
+ return readFileSync(join(baseDir, rel))
228
+ } catch (err) {
229
+ if (err.code === 'ENOENT' || err.code === 'EISDIR') return null
230
+ throw err
231
+ }
232
+ }
233
+
234
+ // Project-relative paths in `sources` whose on-disk file carries a POSIX execute bit -- the
235
+ // `executable` list both artifacts record. The State-driven path derives this in addFile; the
236
+ // static builders never touch a State, so they stat here.
237
+ function executableSources(baseDir, sources, host) {
238
+ const executable = new Set()
239
+ for (const path of sources.keys()) {
240
+ if (isExecutableFile(join(baseDir, path), host)) executable.add(path)
241
+ }
242
+ return executable
243
+ }
244
+
245
+ // Bundles must be self-contained: throw on a missing entry or an unresolved `noun` (import, script,
246
+ // module) of a `lang` bundle -- `missing`'s { spec, from }, with the `reason` it was refused if given.
247
+ function assertSelfContained(lang, noun, entries, sources, missing) {
248
+ const issues = []
249
+ for (const entry of entries) {
250
+ if (!sources.has(entry)) issues.push(`Missing entry: ${entry}`)
251
+ }
252
+ for (const { spec, from, reason } of missing) {
253
+ issues.push(`Unresolved ${noun}: ${spec} from ${from}${reason ? ` (refused: ${reason})` : ''}`)
254
+ }
255
+ if (issues.length > 0) {
256
+ throw new Error(`${lang} bundle has unresolved ${noun}s:\n${issues.map((s) => ` ${s}`).join('\n')}`)
233
257
  }
234
258
  }
235
259
 
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.
260
+ // Assemble a full-scope code Bundle shared by the non-JS bundlers. Files are bucketed by
261
+ // nearest package.json (node_modules -> `npm`-tagged bucket, workspace -> its dir, none ->
262
+ // "." with the placeholder identity); a node_modules file whose nearest package.json is the
263
+ // workspace root is rejected, not mislabeled; `packageOf(path)` finds that package.json
264
+ // (packageLookup by default, which walks past a malformed one). `classifyDep(path)` optionally
265
+ // places a file directly (non-node_modules ecosystems like Soldeer/github); null defers. `format` tags
266
+ // every file; `formats` (Map<path,format>) overrides it per file. `resolutions` values are
267
+ // a flat target string or a Map<platform,target>; both round-trip untouched.
274
268
  function assembleCodeBundle({
275
- baseDir, entries, sources, resolutions, workspaceName, workspaceVersion, format, formats, conditionKey, classifyDep,
269
+ baseDir, entries, sources, resolutions, workspaceName, workspaceVersion, format, formats, conditionKey, classifyDep, host,
270
+ packageOf = packageLookup(baseDir, { host }),
276
271
  }) {
277
272
  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
- }
273
+ // `identity`'s name, version, ecosystem, repo and vendored (moduleInfo takes those alone); a later
274
+ // file's adds the vendored package it is in.
275
+ const ensureBucket = (dir, identity) => {
276
+ const bucket = modules.get(dir)
277
+ modules.set(dir, bucket === undefined ? moduleInfo({ ...identity, files: Object.create(null) }) : withMetadataOf(bucket, identity))
284
278
  return modules.get(dir)
285
279
  }
286
280
 
287
281
  for (const [path, content] of sources) {
288
282
  const dep = classifyDep?.(path)
289
283
  if (dep) {
290
- ensureBucket(dep.bucketDir, dep.name, dep.version, dep.ecosystem)
291
- .files[path.slice(dep.bucketDir.length + 1)] = content
284
+ ensureBucket(dep.bucketDir, dep).files[fileInBucket(dep.bucketDir, path)] = content
292
285
  continue
293
286
  }
294
- const meta = findPackageMetadata(baseDir, path)
287
+ const meta = packageOf(path)
295
288
  const inNodeModules = splitNodeModulesPath(path) !== null
296
289
  if (meta) {
297
- if (inNodeModules && !meta.pkgDir.includes('node_modules')) {
290
+ if (inNodeModules && !hasNodeModulesSegment(meta.pkgDir)) {
298
291
  throw new Error(`No package.json with name+version found for ${path}`)
299
292
  }
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
293
+ ensureBucket(meta.pkgDir, meta).files[fileInBucket(meta.pkgDir, path)] = content
303
294
  } else {
304
295
  if (inNodeModules) throw new Error(`No package.json with name+version found for ${path}`)
305
- ensureBucket('.', workspaceName, workspaceVersion).files[path] = content
296
+ ensureBucket('.', { name: workspaceName, version: workspaceVersion }).files[path] = content
306
297
  }
307
298
  }
308
299
 
300
+ return codeBundle({ baseDir, entries, sources, modules, resolutions, format, formats, conditionKey, host })
301
+ }
302
+
303
+ // A full-scope code Bundle of `sources`, bucketed as `modules`: `format` tags every file, `formats`
304
+ // (Map<path,format>) overrides it per file, and the `resolutions` edges are keyed under `conditionKey`.
305
+ function codeBundle({ baseDir, entries, sources, modules, resolutions, format, formats, conditionKey, host }) {
309
306
  const formatsMap = new Map()
310
307
  for (const path of sources.keys()) formatsMap.set(path, formats?.get(path) ?? format)
311
308
 
@@ -313,75 +310,163 @@ function assembleCodeBundle({
313
310
  for (const [parent, specMap] of resolutions) importsForKey.set(parent, specMap)
314
311
  const imports = new Map([[conditionKey, importsForKey]])
315
312
 
313
+ // Executable bits, straight off disk (a synthetic source with no file there is simply not
314
+ // executable). Shell bundles lean on this most: `stasis extract` puts the +x back on the scripts.
315
+ const executable = executableSources(baseDir, sources, host)
316
+
317
+ // Attribute files to the `bundle` consumer (static builders skip State's per-file tagging).
316
318
  return new Bundle({
317
319
  config: { scope: 'full' },
318
320
  entries: new Set(entries),
319
321
  modules,
320
322
  formats: formatsMap,
321
323
  imports,
322
- })
324
+ executable,
325
+ }).withReason('bundle')
323
326
  }
324
327
 
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`.
329
- export async function buildSolidityBundle({ cwd = process.cwd(), entries, mappingFile } = {}) {
330
- if (!Array.isArray(entries) || entries.length === 0) {
331
- throw new Error('buildSolidityBundle: at least one entry .sol file is required')
328
+ // Files never carried, whatever reads them: `.env` files, and Hardhat's config, which is code
329
+ // (both however they're cased).
330
+ const neverCarried = (rel) => isDotEnvFile(rel) || posix.basename(rel).toLowerCase().startsWith('hardhat.config.')
331
+
332
+ // findPackageMetadata (with `options`) once per directory, the only thing its answer depends on.
333
+ function packageLookup(baseDir, options) {
334
+ const byDir = new Map()
335
+ return (path) => {
336
+ const dir = posix.dirname(path)
337
+ if (!byDir.has(dir)) byDir.set(dir, findPackageMetadata(baseDir, path, options))
338
+ return byDir.get(dir)
332
339
  }
333
- for (const e of entries) {
334
- if (!e.endsWith('.sol')) throw new Error(`buildSolidityBundle: not a .sol file: ${e}`)
340
+ }
341
+
342
+ // The build-description files of a Solidity bundle (--manifests), as Map<path, text>, sorted:
343
+ // `configFiles` (what discoverSolidityConfig read, whatever they're called), each of which must be
344
+ // carried -- one outside the root, neverCarried, refused or gone is an error, as the bundle
345
+ // couldn't reproduce the resolution without it -- plus the SOLIDITY_*_MANIFESTS that exist, for the
346
+ // root and for each package dir `classifyDep`/`packageOf` places a bundled source in, but none
347
+ // whose path `ownership` refuses (see solidityOwnership). Carried as written: whatever they hold
348
+ // (an RPC URL with its API key, an Etherscan key, a URL's credentials) is in the bundle too, as
349
+ // with --package-json. Read through `host`.
350
+ function solidityManifests(baseDir, sources, configFiles, { classifyDep, packageOf, ownership, host }) {
351
+ const realBase = host.realpath(baseDir)
352
+ // `{ text }`, or `{ why }` it can't be carried (null: nothing is there). Read by the real path
353
+ // `ownership` resolved `rel` to, so what's carried is the file it vouched for.
354
+ const carry = (rel) => {
355
+ const { reason, real, outside } = ownership.of(rel)
356
+ if (reason) return { why: reason }
357
+ if (real === null) return { why: null }
358
+ if (outside) throw new Error(`Refusing to follow symlink escaping bundle root: ${rel} -> ${resolve(realBase, real)}`)
359
+ const buf = readRegularFileOrNull(join(realBase, real), rel, host)
360
+ if (buf === null) return { why: null } // a directory
361
+ return { text: decodeUtf8(buf, rel) }
362
+ }
363
+ const unreproducible = (rel, why) => new Error(`--manifests can't carry ${rel}, which the Solidity resolution read: ${why}`)
364
+ const out = new Map()
365
+ for (const rel of configFiles) {
366
+ // Absolute: the OS couldn't give its real path (see projectRelative).
367
+ if (posix.isAbsolute(rel)) throw unreproducible(rel, "its real path can't be resolved")
368
+ if (posixPathEscapes(rel)) throw unreproducible(rel, 'it lies outside the bundle root')
369
+ if (neverCarried(rel)) throw unreproducible(rel, '.env files and hardhat.config.* are never carried')
370
+ if (sources.has(rel)) continue
371
+ const { text, why } = carry(rel)
372
+ if (text === undefined) throw unreproducible(rel, why ?? 'it is gone')
373
+ out.set(rel, text)
374
+ }
375
+ const optional = new Set(SOLIDITY_ROOT_MANIFESTS)
376
+ for (const path of sources.keys()) {
377
+ const dirs = [classifyDep(path)?.bucketDir, packageOf(path)?.pkgDir].filter((d) => d !== undefined)
378
+ for (const dir of dirs) for (const name of SOLIDITY_PACKAGE_MANIFESTS) optional.add(moduleFileKey(dir, name))
379
+ }
380
+ for (const rel of optional) {
381
+ if (sources.has(rel) || out.has(rel)) continue
382
+ const { text, why } = carry(rel)
383
+ if (why) console.warn(`[stasis] Not carrying ${rel}: ${why}`)
384
+ if (text !== undefined) out.set(rel, text)
385
+ }
386
+ return new Map([...out.keys()].toSorted().map((rel) => [rel, out.get(rel)]))
387
+ }
388
+
389
+ // An entry `stasis bundle` takes for a directory: one that is, or an extensionless path that
390
+ // doesn't exist (a project without `script/` still bundles with `src test script`).
391
+ export const isDirEntry = (abs, host = diskHost) => isDir(abs, host) || (extname(abs) === '' && host.stat(abs) === null)
392
+
393
+ // Whether `entry` (resolved against `cwd`) is a Solidity bundle's: a .sol file or a directory entry.
394
+ export const isSolidityEntry = (entry, cwd = process.cwd(), host = diskHost) => entry.endsWith('.sol') || isDirEntry(resolve(cwd, entry), host)
395
+
396
+ // What's wrong with `entries`' directory entries (resolved against `cwd`), or null: a directory
397
+ // entry stands for the .sol files under it, so it goes with Solidity entries only; and entries
398
+ // that are all missing extensionless paths are a mistyped file, not a project without those dirs.
399
+ // `fetched` false: `host` is an empty tree standing in for one not fetched yet, which can't say
400
+ // what is missing.
401
+ export function directoryEntryError(entries, cwd = process.cwd(), host = diskHost, { fetched = true } = {}) {
402
+ const dirs = entries.filter((e) => isDirEntry(resolve(cwd, e), host))
403
+ const absent = fetched ? dirs.filter((e) => host.stat(resolve(cwd, e)) === null) : []
404
+ if (absent.length > 0 && absent.length === entries.length) return `no such file or directory: ${absent[0]}`
405
+ if (dirs.length === 0 || entries.every((e) => e.endsWith('.sol') || dirs.includes(e))) return null
406
+ return absent.length > 0
407
+ ? `no such file or directory: ${absent[0]}`
408
+ : `a directory entry is only supported for Solidity bundles (it stands for the .sol files under it): ${dirs[0]}`
409
+ }
410
+
411
+ // Build an in-memory Bundle from entry .sol files and directories (a directory stands for the .sol
412
+ // files under it, as forge's src/test/script dirs and Hardhat's contracts dir do; a missing or
413
+ // empty one is skipped). Imports resolve through the remappings the project's build uses
414
+ // (discoverSolidityConfig: forge's discovery for a Foundry project); `mappingFile`
415
+ // (foundry.toml/remappings.txt) pins them to exactly what it lists. An import is refused when it
416
+ // reaches a non-.sol file or leaves the root, or when a dependency's reaches the project's own
417
+ // files. Config files are read, and bundled only with `manifests` (see solidityManifests). `env`
418
+ // supplies FOUNDRY_PROFILE / FOUNDRY_REMAPPINGS, reported when they apply. The project is read
419
+ // through `host`.
420
+ export async function buildSolidityBundle({ cwd = process.cwd(), entries, mappingFile, manifests = false, env = process.env, host = diskHost } = {}) {
421
+ if (!Array.isArray(entries) || entries.length === 0) {
422
+ throw new Error('buildSolidityBundle: at least one entry .sol file or directory is required')
335
423
  }
336
424
 
337
425
  const baseDir = resolve(cwd)
338
- const remappings = mappingFile ? await readRemappingsFile(resolve(cwd, mappingFile)) : []
339
426
  const normalized = normalizeEntries(entries, cwd)
427
+ for (const e of normalized) {
428
+ if (!isSolidityEntry(e, baseDir, host)) throw new Error(`buildSolidityBundle: not a .sol file or directory: ${e}`)
429
+ }
430
+ const expanded = expandSolidityEntries(baseDir, normalized, host)
340
431
 
341
- const sources = await collectSolidityFilesFromDisk(baseDir, normalized, remappings)
342
- const { resolutions, missing } = buildSolidityTree(sources, { remappings, baseDir })
432
+ const { remappings, libs, ownership, files: configFiles, envUsed } = discoverSolidityConfig(baseDir, { mappingFile, env, host })
433
+ // The bundle doesn't record the environment, so say when it shaped the resolution.
434
+ if (envUsed.length > 0) console.warn(`[stasis] Solidity imports resolved with ${envUsed.join(', ')} from the environment`)
435
+ const sources = collectSolidityFilesFromDisk(baseDir, expanded, remappings, { libs, ownership, host })
436
+ const { resolutions, missing } = buildSolidityTree(sources, { remappings, baseDir, libs, ownership, host })
343
437
 
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.
348
- const issues = []
349
- for (const entry of normalized) {
350
- if (!sources.has(entry)) issues.push(`Missing entry: ${entry}`)
351
- }
352
- for (const { spec, from } of missing) {
353
- issues.push(`Unresolved import: ${spec} from ${from}`)
354
- }
355
- if (issues.length > 0) {
356
- throw new Error(`Solidity bundle has unresolved imports:\n${issues.map((s) => ` ${s}`).join('\n')}`)
438
+ assertSelfContained('Solidity', 'import', expanded, sources, missing)
439
+
440
+ const classifyDep = makeSolidityClassifier(baseDir, ownership, host)
441
+ const packageOf = packageLookup(baseDir, { strict: true, check: ownership.assert, host })
442
+ const bundled = new Map(sources)
443
+ const formats = new Map()
444
+ if (manifests) {
445
+ for (const [path, text] of solidityManifests(baseDir, sources, configFiles, { classifyDep, packageOf, ownership, host })) {
446
+ bundled.set(path, text)
447
+ formats.set(path, posix.basename(path) === 'package.json' ? 'json' : 'resource')
448
+ }
357
449
  }
358
450
 
359
451
  return assembleCodeBundle({
360
452
  baseDir,
361
- entries: normalized,
362
- sources,
453
+ entries: expanded,
454
+ sources: bundled,
363
455
  resolutions,
364
456
  workspaceName: SOLIDITY_WORKSPACE_NAME,
365
457
  workspaceVersion: SOLIDITY_WORKSPACE_VERSION,
366
458
  format: SOLIDITY_FORMAT,
459
+ formats,
367
460
  conditionKey: 'solidity',
368
- classifyDep: makeSolidityClassifier(baseDir),
461
+ classifyDep,
462
+ packageOf,
463
+ host,
369
464
  })
370
465
  }
371
466
 
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 "*".
467
+ // Build an in-memory Bundle from entry .sh/.bash files by walking the source/exec graph.
468
+ // A local `source` inside the bundle root that resolves to no bundled script is fatal;
469
+ // external refs (PATH commands, `$VAR`, absolute, `../`-escaping) are dropped.
385
470
  export async function buildBashBundle({ cwd = process.cwd(), entries } = {}) {
386
471
  if (!Array.isArray(entries) || entries.length === 0) {
387
472
  throw new Error('buildBashBundle: at least one entry .sh/.bash file is required')
@@ -396,21 +481,8 @@ export async function buildBashBundle({ cwd = process.cwd(), entries } = {}) {
396
481
  const sources = await collectBashFilesFromDisk(baseDir, normalized)
397
482
  const { resolutions, missing } = buildBashTree(sources)
398
483
 
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
- }
484
+ // Self-contained (external refs never reach `missing`).
485
+ assertSelfContained('Bash', 'script', normalized, sources, missing)
414
486
 
415
487
  return assembleCodeBundle({
416
488
  baseDir,
@@ -419,20 +491,27 @@ export async function buildBashBundle({ cwd = process.cwd(), entries } = {}) {
419
491
  resolutions,
420
492
  workspaceName: BASH_WORKSPACE_NAME,
421
493
  workspaceVersion: BASH_WORKSPACE_VERSION,
422
- format: BASH_FORMAT,
423
- conditionKey: 'bash',
494
+ format: SHELL_FORMAT,
495
+ conditionKey: SHELL_FORMAT,
424
496
  })
425
497
  }
426
498
 
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 } = {}) {
499
+ // Build an in-memory Bundle from entry .rs files (crate roots) by walking `mod` declarations and
500
+ // references to in-tree crates (the package's own lib, Cargo `path` deps, `cargo vendor`ed
501
+ // crates). An unresolvable unconditional `mod` is fatal; path edges (`crate::`/`self::`/`super::`/
502
+ // relative `use`s) are recorded best-effort and never widen the file set. Registry deps that
503
+ // aren't vendored can't be bundled: they're reported, with a `cargo vendor` hint when there is no
504
+ // `vendor/` dir at all. Each crate's features are resolved from the manifests (Cargo.toml +
505
+ // Cargo.lock) the way `cargo build` of the entries' packages would, so `#[cfg(feature = …)]` code
506
+ // that is off stays out; `cargo` takes them from `cargo metadata` instead (opt-in: it runs cargo).
507
+ // `cargoFeatures` / `cargoNoDefaultFeatures` / `cargoAllFeatures` are cargo's `--features` /
508
+ // `--no-default-features` / `--all-features` for the entries' packages, in either mode.
509
+ // `cargoTarget` (a triple, or `host`) names the build's target: its cfgs, asked of rustc, decide
510
+ // `#[cfg(unix)]`-style code and target-specific dependency tables, which are otherwise all kept.
511
+ // `cargoManifests` also carries what describes each bundled package's build: its Cargo.toml (and
512
+ // the workspace's), its build script -- walked like a crate root, so its modules and the in-tree
513
+ // build-dependencies it reaches come along -- plus the root's Cargo.lock and .cargo/config.toml.
514
+ export async function buildRustBundle({ cwd = process.cwd(), entries, cargo = false, cargoFeatures = [], cargoNoDefaultFeatures = false, cargoAllFeatures = false, cargoTarget = null, cargoManifests = false } = {}) {
436
515
  if (!Array.isArray(entries) || entries.length === 0) {
437
516
  throw new Error('buildRustBundle: at least one entry .rs file is required')
438
517
  }
@@ -443,18 +522,76 @@ export async function buildRustBundle({ cwd = process.cwd(), entries } = {}) {
443
522
  const baseDir = resolve(cwd)
444
523
  const normalized = normalizeEntries(entries, cwd)
445
524
 
446
- const sources = await collectRustFilesFromDisk(baseDir, normalized)
447
- const { resolutions, missing } = buildRustTree(sources)
525
+ // One Cargo context for the walk, the edge pass and the bucketing: manifests are read once, and
526
+ // the walk's crate resolution and the tree's feature decisions agree.
527
+ const cargoCtx = createCargoContext(baseDir, {
528
+ entries: normalized,
529
+ cargo,
530
+ features: cargoFeatures,
531
+ noDefaultFeatures: cargoNoDefaultFeatures,
532
+ allFeatures: cargoAllFeatures,
533
+ target: cargoTarget,
534
+ })
535
+ // `include_str!` / `include_bytes!` assets ride along as resources: the walk records their
536
+ // format. With `cargoManifests`, each package's build script is walked too (collectRustBundle).
537
+ const { sources, formats, tree } = await collectRustBundle(baseDir, normalized, { cargo: cargoCtx, buildScripts: cargoManifests })
538
+ if (cargoManifests) {
539
+ // Each bundled package's manifests, and its workspace's lockfile and cargo config, carried as
540
+ // written: whatever they hold (a registry token, a `git` URL's credentials) is in the bundle
541
+ // too, as with --package-json. A file that isn't UTF-8 text is refused, not altered.
542
+ const files = new Set()
543
+ for (const path of sources.keys()) for (const f of cargoCtx.buildFilesFor(path)) files.add(f.path)
544
+ const realBase = realpathSync(baseDir)
545
+ for (const rel of files) {
546
+ if (sources.has(rel)) continue
547
+ const buf = readFileWithinBase(baseDir, realBase, rel, boundaryOf(rel, cargoCtx))
548
+ if (buf === null) continue
549
+ cargoCtx.checkVendoredFile(rel, buf)
550
+ if (!isUtf8(buf)) throw new Error(`Rust manifest is not valid UTF-8: ${rel}`)
551
+ sources.set(rel, buf.toString('utf8'))
552
+ formats.set(rel, 'resource')
553
+ }
554
+ }
555
+ const { resolutions, missing, unresolvedCrates } = tree
448
556
 
449
- const issues = []
450
- for (const entry of normalized) {
451
- if (!sources.has(entry)) issues.push(`Missing entry: ${entry}`)
557
+ assertSelfContained('Rust', 'module', normalized, sources, missing)
558
+
559
+ // Where the features the cfg decisions rest on come from: cargo's resolver, or -- something it
560
+ // takes missing -- a replay of the manifests, said so (a project without Cargo has neither).
561
+ const resolution = cargoCtx.resolution()
562
+ if (resolution.mode === 'replay' && normalized.some((e) => cargoCtx.packageInfo(e) !== null)) {
563
+ console.warn(`[stasis] Rust features from a replay of the manifests, not cargo's resolver: ${resolution.why}`)
452
564
  }
453
- for (const { spec, from } of missing) {
454
- issues.push(`Unresolved module: ${spec} from ${from}`)
565
+ // EXODUS_STASIS_DEBUG=1: show the feature resolution the cfg decisions came from, per package and
566
+ // context, so the replay, cargo's resolver and `cargo metadata` (which unifies dev/build deps
567
+ // like resolver 1) can be compared.
568
+ if (process.env.EXODUS_STASIS_DEBUG === '1' || process.env.EXODUS_STASIS_DEBUG === 'true') {
569
+ const mode = { cargo: "cargo's resolver", metadata: 'cargo metadata', replay: 'manifest replay' }[resolution.mode]
570
+ for (const context of ['target', 'host']) {
571
+ const resolved = [...cargoCtx.resolvedFeatures(context)].toSorted(([a], [b]) => (a < b ? -1 : 1))
572
+ console.warn(`[stasis] Rust features (${mode}, ${context}), ${resolved.length} package${resolved.length === 1 ? '' : 's'}:`)
573
+ for (const [dir, set] of resolved) {
574
+ const pkg = cargoCtx.packageInfo(moduleFileKey(dir, 'Cargo.toml'))
575
+ console.warn(`[stasis] ${pkg?.name ?? '?'}@${pkg?.version ?? '?'} (${dir}): ${[...set].toSorted().join(', ') || '(none)'}`)
576
+ }
577
+ }
455
578
  }
456
- if (issues.length > 0) {
457
- throw new Error(`Rust bundle has unresolved modules:\n${issues.map((s) => ` ${s}`).join('\n')}`)
579
+
580
+ // A crate the code names that the bundle doesn't hold: a registry dependency that isn't vendored
581
+ // (or not in a version its requirement allows), a path or patch outside the bundle root (the
582
+ // loader warned of those), or a crate root the walk refused -- and any other dependency a bundled
583
+ // package's build links that nothing in-tree answers, however the code names it, if at all.
584
+ // Reported whatever the vendor dir holds; with none, the fix is one command.
585
+ const notLoaded = tree.wantedRoots.filter((r) => !sources.has(r)).map((r) => `${cargoCtx.packageInfo(r)?.name ?? r} (${r})`)
586
+ const bundledPackages = new Set([...sources.keys()].map((p) => cargoCtx.packageInfo(p)?.dir).filter((d) => d !== undefined))
587
+ const lacking = cargoCtx.lackingDependencies(bundledPackages, { buildScripts: cargoManifests })
588
+ .filter((d) => !unresolvedCrates.has(d.key))
589
+ .map((d) => `${d.name} (a dependency of ${d.from})`)
590
+ if (unresolvedCrates.size > 0 || notLoaded.length > 0 || lacking.length > 0) {
591
+ const names = [...new Set([...unresolvedCrates, ...notLoaded, ...lacking])].toSorted()
592
+ const shown = names.slice(0, 10).join(', ') + (names.length > 10 ? `, ... and ${names.length - 10} more` : '')
593
+ console.warn(`[stasis] ${names.length} crate${names.length === 1 ? '' : 's'} referenced but not in the bundle: ${shown}`)
594
+ if (!existsSync(join(baseDir, cargoCtx.vendorDir))) console.warn('[stasis] Registry dependencies are bundled only when vendored in-tree: run `cargo vendor` first.')
458
595
  }
459
596
 
460
597
  return assembleCodeBundle({
@@ -465,30 +602,16 @@ export async function buildRustBundle({ cwd = process.cwd(), entries } = {}) {
465
602
  workspaceName: RUST_WORKSPACE_NAME,
466
603
  workspaceVersion: RUST_WORKSPACE_VERSION,
467
604
  format: RUST_FORMAT,
605
+ formats,
468
606
  conditionKey: 'rust',
469
- classifyDep: makeRustClassifier(baseDir),
607
+ classifyDep: makeRustClassifier(cargoCtx),
470
608
  })
471
609
  }
472
610
 
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.
611
+ // Build an in-memory Bundle from entry .php files by statically scanning the
612
+ // require/include graph (and Composer's class-autoload graph when present); no PHP is
613
+ // executed. Autoload resolution is best-effort; explicit require/include of a literal path
614
+ // is strict, and a missing entry or unresolved explicit include is fatal.
492
615
  export async function buildPhpBundle({ cwd = process.cwd(), entries } = {}) {
493
616
  if (!Array.isArray(entries) || entries.length === 0) {
494
617
  throw new Error('buildPhpBundle: at least one entry .php file is required')
@@ -500,58 +623,56 @@ export async function buildPhpBundle({ cwd = process.cwd(), entries } = {}) {
500
623
  const baseDir = resolve(cwd)
501
624
  const normalized = normalizeEntries(entries, cwd)
502
625
  const autoload = loadComposerAutoload(baseDir)
626
+ // The Composer packages, of composer.lock (vendor/composer/installed.json held identical to it
627
+ // where both are), or of installed.json alone: read once, before anything is scanned.
628
+ const packages = loadComposerPackages(baseDir)
503
629
 
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)
630
+ // Laravel auto-discovers service providers rather than referencing them statically; seed
631
+ // them as extra roots so their config/route/view files get bundled.
632
+ const providerRoots = loadLaravelProviderFiles(baseDir, autoload, packages)
508
633
 
509
634
  const sources = await collectPhpFilesFromDisk(baseDir, [...normalized, ...providerRoots], { autoload })
510
635
  const { resolutions, missing } = buildPhpTree(sources, { baseDir, autoload })
511
636
 
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
- }
637
+ assertSelfContained('PHP', 'import', normalized, sources, missing)
522
638
 
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
-
527
- const formats = new Map()
528
- for (const path of sources.keys()) formats.set(path, PHP_FORMAT)
639
+ return codeBundle({
640
+ baseDir,
641
+ entries: normalized,
642
+ sources,
643
+ // Group per Composer package (vendor/<pkg>), not the node_modules bucketizer.
644
+ modules: bucketizePhpSources(baseDir, sources, PHP_WORKSPACE_NAME, PHP_WORKSPACE_VERSION, packages),
645
+ resolutions,
646
+ format: PHP_FORMAT,
647
+ // PHP includes don't vary by Node condition; key edges under "php", not the JS wildcard "*".
648
+ conditionKey: 'php',
649
+ })
650
+ }
529
651
 
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.
532
- const importsForKey = new Map()
533
- for (const [parent, specMap] of resolutions) importsForKey.set(parent, specMap)
534
- const imports = new Map([['php', importsForKey]])
652
+ // Render a scanned file URL for diagnostics: project-relative when inside `baseDir`, else absolute.
653
+ // The scan issue checks run on the LIVE scan, whose files/imports are keyed by absolute file: URLs
654
+ // (the resolver returns absolute paths); relativizing here matches the project-relative paths the
655
+ // bundle itself stores (toRel), rather than leaking the machine's absolute paths into the message.
656
+ function displayPath(url, baseDir) {
657
+ const abs = fileURLToPath(url)
658
+ const rel = relative(baseDir, abs)
659
+ return rel && !relativeEscapes(rel) ? rel : abs
660
+ }
535
661
 
536
- return new Bundle({
537
- config: { scope: 'full' },
538
- entries: new Set(normalized),
539
- modules,
540
- formats,
541
- imports,
542
- })
662
+ // One unresolved scan edge for diagnostics, naming the file --typescript would resolve it to when
663
+ // the scan found one (Scan#typescriptCandidate).
664
+ function describeUnresolved(u, show) {
665
+ const typescript = u.typescriptURL ? `; resolves to ${show(u.typescriptURL)} under --typescript` : ''
666
+ return `${u.kind} ${u.spec ?? '<dynamic>'} from ${show(u.parentURL)} (${u.reason}${typescript})`
543
667
  }
544
668
 
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.
669
+ // Classify scanner unresolved edges + parse errors into fatal (broken/divergent at load)
670
+ // vs tolerated (a catchable runtime miss). Shared so the plain and --mainFields JS paths
671
+ // gate identically. `baseDir` relativizes the paths in the emitted messages (see displayPath).
672
+ function analyzeScanner(scanner, { baseDir }) {
673
+ const show = (url) => displayPath(url, baseDir)
674
+ // Static ESM import/export-from edges load eagerly before user code, so a failure there is
675
+ // uncatchable (fatal); misses below a require()/dynamic-import() boundary are catchable (warn).
555
676
  const staticKinds = new Set(['import', 'export-from'])
556
677
  const staticReachable = new Set(scanner.entries)
557
678
  const walk = [...scanner.entries]
@@ -566,20 +687,19 @@ function analyzeScanner(scanner) {
566
687
  const fatalUnresolved = (u) => staticKinds.has(u.kind) && staticReachable.has(u.parentURL)
567
688
  const fatal = scanner.unresolved
568
689
  .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.
690
+ .map((u) => `unresolved ${describeUnresolved(u, show)}`)
691
+ // Fatal parse: an eagerly-linked module-family file, or any file the parser couldn't process.
572
692
  const fatalParse = (p) => staticReachable.has(p.url) && (p.format?.startsWith('module') === true || !p.recovered)
573
693
  for (const p of scanner.parseErrors) {
574
- if (fatalParse(p)) fatal.push(`parse error in ${fileURLToPath(p.url)}: ${p.message}`)
694
+ if (fatalParse(p)) fatal.push(`parse error in ${show(p.url)}: ${p.message}`)
575
695
  }
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.
696
+ // Edge resolving to a file a source bundle can't carry (.node/.wasm/extensionless): scan
697
+ // records it but never queues the child, so load would die.
578
698
  for (const [, byParent] of scanner.imports) {
579
699
  for (const [parentURL, specMap] of byParent) {
580
700
  for (const [spec, childURL] of specMap) {
581
701
  if (!scanner.files.has(childURL)) {
582
- fatal.push(`${spec} from ${fileURLToPath(parentURL)} resolves to ${fileURLToPath(childURL)}, which a source bundle can't carry`)
702
+ fatal.push(`${spec} from ${show(parentURL)} resolves to ${show(childURL)}, which a source bundle can't carry`)
583
703
  }
584
704
  }
585
705
  }
@@ -589,69 +709,58 @@ function analyzeScanner(scanner) {
589
709
  return { fatal, tolerated, toleratedParse }
590
710
  }
591
711
 
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 = '' } = {}) {
712
+ // Throw on fatal scan issues; warn on tolerated ones. `label` tags the scan pass; `baseDir`
713
+ // relativizes the paths in the warnings (see displayPath), matching analyzeScanner's messages.
714
+ function reportScanIssues({ fatal, tolerated, toleratedParse }, { label = '', baseDir }) {
595
715
  const where = label ? ` (${label})` : ''
716
+ const show = (url) => displayPath(url, baseDir)
717
+ // The first 10 of `items` as indented lines (`line` renders one), then how many more there are.
718
+ const listed = (items, line = (s) => s) => {
719
+ const more = items.length > 10 ? `\n ... and ${items.length - 10} more` : ''
720
+ return items.slice(0, 10).map((x) => ` ${line(x)}`).join('\n') + more
721
+ }
596
722
  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}`)
723
+ throw new Error(`JS bundle would be broken at load time${where}:\n${listed(fatal)}`)
600
724
  }
601
725
  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}`)
726
+ const summary = listed(tolerated, (u) => describeUnresolved(u, show))
727
+ console.warn(`[stasis] Bundle has ${tolerated.length} unresolved import(s)${where}; they will fall through at load time:\n${summary}`)
608
728
  }
609
729
  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}`)
730
+ const summary = listed(toleratedParse, (p) => `${show(p.url)}: ${p.message}`)
731
+ console.warn(`[stasis] Bundle has ${toleratedParse.length} file(s) with parse errors${where}; their recorded imports may be incomplete:\n${summary}`)
616
732
  }
617
733
  }
618
734
 
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 = [] } = {}) {
735
+ // Normalize conditions (trim, drop empties) so a sloppy programmatic caller can't push a bogus
736
+ // token into the resolver.
737
+ const cleanConditions = (conditions) => conditions.map((c) => (typeof c === 'string' ? c.trim() : c)).filter(Boolean)
738
+
739
+ // --typescript honours tsconfig `paths` aliases: an explicit --tsconfig's for every file (it must
740
+ // exist), else those of the tsconfig.json beside each importing file's package.json
741
+ // (packageTsconfigPaths, the project read as the State reads it, to `env`'s PROJECT_CWD; null
742
+ // matcher = no aliases). Without --typescript they only hint a miss (Scan#typescriptCandidate), so a
743
+ // config that doesn't load is none.
744
+ function typescriptPathsFor(typescript, baseDir, tsconfig, host, env) {
745
+ if (typescript && tsconfig !== undefined) return loadTsconfigPaths(discoverTsconfig(baseDir, tsconfig, host), host)
746
+ return packageTsconfigPaths(baseDir, { host, lenient: !typescript, projectCwd: env.PROJECT_CWD })
747
+ }
748
+
749
+ // Build a JS/TS Bundle (in-memory) by statically scanning the require/import graph; no
750
+ // user code is executed, and TS is stored verbatim (Node strips types at load). Scope comes
751
+ // from stasis.config.json / `EXODUS_STASIS_SCOPE` unless `scope` overrides. `conditions` are
752
+ // extra `exports`/`imports` conditions; on their own they don't honour legacy mainFields or
753
+ // platform suffixes (see `--mainFields` / buildResolvedJsBundle). `typescript` maps a failed
754
+ // resolution to its on-disk TS source (tsc's rules; see resolve-typescript.js), honouring the
755
+ // `paths` aliases of `tsconfig` (an explicit config path, default each package's tsconfig.json); it
756
+ // is taken as given where the entries import TS sources by output name (importsTypescriptByOutputName).
757
+ // Files are read through `host` (@exodus/stasis-core/host), the disk by default; EXODUS_STASIS_*
758
+ // settings from `env`. With `innermostRoot`, the State is rooted at the innermost package at or
759
+ // above cwd that holds every file the scan reaches (innermostRootOf), rather than at the project's
760
+ // root above it, where that holds no stasis file of its own.
761
+ export async function buildJsBundle({ cwd = process.cwd(), env = process.env, entries, scope, conditions = [], jsx = false, flow = false, typescript = false, tsconfig, resources = [], packageJSON = false, host = diskHost, innermostRoot = false } = {}) {
653
762
  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')
763
+ throw new Error('buildJsBundle: at least one entry .js/.cjs/.mjs/.ts/.cts/.mts/.jsx/.tsx file is required')
655
764
  }
656
765
  for (const e of entries) {
657
766
  if (!JS_EXTS.has(extname(e))) throw new Error(`buildJsBundle: not a JS/TS file: ${e}`)
@@ -660,126 +769,243 @@ export async function buildJsBundle({ cwd = process.cwd(), entries, scope, condi
660
769
  const baseDir = resolve(cwd)
661
770
  const absEntries = entries.map((e) => resolve(baseDir, e))
662
771
 
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 } : {}) })
772
+ const scanConditions = cleanConditions(conditions)
773
+ // --resources: extensions/filenames carried as opaque assets instead of failing "can't carry".
774
+ const resourceSet = parseResourcesOption('buildJsBundle', resources)
775
+
776
+ typescript ||= importsTypescriptByOutputName(absEntries, host)
777
+ const typescriptPaths = typescriptPathsFor(typescript, baseDir, tsconfig, host, env)
778
+
779
+ const scanner = scan(absEntries, { conditions: scanConditions, jsx, flow, typescript, typescriptPaths, resources: resourceSet, host })
780
+
781
+ // Fail closed where the bundle is guaranteed broken at load; warn on catchable misses (see analyzeScanner).
782
+ reportScanIssues(analyzeScanner(scanner, { baseDir }), { baseDir })
783
+
784
+ // Materialise via a non-preload State: addFile bucketizes + records sources/formats,
785
+ // addImport replays the edge map; serialize emits the runtime loader's v1 layout.
786
+ // bundle:'replace' skips reading any on-disk stasis.code.br (bundle:'add' would leak stale
787
+ // entries); lock:'ignore' tolerates a pre-existing lockfile without consuming it; and it is never
788
+ // written, so it claims no write target.
789
+ const stateOptions = { bundle: 'replace', lock: 'ignore', ...(scope ? { scope } : {}), host, claim: false }
790
+ let state = new State(baseDir, { ...stateOptions, env })
791
+ // A root holding a stasis file is the project's own choice, whose config the build takes.
792
+ if (innermostRoot && !STASIS_ROOT_FILES.some((file) => host.stat(join(state.root, file)) !== null)) {
793
+ const root = innermostRootOf(baseDir, [...scanner.files.keys()].map((url) => host.realpath(fileURLToPath(url))), state.root, host)
794
+ // The State's walk up from cwd stops at PROJECT_CWD, as it does where yarn sets it.
795
+ if (root !== state.root) state = new State(baseDir, { ...stateOptions, env: { ...env, PROJECT_CWD: root } })
796
+ }
797
+ // Named here rather than as the State's bare assertion: a stasis file in a directory below the
798
+ // project's root (a stray stasis.code.br there, say) roots the State at it, out of the files above.
799
+ for (const url of scanner.files.keys()) {
800
+ const file = fileURLToPath(url)
801
+ if (!relativeEscapes(relative(state.root, file))) continue
802
+ const markers = STASIS_ROOT_FILES.filter((name) => host.stat(join(state.root, name)) !== null)
803
+ const rootedBy = markers.length === 0 ? '' : ` (rooted there by its ${markers.join(' and ')}, which can be removed if stale)`
804
+ throw new Error(`buildJsBundle: ${relative(baseDir, file)} is outside the project root at ${relative(baseDir, state.root) || '.'}${rootedBy}`)
805
+ }
695
806
  for (const [url, info] of scanner.files) {
807
+ // A resource carries bytes only: addFile derives resource vs resource:base64 from the content
808
+ // and stores it under `resources` (a resource can't be an entry, so no isEntry).
809
+ if (info.resource) {
810
+ state.addFile(url, { resource: true })
811
+ continue
812
+ }
696
813
  const isEntry = scanner.entries.has(url)
697
814
  state.addFile(url, { format: info.format, isEntry })
698
815
  }
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) {
816
+ // Edges where every context agrees keep the wildcard '*' key (getImport's fallback when a
817
+ // condition lookup misses). Where the require()- and import()-context resolutions of one
818
+ // (parent, specifier) DIVERGE, each target keeps its real condition key -- one '*' entry
819
+ // would serve one context the other's file; an unmatched set fails closed via resolveBundled.
820
+ const byParent = new Map()
821
+ for (const [key, parents] of scanner.imports) {
822
+ for (const [parentURL, specs] of parents) {
823
+ if (!byParent.has(parentURL)) byParent.set(parentURL, new Map())
824
+ const bySpec = byParent.get(parentURL)
706
825
  for (const [spec, childURL] of specs) {
707
- state.addImport(parentURL, spec, childURL, { conditions: '*' })
826
+ if (!bySpec.has(spec)) bySpec.set(spec, new Map())
827
+ bySpec.get(spec).set(key, childURL)
828
+ }
829
+ }
830
+ }
831
+ for (const [parentURL, bySpec] of byParent) {
832
+ for (const [spec, byKey] of bySpec) {
833
+ const targets = new Set(byKey.values())
834
+ if (targets.size === 1) {
835
+ state.addImport(parentURL, spec, [...targets][0], { conditions: '*' })
836
+ } else {
837
+ for (const [key, childURL] of byKey) {
838
+ state.addImport(parentURL, spec, childURL, { conditions: key.split(', ') })
839
+ }
708
840
  }
709
841
  }
710
842
  }
843
+ // --package-json: fold each bundled module's package.json into the State's buckets (idempotent --
844
+ // no-ops any manifest the scan already reached). State's bundle=replace makes writeBundle true, so addFile accepts them.
845
+ if (packageJSON) state.includePackageJson()
711
846
  return state
712
847
  }
713
848
 
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).
849
+ // The files that root a State in the directory holding one (state.js's discovery).
850
+ const STASIS_ROOT_FILES = ['stasis.config.json', 'stasis.lock.json', DEFAULT_BUNDLE_FILE]
851
+
852
+ // The innermost directory at or above `baseDir`, up to `root`, holding every one of `files` (real
853
+ // paths) and a package.json with a name, which a State can be rooted at; `root` where none below it
854
+ // does. One without a name, such as a `{"type":"module"}` marker, is no root: the State takes such a
855
+ // file's package to be the named one above it, which it then must hold.
856
+ function innermostRootOf(baseDir, files, root, host) {
857
+ for (let dir = baseDir; dir !== root && dirname(dir) !== dir; dir = dirname(dir)) {
858
+ if (files.every((file) => isPathWithin(dir, file)) && readPackageJson(dir, 'package.json', { host })?.name !== undefined) return dir
859
+ }
860
+ return root
861
+ }
862
+
863
+ // Extensions probed when a resolved target names none, in Metro's default sourceExts order.
864
+ // Limited to what a source bundle can carry (scan's RESOLVABLE_EXTS) so the resolver never
865
+ // resolves a file the bundle would reject; an extensionless `import './Foo'` can land on
866
+ // Foo.jsx/Foo.tsx, as it does under Metro. Like Metro's default, this doesn't track a project's
867
+ // own sourceExts.
868
+ const SOURCE_EXTS = ['js', 'jsx', 'json', 'ts', 'tsx']
869
+ // React Native preset mainFields for `--metro` (which also sets the RN conditions + platform suffixes).
723
870
  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 }) {
871
+ // What esbuild and webpack 5 both assert on every platform besides `import`/`require` and
872
+ // `default`, and so `--mainFields` too: `module`, the bundler-only condition a package's ESM build
873
+ // sits under, from an import and a require alike. (esbuild drops it when given conditions of its
874
+ // own; webpack keeps it, as `--conditions` adds to it here.) Node and Metro never assert it.
875
+ const BUNDLER_CONDITIONS = ['module']
876
+
877
+ // Recursively collect the files under `dirAbs` whose name `keep` takes, skipping build output,
878
+ // symlinks (cycle/escape hazard) and, directly in `dirAbs`, the dirs `skipAtRoot` names. Absolute
879
+ // paths into `out`.
880
+ function walkNative(dirAbs, out, host, keep, skipAtRoot) {
881
+ let entries
882
+ try {
883
+ entries = host.readdir(dirAbs)
884
+ } catch {
885
+ return // absent -- nothing for this platform
886
+ }
887
+ for (const ent of entries) {
888
+ if (ent.isSymbolicLink()) continue
889
+ const full = join(dirAbs, ent.name)
890
+ if (ent.isDirectory()) {
891
+ if (!isSkippedNativeWalkDir(ent.name) && !skipAtRoot?.(ent.name)) walkNative(full, out, host, keep)
892
+ } else if (ent.isFile() && keep(ent.name)) {
893
+ out.push(full)
894
+ }
895
+ }
896
+ }
897
+
898
+ // The files of a native ios/android dir: all but build artifacts.
899
+ const isNativeSource = (name) => !isNativeArtifact(name)
900
+
901
+ // Native source files a bundled RN dep contributes to the app's native build (podspecs +
902
+ // ios/android sources). Manifests are kept only when the package is actually native (podspec
903
+ // or ios/android dir), else a JS-only dep's package.json would be pulled in. Deduped absolute paths.
904
+ function nativeModuleFiles(pkgAbs, host) {
905
+ // Podspec-load manifests: RN's own podspecs live in scattered subdirs a root-only scan would miss,
906
+ // so recurse fully.
907
+ const manifests = []
908
+ walkNative(pkgAbs, manifests, host, isNativeManifest, isExcludedNativeDir)
909
+ const hasIos = host.stat(join(pkgAbs, 'ios')) !== null
910
+ const hasAndroid = host.stat(join(pkgAbs, 'android')) !== null
911
+ if (!hasIos && !hasAndroid && !manifests.some((f) => isPodspec(f))) return []
912
+ const out = [...manifests]
913
+ if (hasIos) walkNative(join(pkgAbs, 'ios'), out, host, isNativeSource)
914
+ if (hasAndroid) walkNative(join(pkgAbs, 'android'), out, host, isNativeSource)
915
+ return [...new Set(out)]
916
+ }
917
+
918
+ // The built-in field/suffix resolver (resolve-fields.js) the legacy-field build resolves with on
919
+ // `platform` (null for --mainFields), its `mainFields` (Metro's under --metro), and the conditions
920
+ // it adds to the bundler base (`import`/`require` + `default`, never Node's `node`; see
921
+ // resolveConditions): --metro asserts the RN conditions (+ browser on web), Metro's own set;
922
+ // --mainFields asserts esbuild's and webpack's `module` (BUNDLER_CONDITIONS) and the user's --conditions.
923
+ export function fieldResolverFor(platform, { mainFields, metro = false, conditions = [], typescript = false, typescriptPaths = null, host = diskHost }) {
924
+ const extras = metro ? ['react-native', ...(platform === 'web' ? ['browser'] : [])] : [...new Set([...BUNDLER_CONDITIONS, ...conditions])]
925
+ const fields = metro ? METRO_MAIN_FIELDS : mainFields
926
+ const resolver = createFieldResolver({
927
+ mainFields: fields,
928
+ platform,
929
+ preferNative: platform !== null && platform !== 'web',
930
+ sourceExts: SOURCE_EXTS,
931
+ conditions: resolveConditions('commonjs', extras),
932
+ // Opt into Metro's package-entry browser-field quirks only on the --metro path.
933
+ metro,
934
+ // --typescript: tsc's mapping, inside the field resolver (the scanner's own fallback
935
+ // only backs the built-in resolver). Unreachable under --metro-resolver
936
+ // (classifyEntries rejects the combination -- metro-resolver can't substitute).
937
+ typescript,
938
+ typescriptPaths,
939
+ host,
940
+ })
941
+ return { extras, mainFields: fields, resolver }
942
+ }
943
+
944
+ // Build a JS/TS Bundle + companion Lockfile via the legacy-field resolver (`--mainFields`/
945
+ // `--metro`). Scanned once per platform; each edge is recorded flat when the platforms that
946
+ // have it agree, or as a `{ platform: target }` map where they diverge. Returns { bundle, lockfile }.
947
+ async function buildResolvedJsBundle({ cwd = process.cwd(), env = process.env, entries, mainFields, platforms, conditions = [], metro = false, metroResolver = false, jsx = false, flow = false, typescript = false, tsconfig, resources = [], packageJSON = false, host = diskHost }) {
741
948
  const baseDir = resolve(cwd)
742
949
  const absEntries = entries.map((e) => resolve(baseDir, e))
743
950
  const normalized = normalizeEntries(entries, cwd)
744
951
 
952
+ // A resolved file arrives by its real path, an entry as spelled: under a cwd named through a link,
953
+ // the files the scan reaches lie below the root's real path, not below `baseDir` as spelled. Either
954
+ // is the root; a file below neither is outside it. (Realpathed only when needed, so a missing cwd
955
+ // is still the scan's missing entry.)
956
+ let realRoot
745
957
  const toRel = (abs) => {
746
- const rel = relative(baseDir, abs).split(/[\\/]/u).join('/')
747
- if (rel.startsWith('..') || isAbsolute(rel)) {
958
+ let rel = toPosix(relative(baseDir, abs))
959
+ if (relativeEscapes(rel)) {
960
+ realRoot ??= host.realpath(baseDir)
961
+ rel = toPosix(relative(realRoot, abs))
962
+ }
963
+ if (relativeEscapes(rel)) {
748
964
  throw new Error(`Bundle would reach a file outside the project root: ${abs}`)
749
965
  }
750
966
  return rel
751
967
  }
752
968
 
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)
969
+ const scanConditions = cleanConditions(conditions)
970
+
971
+ // Taken as given where the entries import TS sources by output name, but under --metro-resolver,
972
+ // which has no tsc mapping (classifyEntries rejects --typescript there).
973
+ if (!metroResolver) typescript ||= importsTypescriptByOutputName(absEntries, host)
974
+ // --typescript's tsconfig `paths` matcher, shared by every per-platform resolver below.
975
+ const typescriptPaths = typescriptPathsFor(typescript, baseDir, tsconfig, host, env)
976
+ // --resources: extensions/filenames carried as opaque assets instead of failing "can't carry".
977
+ const resourceSet = parseResourcesOption('buildResolvedJsBundle', resources)
756
978
 
757
979
  const formatsByRel = new Map()
758
- // parentRel -> specifier -> Map<platformKey, targetRel>. Collapsed into flat /
759
- // platform-map edges after every platform has been scanned.
980
+ // Reached files the scanner tagged as resources (rel keys); read as bytes below, not UTF-8 source.
981
+ const resourceRels = new Set()
982
+ // parentRel -> specifier -> Map<platformKey, targetRel>; collapsed after all platforms scanned.
760
983
  const edges = new Map()
761
- const reached = new Set() // absolute paths of every real file reached on any platform
984
+ const reached = new Set() // absolute paths reached on any platform
762
985
  let usesEmpty = false
763
986
 
764
987
  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' })
988
+ const field = fieldResolverFor(platform, { mainFields, metro, conditions: scanConditions, typescript, typescriptPaths, host })
989
+ const { extras } = field
990
+ // --metro --metro-resolver delegates to the project's own metro-resolver for byte-for-byte Metro
991
+ // fidelity; otherwise the built-in field/suffix resolver reproduces it. metro-resolver derives
992
+ // default/require|import/platform conditions itself, so it takes only the extra `react-native`
993
+ // condition (browser comes from its per-platform map, keyed on `web`).
994
+ const resolver = metroResolver
995
+ ? createMetroResolver({ projectDir: baseDir, platform, sourceExts: SOURCE_EXTS, mainFields, conditionNames: ['react-native'], host })
996
+ : field.resolver
997
+ // Without --typescript, the field resolver --typescript would build names the file a miss would
998
+ // land on: a hint in the unresolved report, never an edge (see Scan#typescriptCandidate).
999
+ const typescriptResolve = typescript || metroResolver ? null : fieldResolverFor(platform, { mainFields, metro, conditions: scanConditions, typescript: true, typescriptPaths, host }).resolver
1000
+ const scanner = scan(absEntries, { conditions: extras, resolve: resolver, typescriptResolve, jsx, flow, resources: resourceSet, host })
1001
+ reportScanIssues(analyzeScanner(scanner, { baseDir }), { baseDir, label: platform ?? 'mainFields' })
778
1002
 
779
1003
  const platformKey = platform ?? '*' // '*' is a private placeholder for the single mainFields pass; it never unflattens
780
1004
  for (const [url, info] of scanner.files) {
781
- const rel = toRel(fileURLToPath(url))
782
- reached.add(fileURLToPath(url))
1005
+ const abs = fileURLToPath(url)
1006
+ const rel = toRel(abs)
1007
+ reached.add(abs)
1008
+ if (info.resource) resourceRels.add(rel)
783
1009
  formatsByRel.set(rel, info.format)
784
1010
  for (const e of info.edges) {
785
1011
  if (e.builtin || e.dynamic) continue
@@ -795,38 +1021,187 @@ async function buildResolvedJsBundle({ cwd = process.cwd(), entries, mainFields,
795
1021
  }
796
1022
  }
797
1023
 
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.
1024
+ // Read each reached file once: bytes for the bundle, integrity for the lockfile. Source is
1025
+ // stored as UTF-8 text, so non-UTF-8 bytes would diverge from the hashed bytes -- reject them.
802
1026
  const sources = new Map()
803
1027
  const integrities = new Map()
804
- const realBase = realpathSync(baseDir)
1028
+ // Carry `rel`: `content` in the bundle as `format`, the integrity of its bytes `buf` in the lockfile.
1029
+ const put = (rel, content, format, buf) => {
1030
+ sources.set(rel, content)
1031
+ formatsByRel.set(rel, format)
1032
+ integrities.set(rel, sha512integrity(buf))
1033
+ }
1034
+ // A resource carries bytes, not source: UTF-8 verbatim or base64 when binary, tagged with the
1035
+ // byte-derived format (here and in the --metro native capture below).
1036
+ const putResource = (rel, buf) => (isUtf8(buf) ? put(rel, buf.toString('utf8'), 'resource', buf) : put(rel, buf.toString('base64'), 'resource:base64', buf))
1037
+ const realBase = host.realpath(baseDir)
805
1038
  for (const abs of reached) {
806
1039
  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))
1040
+ // Security: a resolved file arrives by its real path, which toRel already holds to the root,
1041
+ // but an entry is taken as spelled (and --metro-resolver keeps a path it can't realpath), so an
1042
+ // in-tree-named symlink escaping the root would slip past toRel's textual check -- realpath and
1043
+ // fail closed.
1044
+ assertRealPathWithinBase(realBase, baseDir, rel, host)
1045
+ const buf = host.readFile(abs)
1046
+ if (resourceRels.has(rel)) {
1047
+ putResource(rel, buf)
1048
+ } else {
1049
+ if (!isUtf8(buf)) throw new Error(`JS bundle source is not valid UTF-8: ${rel}`)
1050
+ // Its format is the scan's.
1051
+ put(rel, buf.toString('utf8'), formatsByRel.get(rel), buf)
1052
+ }
816
1053
  }
817
1054
  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.
1055
+ // Refuse to shadow a real reached file sitting at the reserved empty-module path.
820
1056
  if (formatsByRel.has(EMPTY_MODULE_PATH)) {
821
1057
  throw new Error(`Bundle needs the reserved empty-module path ${EMPTY_MODULE_PATH}, but the project has a real file there`)
822
1058
  }
823
- sources.set(EMPTY_MODULE_PATH, '')
824
- formatsByRel.set(EMPTY_MODULE_PATH, 'commonjs')
825
- integrities.set(EMPTY_MODULE_PATH, sha512integrity(Buffer.alloc(0)))
1059
+ put(EMPTY_MODULE_PATH, '', 'commonjs', Buffer.alloc(0))
1060
+ }
1061
+
1062
+ // The package a bundled file is in (findPackageMetadata: its pkgDir and identity, null where no
1063
+ // manifest names one), memoized per directory so a package's many files don't each re-walk to it.
1064
+ const packageOf = packageLookup(baseDir, { host })
1065
+
1066
+ // --metro also carries each bundled dependency's native build-input surface (ios/android
1067
+ // sources + podspecs), scoped to the packages in the bundle installed or linked into
1068
+ // node_modules. Native source is stored as code under a language tag; other assets as
1069
+ // 'resource'/'resource:base64'.
1070
+ if (metro) {
1071
+ const pkgDirs = new Map() // package dir -> its package's name
1072
+ // Follow the CODE/module graph only: a package reached solely for an asset (--resources) is
1073
+ // not a linked native dependency, so it must not drag in its ios/android surface.
1074
+ const isCode = (rel) => !resourceRels.has(rel)
1075
+ for (const abs of reached) {
1076
+ const rel = toRel(abs)
1077
+ const nm = isCode(rel) && splitNodeModulesPath(rel)
1078
+ if (nm) pkgDirs.set(nm.dir, nm.name)
1079
+ }
1080
+ // A package linked into node_modules from out of it (a workspace package) is reached by its real
1081
+ // path, out of node_modules, but is a dependency all the same, however an import reaches it (by
1082
+ // name or alias, through a browser/react-native map, by a relative path): one linked, under any
1083
+ // name, into a node_modules its importer finds packages in (the directories the resolver looks
1084
+ // in). One linked nowhere there is the project's own source.
1085
+ const linksIn = new Map() // directory -> Set of the real package dirs linked into a node_modules it finds packages in
1086
+ const linkedFrom = (dir) => {
1087
+ let links = linksIn.get(dir)
1088
+ if (links !== undefined) return links
1089
+ const parent = dirname(dir)
1090
+ links = new Set(parent === dir ? [] : linkedFrom(parent))
1091
+ const link = (abs) => {
1092
+ try {
1093
+ const rel = toRel(host.realpath(abs))
1094
+ if (!splitNodeModulesPath(rel)) links.add(rel)
1095
+ } catch { /* a dangling link, or one out of the root, whose files the scan pass refused already */ }
1096
+ }
1097
+ const listing = (abs) => {
1098
+ try {
1099
+ return host.readdir(abs)
1100
+ } catch {
1101
+ return []
1102
+ }
1103
+ }
1104
+ // As locatePackage: a directory named node_modules holds no node_modules of its own to look in.
1105
+ if (basename(dir) !== 'node_modules') {
1106
+ const nodeModules = join(dir, 'node_modules')
1107
+ for (const ent of listing(nodeModules)) {
1108
+ const abs = join(nodeModules, ent.name)
1109
+ if (ent.name.startsWith('@')) {
1110
+ // A scope holds packages, a directory or a link to one (a whole scope linked, where
1111
+ // every package in it is reached through the link).
1112
+ for (const scoped of listing(abs)) {
1113
+ if (ent.isSymbolicLink() || scoped.isSymbolicLink()) link(join(abs, scoped.name))
1114
+ }
1115
+ } else if (ent.isSymbolicLink()) {
1116
+ link(abs)
1117
+ }
1118
+ }
1119
+ }
1120
+ linksIn.set(dir, links)
1121
+ return links
1122
+ }
1123
+ for (const [parent, bySpec] of edges) {
1124
+ for (const byPlatform of bySpec.values()) {
1125
+ for (const target of byPlatform.values()) {
1126
+ if (!isCode(target) || splitNodeModulesPath(target)) continue
1127
+ const links = linkedFrom(dirname(join(baseDir, parent)))
1128
+ // The linked root holding the target, nearest first: its entry may lie below a manifest of
1129
+ // its own (a named dist/package.json), as a node_modules path names the package it's in.
1130
+ let root = posix.dirname(target)
1131
+ while (root !== '.' && !links.has(root)) root = posix.dirname(root)
1132
+ // The project's own source isn't a dependency, nor is an import within the package itself.
1133
+ if (root === '.' || parent.startsWith(`${root}/`) || pkgDirs.has(root)) continue
1134
+ // Named by its own manifest, whatever name it's linked under.
1135
+ pkgDirs.set(root, readPackageJson(baseDir, `${root}/package.json`, { host })?.name)
1136
+ }
1137
+ }
1138
+ }
1139
+ for (const [pkgDir, name] of [...pkgDirs].toSorted(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {
1140
+ const pkgAbs = join(baseDir, pkgDir)
1141
+ // react-native core isn't a Pod (config reports it via reactNativePath, not `dependencies`),
1142
+ // so walk its whole tree for native source (React/, ReactCommon/, ReactAndroid/, ...) the same
1143
+ // way a native dep's ios/android surface is walked; every other dep gets its ios/android + podspecs.
1144
+ const isRnCore = name === 'react-native'
1145
+ const files = isRnCore ? [] : nativeModuleFiles(pkgAbs, host)
1146
+ if (isRnCore) walkNative(pkgAbs, files, host, isNativeSource)
1147
+ for (const abs of files) {
1148
+ const rel = toRel(abs)
1149
+ if (sources.has(rel)) continue
1150
+ assertRealPathWithinBase(realBase, baseDir, rel, host)
1151
+ // classifyNativeCapture (shared with the StasisMetro plugin) returns action skip/code/resource with a format tag.
1152
+ const byName = classifyNativeCapture(rel)
1153
+ if (byName.action === 'skip') continue
1154
+ const buf = host.readFile(abs)
1155
+ // Byte-level rules (prebuilt binaries, binary plists) -- see refineNativeCapture.
1156
+ const { action, format } = refineNativeCapture(byName, rel, buf, resourceSet)
1157
+ if (action === 'skip') continue
1158
+ if (action === 'code') {
1159
+ if (!isUtf8(buf)) throw new Error(`native source is not valid UTF-8: ${rel}`)
1160
+ put(rel, buf.toString('utf8'), format, buf)
1161
+ } else {
1162
+ putResource(rel, buf)
1163
+ }
1164
+ }
1165
+ // RN core's `.js` build scripts the classify loop skips (Metro-owned by extension, but never in
1166
+ // the graph): force-include as code so the podspec/Ruby that invokes them at pod-install resolves.
1167
+ if (isRnCore) {
1168
+ for (const file of RN_CORE_INCLUDE_FILES) {
1169
+ const abs = join(pkgAbs, file)
1170
+ if (host.stat(abs) === null) continue
1171
+ const rel = toRel(abs)
1172
+ if (sources.has(rel)) continue
1173
+ assertRealPathWithinBase(realBase, baseDir, rel, host)
1174
+ const buf = host.readFile(abs)
1175
+ if (!isUtf8(buf)) throw new Error(`native source is not valid UTF-8: ${rel}`)
1176
+ put(rel, buf.toString('utf8'), packageType(abs, host) === 'module' ? 'module' : 'commonjs', buf)
1177
+ }
1178
+ }
1179
+ }
1180
+ }
1181
+
1182
+ // --package-json: fold each bundled module's package.json into `sources` (and its integrity into
1183
+ // the companion lockfile) even when the scan never reached it. Buckets are the ones
1184
+ // assembleCodeBundle derives, from the same `packageOf` (its pkgDir, else the '.' workspace
1185
+ // bucket). readModuleManifest applies the read/validate rules shared with the State path
1186
+ // (containment, UTF-8-aborts); no identity check here -- these buckets are all fresh from disk.
1187
+ if (packageJSON) {
1188
+ const pkgDirs = new Set()
1189
+ for (const abs of reached) {
1190
+ const rel = toRel(abs)
1191
+ const meta = packageOf(rel)
1192
+ if (meta) pkgDirs.add(meta.pkgDir)
1193
+ else if (!splitNodeModulesPath(rel)) pkgDirs.add('.')
1194
+ }
1195
+ for (const pkgDir of pkgDirs) {
1196
+ const rel = moduleFileKey(pkgDir, 'package.json')
1197
+ if (sources.has(rel)) continue
1198
+ const buf = readModuleManifest({ baseDir, realBase, rel, host })
1199
+ if (!buf) continue
1200
+ put(rel, buf.toString('utf8'), 'json', buf)
1201
+ }
826
1202
  }
827
1203
 
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).
1204
+ // Collapse each edge: one distinct target across platforms -> flat string; else a sorted Map<platform, target>.
830
1205
  const resolutions = new Map()
831
1206
  for (const [parent, bySpec] of edges) {
832
1207
  const specMap = new Map()
@@ -841,7 +1216,7 @@ async function buildResolvedJsBundle({ cwd = process.cwd(), entries, mainFields,
841
1216
  resolutions.set(parent, specMap)
842
1217
  }
843
1218
 
844
- const rootPkg = readJson(join(baseDir, 'package.json')) ?? {}
1219
+ const rootPkg = readJson(join(baseDir, 'package.json'), host) ?? {}
845
1220
  const bundle = assembleCodeBundle({
846
1221
  baseDir,
847
1222
  entries: normalized,
@@ -851,16 +1226,16 @@ async function buildResolvedJsBundle({ cwd = process.cwd(), entries, mainFields,
851
1226
  workspaceName: rootPkg.name ?? 'workspace',
852
1227
  workspaceVersion: rootPkg.version ?? '0.0.0',
853
1228
  conditionKey: '*',
1229
+ host,
1230
+ packageOf,
854
1231
  })
855
1232
 
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.
1233
+ // The companion lockfile mirrors the bundle, swapping file content for its integrity.
859
1234
  const lockModules = new Map()
860
1235
  for (const [dir, m] of bundle.modules) {
861
1236
  const files = Object.create(null)
862
1237
  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 })
1238
+ lockModules.set(dir, moduleInfo({ ...m, files }))
864
1239
  }
865
1240
  const lockfile = new Lockfile({
866
1241
  config: bundle.config,
@@ -868,52 +1243,91 @@ async function buildResolvedJsBundle({ cwd = process.cwd(), entries, mainFields,
868
1243
  modules: lockModules,
869
1244
  imports: bundle.imports,
870
1245
  formats: bundle.formats,
1246
+ // Same file set as the bundle, so the same executable list attests it.
1247
+ executable: bundle.executable,
871
1248
  })
872
1249
 
873
1250
  return { bundle, lockfile }
874
1251
  }
875
1252
 
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 }) {
1253
+ // Classify entries into their single shared language and check option applicability; `name` prefixes
1254
+ // errors. A directory entry (resolved against `cwd`, on `host`) stands for the .sol files under it: Solidity only.
1255
+ function classifyEntries(name, { cwd = process.cwd(), entries, mappingFile, manifests, scope, lockfile, conditions, mainFields, platforms, metro, metroResolver, jsx, flow, typescript, tsconfig, resources, packageJSON, cargo, cargoFeatures, cargoNoDefaultFeatures, cargoAllFeatures, cargoTarget, cargoManifests, host = diskHost, fetched }) {
880
1256
  if (!Array.isArray(entries) || entries.length === 0) {
881
1257
  throw new Error(`${name}: at least one entry file is required`)
882
1258
  }
1259
+ const dirError = directoryEntryError(entries, cwd, host, { fetched })
1260
+ if (dirError !== null) throw new Error(`${name}: ${dirError}`)
883
1261
  let kind
884
- if (entries.every((e) => e.endsWith('.sol'))) kind = 'sol'
1262
+ if (entries.every((e) => isSolidityEntry(e, cwd, host))) kind = 'sol'
885
1263
  else if (entries.every((e) => e.endsWith('.php'))) kind = 'php'
886
1264
  else if (entries.every((e) => JS_EXTS.has(extname(e)))) kind = 'js'
887
1265
  else if (entries.every((e) => BASH_EXTS.has(extname(e)))) kind = 'bash'
888
1266
  else if (entries.every((e) => RUST_EXTS.has(extname(e)))) kind = 'rust'
889
1267
  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)`)
1268
+ throw new Error(`${name}: entries must all be .sol, all be .php, all be .js/.cjs/.mjs/.ts/.cts/.mts/.jsx/.tsx, all be .sh/.bash, or all be .rs (no mixing)`)
891
1269
  }
892
1270
  if (mappingFile && kind !== 'sol') {
893
1271
  throw new Error(`${name}: --mapping is only valid for .sol bundles`)
894
1272
  }
1273
+ // --manifests carries the Solidity build's description files (foundry.toml, remappings.txt, ...).
1274
+ if (manifests && kind !== 'sol') {
1275
+ throw new Error(`${name}: --manifests is only valid for .sol bundles`)
1276
+ }
1277
+ // --cargo runs `cargo metadata` for the Rust feature/dependency resolution and the --cargo-*
1278
+ // flags steer that resolution; nothing else reads Cargo.
1279
+ if (kind !== 'rust') {
1280
+ const given = { cargo, 'cargo-features': Array.isArray(cargoFeatures) && cargoFeatures.length > 0, 'cargo-no-default-features': cargoNoDefaultFeatures, 'cargo-all-features': cargoAllFeatures, 'cargo-target': cargoTarget, 'cargo-manifests': cargoManifests }
1281
+ for (const [flag, on] of Object.entries(given)) if (on) throw new Error(`${name}: --${flag} is only valid for Rust bundles`)
1282
+ }
895
1283
  if (scope !== undefined && kind !== 'js') {
896
1284
  throw new Error(`${name}: --scope is only valid for JS bundles`)
897
1285
  }
898
1286
  if (lockfile !== undefined && kind !== 'js') {
899
1287
  throw new Error(`${name}: --lockfile is only valid for JS bundles`)
900
1288
  }
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.
1289
+ // Reject --conditions for non-JS: their resolvers don't consult exports/imports conditions, so it would silently do nothing.
904
1290
  if (Array.isArray(conditions) && conditions.length > 0 && kind !== 'js') {
905
1291
  throw new Error(`${name}: --conditions is only valid for JS bundles`)
906
1292
  }
1293
+ // --flow strips Flow type syntax before the JS parser; the non-JS loaders never parse with oxc.
1294
+ if (flow && kind !== 'js') {
1295
+ throw new Error(`${name}: --flow is only valid for JS bundles`)
1296
+ }
1297
+ // --typescript maps JS-output specifiers to their on-disk TS sources; only the JS resolvers do it.
1298
+ if (typescript && kind !== 'js') {
1299
+ throw new Error(`${name}: --typescript is only valid for JS bundles`)
1300
+ }
1301
+ // --tsconfig names the config whose `paths` aliases --typescript honours; meaningless without it.
1302
+ if (tsconfig !== undefined && !typescript) {
1303
+ throw new Error(`${name}: --tsconfig is only valid with --typescript`)
1304
+ }
1305
+ // --resources carries allowlisted assets reached through the JS import graph; JS-only. The list
1306
+ // is validated (parseResourcesOption) in the builders, but reject a code extension here too so a
1307
+ // typo surfaces with the command name rather than deep in the scan.
1308
+ if (Array.isArray(resources) && resources.length > 0) {
1309
+ if (kind !== 'js') throw new Error(`${name}: --resources is only valid for JS bundles`)
1310
+ parseResourcesOption(name, resources)
1311
+ }
907
1312
  // --mainFields and --metro/--platforms are legacy-/platform-resolution knobs; JS-only.
908
1313
  if (mainFields !== undefined && kind !== 'js') {
909
1314
  throw new Error(`${name}: --mainFields is only valid for JS bundles`)
910
1315
  }
1316
+ // --jsx toggles the scanner's JSX parsing for the .js family (.jsx/.tsx need no flag); only JS
1317
+ // entries are scanned.
1318
+ if (jsx && kind !== 'js') {
1319
+ throw new Error(`${name}: --jsx is only valid for JS bundles`)
1320
+ }
1321
+ // --package-json folds each bundled module's npm package.json in; JS-only (the sol/php/bash/rust
1322
+ // bucketizers group by their own ecosystems' manifests, not an npm package.json).
1323
+ if (packageJSON && kind !== 'js') {
1324
+ throw new Error(`${name}: --package-json is only valid for JS bundles`)
1325
+ }
911
1326
  if ((metro || (Array.isArray(platforms) && platforms.length > 0)) && kind !== 'js') {
912
1327
  throw new Error(`${name}: --metro is only valid for JS bundles`)
913
1328
  }
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.
1329
+ // --metro presets conditions + mainFields + platform suffixes, so it can't combine with
1330
+ // explicit --conditions/--mainFields and requires --platforms; --platforms is meaningless without it.
917
1331
  if (metro) {
918
1332
  if (Array.isArray(conditions) && conditions.length > 0) {
919
1333
  throw new Error(`${name}: --conditions can't be combined with --metro (it sets its own conditions)`)
@@ -924,9 +1338,8 @@ function classifyEntries(name, { entries, mappingFile, scope, lockfile, conditio
924
1338
  if (!Array.isArray(platforms) || platforms.length === 0) {
925
1339
  throw new Error(`${name}: --metro requires --platforms (e.g. --platforms=ios,android)`)
926
1340
  }
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.
1341
+ // A platform name becomes an edge key: reject '/' (Bundle/Lockfile parse refuse it) and
1342
+ // the reserved '*' placeholder, so the writer never emits a map the readers reject.
930
1343
  for (const p of platforms) {
931
1344
  if (typeof p !== 'string' || p.length === 0 || p === '*' || p.includes('/')) {
932
1345
  throw new Error(`${name}: invalid platform '${p}' (a platform name can't be empty, contain '/', or be '*')`)
@@ -935,145 +1348,199 @@ function classifyEntries(name, { entries, mappingFile, scope, lockfile, conditio
935
1348
  } else if (Array.isArray(platforms) && platforms.length > 0) {
936
1349
  throw new Error(`${name}: --platforms is only valid with --metro`)
937
1350
  }
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.
1351
+ // --metro-resolver swaps in the project's real metro-resolver; it only means something under --metro.
1352
+ if (metroResolver && !metro) {
1353
+ throw new Error(`${name}: --metro-resolver is only valid with --metro`)
1354
+ }
1355
+ // metro-resolver has no TS extension substitution, so --typescript would silently not apply -- reject it.
1356
+ if (typescript && metroResolver) {
1357
+ throw new Error(`${name}: --typescript is not supported with --metro-resolver (the project's metro-resolver doesn't substitute .js -> .ts)`)
1358
+ }
1359
+ // The field resolver always emits full-scope, so --scope with --mainFields/--metro would be silently ignored -- reject it.
940
1360
  if (scope !== undefined && (mainFields !== undefined || metro)) {
941
1361
  throw new Error(`${name}: --scope is not supported with --mainFields or --metro`)
942
1362
  }
943
1363
  return kind
944
1364
  }
945
1365
 
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 })
1366
+ // A JS bundle as `stasis bundle` builds it: by the legacy-field resolver with `mainFields` or
1367
+ // `metro`, whose per-platform edges and synthetic empty module a State can't hold, else through a
1368
+ // State, which detects the bundle's repo itself. `root` is the directory the bundle's paths are
1369
+ // relative to: cwd for the resolver's, the State's root for the State's.
1370
+ // -> { bundle, lockfile: () => Lockfile, stateBuilt, root }
1371
+ async function buildJs({ mainFields, platforms, metro, metroResolver, innermostRoot, ...options }) {
972
1372
  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
1373
+ const built = await buildResolvedJsBundle({ ...options, mainFields: metro ? METRO_MAIN_FIELDS : mainFields, platforms: metro ? platforms : [null], metro: Boolean(metro), metroResolver: Boolean(metroResolver) })
1374
+ return { bundle: built.bundle, lockfile: () => built.lockfile, stateBuilt: false, root: resolve(options.cwd) }
1375
+ }
1376
+ const state = await buildJsBundle({ ...options, innermostRoot })
1377
+ // Stamp the `bundle` consumer (the static build carries none).
1378
+ return { bundle: state.sourceBundle.withReason('bundle'), lockfile: () => state.lockfile, stateBuilt: true, root: state.root }
985
1379
  }
986
1380
 
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
-
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 })
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
1049
- } else {
1050
- bundle = await buildBundle({ cwd, entries, mappingFile, scope, conditions })
1381
+ // The bundle of `kind` (classifyEntries') built from buildBundle's options, from disk: a JS bundle's
1382
+ // git dependencies at the commits their package managers' records name (pinInstalledCommits).
1383
+ // -> { bundle, lockfile: () => Lockfile, stateBuilt } (the last two of a JS bundle alone)
1384
+ async function buildOfKind(kind, { cwd, env, entries, mappingFile, manifests, scope, conditions, mainFields, platforms, metro, metroResolver, jsx, flow, typescript, tsconfig, resources, packageJSON, cargo, cargoFeatures, cargoNoDefaultFeatures, cargoAllFeatures, cargoTarget, cargoManifests }) {
1385
+ if (kind === 'sol') return { bundle: await buildSolidityBundle({ cwd, env, entries, mappingFile, manifests }) }
1386
+ if (kind === 'php') return { bundle: await buildPhpBundle({ cwd, entries }) }
1387
+ if (kind === 'bash') return { bundle: await buildBashBundle({ cwd, entries }) }
1388
+ if (kind === 'rust') return { bundle: await buildRustBundle({ cwd, entries, cargo, cargoFeatures, cargoNoDefaultFeatures, cargoAllFeatures, cargoTarget, cargoManifests }) }
1389
+ const built = await buildJs({ cwd, env, entries, scope, conditions, mainFields, platforms, metro, metroResolver, jsx, flow, typescript, tsconfig, resources, packageJSON })
1390
+ pinInstalledCommits(built.bundle, built.root)
1391
+ return built
1392
+ }
1393
+
1394
+ // buildVfsBundle's checks of `options`, which hold before anything is fetched (`host` the
1395
+ // project's, or an empty tree's with `fetched: false`): no metro-resolver, which reads the disk;
1396
+ // `generate` only for JS.
1397
+ // -> the kind of bundle its entries make
1398
+ export function checkVfsOptions(name, options) {
1399
+ const kind = classifyEntries(name, options)
1400
+ if (options.metroResolver) throw new Error(`${name}: metroResolver is not supported`)
1401
+ const { generate = [] } = options
1402
+ if (!Array.isArray(generate) || generate.some((step) => step !== 'prisma')) throw new TypeError(`${name}: generate must be an array of 'prisma'`)
1403
+ if (generate.length > 0 && kind !== 'js') throw new Error(`${name}: --generate is only valid for JS bundles`)
1404
+ return kind
1405
+ }
1406
+
1407
+ // A bundle from the lockfile of the project held in `vfs` alone (@exodus/stasis/vfs-bundle), `cwd` a
1408
+ // path there: buildBundle's JS options, resolved through the node_modules 'pnpm', 'yarn1' or 'npm'
1409
+ // would install, or its Solidity options, through the dependencies folder 'soldeer' would install; with
1410
+ // nothing read from disk but tarballs and zips, nor from the environment: a Solidity bundle is
1411
+ // built with foundry.toml's default profile, whatever FOUNDRY_PROFILE or remappings one sets, and no
1412
+ // EXODUS_STASIS_* setting is read. `repo`, the informational `{ github, directory, commit }`,
1413
+ // is the Bundle's, over what is detected in the Vfs as `stasis bundle` detects it on disk. `os`,
1414
+ // `cpu` and `libc` are loadNodeModules'. Without a `packageManager`, it is the one whose lockfile
1415
+ // installs cwd, where only one's does. `innermostRoot` is buildJsBundle's, for a JS bundle built
1416
+ // through a State. `generate: ['prisma']` writes each project's Prisma Client into the laid-out
1417
+ // tree before the scan, leaving `vfs` as it is (vfs-bundle/prisma.js). `client`, a
1418
+ // @preventive/upstream/github.js client, is what a Soldeer git dependency is fetched through; without
1419
+ // one, a soldeer.lock holding one is refused, naming deptree's `github`, before anything is fetched.
1420
+ // `cache` is where npm packages' tarballs and version documents are kept (checkCache). Each
1421
+ // dependency is recorded at the commit the tree installs it from, where deptree names one
1422
+ // (pinCommits). `root` is the directory in the Vfs the bundle's paths are relative to: cwd, or for a
1423
+ // JS bundle built through a State, the State's root, which is at or above it.
1424
+ // -> { bundle: Bundle, lockfile: Lockfile (of a JS bundle), stats, packageManager, root }
1425
+ export async function buildVfsBundle({ vfs, packageManager, cwd = '/', packageManagerVersion, os, cpu, libc, repo, innermostRoot = false, generate = [], client, cache, ...options } = {}) {
1426
+ const { checkCache, checkKind, checkTarget, checkVfs, loadTree, packageManagerFor, packageManagerOf, pinCommits, treeHost, vfsHost } = await import('../vfs-bundle/tree.js')
1427
+ checkVfs('buildVfsBundle', vfs)
1428
+ checkTarget('buildVfsBundle', { os, cpu, libc })
1429
+ checkCache('buildVfsBundle', cache)
1430
+ // Checked as the Bundle checks it, before anything is fetched.
1431
+ if (repo !== undefined) repo = new Bundle({ repo }).repo
1432
+ const project = vfsHost(vfs)
1433
+ // A real path, as every file the scan reaches is.
1434
+ cwd = project.realpath(posix.resolve('/', cwd))
1435
+ packageManager = packageManagerFor('buildVfsBundle', project, cwd, { packageManager, packageManagerVersion, os })
1436
+ const pm = packageManagerOf('buildVfsBundle', packageManager)
1437
+ checkKind('buildVfsBundle', checkVfsOptions('buildVfsBundle', { ...options, generate, cwd, host: project }), [packageManager])
1438
+ // Checked before anything is fetched: an entry out of what the tree installs is in the project
1439
+ // already. (A Solidity entry that is no .sol file is a directory, skipped where it is missing.)
1440
+ for (const entry of options.entries) {
1441
+ const abs = posix.resolve(cwd, entry)
1442
+ if (pm.kind === 'sol' && !abs.endsWith('.sol')) continue
1443
+ if (!posix.relative(cwd, abs).split('/').includes(pm.installs) && project.stat(abs) === null) throw new Error(`entry not found: ${abs}`)
1444
+ }
1445
+ const tree = await loadTree({ project, packageManager, cwd, packageManagerVersion, os, cpu, libc, client, cache })
1446
+ let { host } = tree
1447
+ if (generate.includes('prisma')) {
1448
+ const { generatePrismaClients, withPrismaClients } = await import('../vfs-bundle/prisma.js')
1449
+ const outputs = await generatePrismaClients({ host, root: tree.root, projects: tree.projects })
1450
+ if (outputs.length > 0) host = treeHost(tree, vfsHost(withPrismaClients(vfs, outputs)))
1451
+ }
1452
+ const args = { ...options, cwd, host, env: {} }
1453
+ const { bundle, lockfile, stateBuilt, root } = pm.kind === 'sol' ? { bundle: await buildSolidityBundle(args), root: cwd } : await buildJs({ ...args, innermostRoot })
1454
+ if (repo !== undefined) bundle.repo = repo
1455
+ // Rooted at cwd, where `stasis bundle` detects its repo.
1456
+ else if (!stateBuilt) bundle.repo ??= detectRepo(cwd, host)
1457
+ pinCommits(bundle, root, tree)
1458
+ return { bundle, lockfile: lockfile?.(), stats: tree.stats, packageManager, root }
1459
+ }
1460
+
1461
+ // Programmatic equivalent of `stasis bundle`: build and return an in-memory Bundle without
1462
+ // writing to disk. Files are attributed to the `bundle` consumer. Option applicability
1463
+ // (--mapping|--manifests/.sol, --scope|--conditions|--mainFields|--metro|--jsx|--flow|--typescript/JS) is enforced by classifyEntries.
1464
+ export async function buildBundle({ cwd = process.cwd(), env = process.env, entries, mappingFile, manifests = false, scope, conditions, mainFields, platforms, metro, metroResolver, jsx = false, flow = false, typescript = false, tsconfig, resources = [], packageJSON = false, cargo = false, cargoFeatures = [], cargoNoDefaultFeatures = false, cargoAllFeatures = false, cargoTarget = null, cargoManifests = false } = {}) {
1465
+ const options = { cwd, env, entries, mappingFile, manifests, scope, conditions, mainFields, platforms, metro, metroResolver, jsx, flow, typescript, tsconfig, resources, packageJSON, cargo, cargoFeatures, cargoNoDefaultFeatures, cargoAllFeatures, cargoTarget, cargoManifests }
1466
+ return (await buildOfKind(classifyEntries('buildBundle', options), options)).bundle
1467
+ }
1468
+
1469
+ // Run `stasis bundle`: build a brotli-compressed bundle and write it to `output`
1470
+ // (stasis.code.br by default, `-` for stdout; the summary goes to stderr so it never
1471
+ // interleaves with binary stdout). An optional JS `lockfile` attests every bundled file;
1472
+ // with `--conditions` it attests the conditions-selected graph, so a plain
1473
+ // `stasis run --lock=frozen` (which doesn't replay them) fails closed -- pair it with
1474
+ // `--bundle=load` or replay the conditions. `add` unions the fresh build into the bundle
1475
+ // already on disk (strict; a conflicting file throws) and can't target stdout.
1476
+ export async function bundleCommand({ cwd = process.cwd(), env = process.env, entries, mappingFile, manifests = false, output, scope, lockfile, conditions, mainFields, platforms, metro, metroResolver, jsx = false, flow = false, typescript = false, tsconfig, resources = [], packageJSON = false, cargo = false, cargoFeatures = [], cargoNoDefaultFeatures = false, cargoAllFeatures = false, cargoTarget = null, cargoManifests = false, brotliQuality, add = false } = {}) {
1477
+ const options = { cwd, env, entries, mappingFile, manifests, scope, conditions, mainFields, platforms, metro, metroResolver, jsx, flow, typescript, tsconfig, resources, packageJSON, cargo, cargoFeatures, cargoNoDefaultFeatures, cargoAllFeatures, cargoTarget, cargoManifests }
1478
+ const kind = classifyEntries('bundleCommand', { ...options, lockfile })
1479
+
1480
+ // --add has nothing to merge into on stdout (write-only).
1481
+ if (add && output === '-') {
1482
+ throw new Error('bundleCommand: --add cannot be combined with --output=- (nothing to merge into on stdout)')
1051
1483
  }
1484
+
1485
+ const built = await buildOfKind(kind, options)
1486
+ let { bundle } = built
1487
+ // The directory the bundle's paths are relative to: a State-built JS bundle's root, which is the
1488
+ // project's at or above cwd (a workspace package's is the workspace root's), else cwd.
1489
+ const root = built.root ?? resolve(cwd)
1490
+ // By default the bundle goes there, where `stasis run --bundle=load` discovers it. In cwd below
1491
+ // the root it would load as rooted at cwd, and root the next `stasis bundle` there too, out of the
1492
+ // files above it.
1493
+ const target = output ?? relative(resolve(cwd), join(root, DEFAULT_BUNDLE_FILE))
1494
+ // Only a JS bundle has a lockfile (classifyEntries refuses one for the others).
1495
+ let lockData = lockfile ? built.lockfile().serialize() : undefined
1496
+
1497
+ // State-built bundles keep the State root's repo (no cwd fallback: their paths are relative to that root).
1498
+ if (!built.stateBuilt) bundle.repo ??= detectRepo(cwd)
1499
+
1500
+ // --add: union the fresh build into the existing on-disk bundle; a conflicting file throws. Skipped when nothing is on disk.
1501
+ const outAbs = target === '-' ? undefined : resolve(cwd, target)
1502
+ const lockAbs = lockData ? resolve(cwd, lockfile) : undefined
1503
+ // Files the pre-existing bundle carried; undefined when no merge happened (the summary's merged sentinel).
1504
+ let mergedFrom
1505
+ if (add && existsSync(outAbs)) {
1506
+ let existing
1507
+ try {
1508
+ existing = Bundle.parse(brotliDecompressSync(readFileSync(outAbs)).toString('utf8'))
1509
+ } catch (cause) {
1510
+ throw new Error(`bundleCommand: --add failed to read the existing bundle at ${target}`, { cause })
1511
+ }
1512
+ mergedFrom = existing.sources.size
1513
+ bundle = existing.merge(bundle)
1514
+ // Keep the companion lockfile consistent with the merged bundle: union an existing one.
1515
+ // With none on disk we can't attest the pre-existing bundle's files (their hashes live
1516
+ // only in that missing lockfile), so refuse rather than write an under-attesting lockfile.
1517
+ if (lockData) {
1518
+ if (!existsSync(lockAbs)) {
1519
+ 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.`)
1520
+ }
1521
+ let existingLock
1522
+ try {
1523
+ existingLock = Lockfile.parse(readFileSync(lockAbs, 'utf8'))
1524
+ } catch (cause) {
1525
+ throw new Error(`bundleCommand: --add failed to read the existing lockfile at ${lockfile}`, { cause })
1526
+ }
1527
+ lockData = existingLock.merge(Lockfile.parse(lockData)).serialize()
1528
+ }
1529
+ }
1530
+
1052
1531
  const serialized = bundle.serialize()
1053
1532
  const files = [...bundle.sources.keys()]
1054
1533
  const modules = bundle.modules
1055
1534
 
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)
1535
+ const dest = writeBundle(cwd, target, serialized, brotliQuality)
1536
+ if (lockData) writeFile(lockAbs, lockData)
1537
+ // The bundle's paths are relative to its root; shown relative to cwd, as `dest` is.
1538
+ const fromDir = posix.join(toPosix(relative(resolve(cwd), root)), outermostDir(files, root))
1539
+ // On a merge, report newly-added files alongside the totals; a fresh write keeps the plain line.
1540
+ if (mergedFrom !== undefined) {
1541
+ const added = files.length - mergedFrom
1542
+ console.warn(`[stasis] Added ${added} file${added === 1 ? '' : 's'} (${files.length} total in ${packagesLabel(modules)}) from ${fromDir} to ${dest}`)
1062
1543
  } 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
- }
1072
- const fromDir = outermostDir(files, resolve(cwd))
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}`)
1544
+ console.warn(bundledSummary(files.length, modules, fromDir, dest))
1545
+ }
1079
1546
  }