@exodus/stasis 1.0.0-beta.3 → 1.0.0-beta.6

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