@exodus/stasis 1.0.0-alpha.3 → 1.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +67 -3
- package/bin/stasis.js +326 -19
- package/logo.svg +12 -3
- package/package.json +38 -18
- package/src/audit.js +1 -38
- package/src/bundle-from-lockfile.js +65 -0
- package/src/bundle.js +4 -217
- package/src/cmd/build.js +321 -0
- package/src/cmd/bundle.js +949 -58
- package/src/cmd/diff.js +36 -0
- package/src/cmd/extract.js +193 -0
- package/src/cmd/sbom.js +35 -0
- package/src/diff.js +333 -0
- package/src/esbuild.js +5 -56
- package/src/loader-mock.js +19 -0
- package/src/loader.js +6 -132
- package/src/loaders/bash.js +257 -0
- package/src/loaders/php.js +1058 -0
- package/src/loaders/rust.js +336 -0
- package/src/loaders/solidity.js +26 -1
- package/src/lockfile.js +4 -80
- package/src/metro-transformer.js +6 -0
- package/src/metro.js +5 -0
- package/src/mock.js +310 -0
- package/src/parse.js +53 -0
- package/src/prune.js +4 -162
- package/src/sbom.js +266 -0
- package/src/scan.js +373 -0
- package/src/webpack.js +5 -48
- package/src/config.js +0 -205
- package/src/state.js +0 -440
- package/src/state.util.js +0 -25
- package/src/util.js +0 -60
package/src/cmd/bundle.js
CHANGED
|
@@ -1,22 +1,54 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { isUtf8 } from 'node:buffer'
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { dirname, extname, isAbsolute, join, posix, relative, resolve } from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
3
5
|
import { brotliCompressSync } from 'node:zlib'
|
|
4
6
|
|
|
5
|
-
import { Bundle } from '
|
|
6
|
-
import {
|
|
7
|
+
import { Bundle } from '@exodus/stasis-core/bundle'
|
|
8
|
+
import { Lockfile } from '@exodus/stasis-core/lockfile'
|
|
9
|
+
import { scan } from '../scan.js'
|
|
10
|
+
import { createFieldResolver, resolveConditions } from '../resolve-fields.js'
|
|
11
|
+
import { State } from '@exodus/stasis-core/state'
|
|
12
|
+
import { brotliOptions } from '@exodus/stasis-core/brotli'
|
|
13
|
+
import { sha512integrity } from '@exodus/stasis-core/state-util'
|
|
14
|
+
import { assertRealPathWithinBase, moduleFileKey, splitNodeModulesPath } from '@exodus/stasis-core/util'
|
|
7
15
|
import {
|
|
8
16
|
buildSolidityTree,
|
|
9
17
|
collectSolidityFilesFromDisk,
|
|
10
18
|
readRemappingsFile,
|
|
11
19
|
} from '../loaders/solidity.js'
|
|
20
|
+
import { buildBashTree, collectBashFilesFromDisk } from '../loaders/bash.js'
|
|
21
|
+
import { buildRustTree, collectRustFilesFromDisk } from '../loaders/rust.js'
|
|
22
|
+
import {
|
|
23
|
+
bucketizePhpSources,
|
|
24
|
+
buildPhpTree,
|
|
25
|
+
collectPhpFilesFromDisk,
|
|
26
|
+
loadComposerAutoload,
|
|
27
|
+
loadLaravelProviderFiles,
|
|
28
|
+
} from '../loaders/php.js'
|
|
29
|
+
|
|
30
|
+
const JS_EXTS = new Set(['.js', '.cjs', '.mjs', '.ts', '.cts', '.mts'])
|
|
31
|
+
const BASH_EXTS = new Set(['.sh', '.bash'])
|
|
32
|
+
const RUST_EXTS = new Set(['.rs'])
|
|
12
33
|
|
|
13
34
|
// Fallback package identity for the workspace bucket when no package.json
|
|
14
35
|
// with a name+version can be found by walking up from any of the bundled
|
|
15
|
-
// files. Bundle.
|
|
36
|
+
// files. Bundle.parse requires every workspace/module bucket to have
|
|
16
37
|
// both fields, so we have to attest something.
|
|
17
38
|
const SOLIDITY_WORKSPACE_NAME = 'solidity-bundle'
|
|
18
39
|
const SOLIDITY_WORKSPACE_VERSION = '0.0.0'
|
|
19
40
|
const SOLIDITY_FORMAT = 'solidity'
|
|
41
|
+
const BASH_WORKSPACE_NAME = 'bash-bundle'
|
|
42
|
+
const BASH_WORKSPACE_VERSION = '0.0.0'
|
|
43
|
+
const BASH_FORMAT = 'bash'
|
|
44
|
+
|
|
45
|
+
const RUST_WORKSPACE_NAME = 'rust-bundle'
|
|
46
|
+
const RUST_WORKSPACE_VERSION = '0.0.0'
|
|
47
|
+
const RUST_FORMAT = 'rust'
|
|
48
|
+
|
|
49
|
+
const PHP_WORKSPACE_NAME = 'php-bundle'
|
|
50
|
+
const PHP_WORKSPACE_VERSION = '0.0.0'
|
|
51
|
+
const PHP_FORMAT = 'php'
|
|
20
52
|
|
|
21
53
|
// Walk up from the file's directory looking for the nearest package.json
|
|
22
54
|
// with both `name` and `version`. Returns { pkgDir, name, version }
|
|
@@ -75,6 +107,221 @@ export function outermostDir(paths, cwd) {
|
|
|
75
107
|
return rel === '' ? '.' : rel
|
|
76
108
|
}
|
|
77
109
|
|
|
110
|
+
function readJson(file) {
|
|
111
|
+
try {
|
|
112
|
+
return JSON.parse(readFileSync(file, 'utf8'))
|
|
113
|
+
} catch {
|
|
114
|
+
return null
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Split a Soldeer dependency dir name (`<name>-<version>`) into its parts, e.g.
|
|
119
|
+
// `@openzeppelin-contracts-5.0.2` -> { name: '@openzeppelin-contracts',
|
|
120
|
+
// version: '5.0.2' }. Soldeer encodes the version into the directory name, so
|
|
121
|
+
// that's the authoritative identity for a Soldeer package. Falls back to
|
|
122
|
+
// version 0.0.0 when the dir doesn't end in a semver (every bucket must attest
|
|
123
|
+
// a version).
|
|
124
|
+
function parseSoldeerDir(seg) {
|
|
125
|
+
const m = /^(.+)-(\d+\.\d+\.\d+(?:[-+][\w.-]+)?)$/u.exec(seg)
|
|
126
|
+
return m ? { name: m[1], version: m[2] } : { name: seg, version: '0.0.0' }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Extract `owner/repo` from a github.com remote (https, ssh, or scp-style),
|
|
130
|
+
// dropping a trailing `.git`. Returns null for non-github hosts.
|
|
131
|
+
function githubSlug(url) {
|
|
132
|
+
const m = /github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?\/?$/iu.exec(url)
|
|
133
|
+
return m ? `${m[1]}/${m[2]}` : null
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Parse `.gitmodules` into Map<submodulePath, { name, branch }>, keeping only
|
|
137
|
+
// github.com submodules (the ones we can attribute as `github`). `.gitmodules`
|
|
138
|
+
// is git-config INI: `[submodule "<n>"]` sections with `path`/`url`/`branch`.
|
|
139
|
+
function parseGithubSubmodules(baseDir) {
|
|
140
|
+
const byPath = new Map()
|
|
141
|
+
const text = readFileSyncOrNull(join(baseDir, '.gitmodules'))
|
|
142
|
+
if (!text) return byPath
|
|
143
|
+
let cur = null
|
|
144
|
+
const flush = () => {
|
|
145
|
+
if (cur?.path && cur?.url) {
|
|
146
|
+
const name = githubSlug(cur.url)
|
|
147
|
+
if (name) byPath.set(cur.path.replace(/\/+$/u, ''), { name, branch: cur.branch })
|
|
148
|
+
}
|
|
149
|
+
cur = null
|
|
150
|
+
}
|
|
151
|
+
for (const raw of text.split('\n')) {
|
|
152
|
+
const line = raw.trim()
|
|
153
|
+
if (line.startsWith('[')) {
|
|
154
|
+
flush()
|
|
155
|
+
cur = line.startsWith('[submodule') ? {} : null
|
|
156
|
+
continue
|
|
157
|
+
}
|
|
158
|
+
if (!cur) continue
|
|
159
|
+
const eq = line.indexOf('=')
|
|
160
|
+
if (eq === -1) continue
|
|
161
|
+
const key = line.slice(0, eq).trim()
|
|
162
|
+
if (key === 'path' || key === 'url' || key === 'branch') cur[key] = line.slice(eq + 1).trim()
|
|
163
|
+
}
|
|
164
|
+
flush()
|
|
165
|
+
return byPath
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function readFileSyncOrNull(file) {
|
|
169
|
+
try {
|
|
170
|
+
return readFileSync(file, 'utf8')
|
|
171
|
+
} catch {
|
|
172
|
+
return null
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Classify a bundled Solidity file into its dependency bucket. Solidity installs
|
|
177
|
+
// deps three ways, each its own ecosystem: npm (`node_modules`, left to the
|
|
178
|
+
// shared bucketizer), Soldeer (`dependencies/<name>-<version>/` → `soldeer`),
|
|
179
|
+
// and `forge install` git submodules (`lib/<dir>/`, when `.gitmodules` records a
|
|
180
|
+
// github.com URL → `github`, per SBOM/Package-URL `pkg:github/owner/repo`).
|
|
181
|
+
// Returns { bucketDir, name, version, ecosystem } for a Soldeer/github dep, or
|
|
182
|
+
// null to defer to the package.json/node_modules/workspace logic.
|
|
183
|
+
function makeSolidityClassifier(baseDir) {
|
|
184
|
+
const submodules = parseGithubSubmodules(baseDir)
|
|
185
|
+
return (path) => {
|
|
186
|
+
if (path.startsWith('dependencies/')) {
|
|
187
|
+
const seg = path.slice('dependencies/'.length).split('/')[0]
|
|
188
|
+
if (seg) {
|
|
189
|
+
const { name, version } = parseSoldeerDir(seg)
|
|
190
|
+
return { bucketDir: `dependencies/${seg}`, name, version, ecosystem: 'soldeer' }
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
for (const [sub, { name, branch }] of submodules) {
|
|
194
|
+
if (path === sub || path.startsWith(`${sub}/`)) {
|
|
195
|
+
const pkg = readJson(join(baseDir, sub, 'package.json'))
|
|
196
|
+
return { bucketDir: sub, name, version: pkg?.version ?? branch ?? '0.0.0', ecosystem: 'github' }
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return null
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Minimal Cargo.toml reader: pull `name`/`version` from the `[package]` table.
|
|
204
|
+
// A full TOML parse isn't warranted — those two string keys are all we need to
|
|
205
|
+
// identify a vendored crate. Returns null when there's no readable package name.
|
|
206
|
+
function parseCargoToml(file) {
|
|
207
|
+
const text = readFileSyncOrNull(file)
|
|
208
|
+
if (!text) return null
|
|
209
|
+
const at = text.search(/^\[package\]\s*$/mu)
|
|
210
|
+
let body = text
|
|
211
|
+
if (at !== -1) {
|
|
212
|
+
const rest = text.slice(at)
|
|
213
|
+
const next = rest.slice(1).search(/^\[/mu) // start of the table after [package]
|
|
214
|
+
body = next === -1 ? rest : rest.slice(0, next + 1)
|
|
215
|
+
}
|
|
216
|
+
const name = /^\s*name\s*=\s*"([^"]+)"/mu.exec(body)?.[1]
|
|
217
|
+
const version = /^\s*version\s*=\s*"([^"]+)"/mu.exec(body)?.[1]
|
|
218
|
+
return name ? { name, version: version ?? '0.0.0' } : null
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Classify a bundled Rust file. `cargo vendor` copies external crates in-tree
|
|
222
|
+
// under `vendor/<dir>/`; each is its own crate with a Cargo.toml, so bucket it
|
|
223
|
+
// under `vendor/<dir>` and tag it `cargo` (Package-URL `pkg:cargo/<name>`). The
|
|
224
|
+
// workspace's own code and path/workspace members defer to the workspace logic.
|
|
225
|
+
function makeRustClassifier(baseDir) {
|
|
226
|
+
return (path) => {
|
|
227
|
+
if (!path.startsWith('vendor/')) return null
|
|
228
|
+
const dir = path.slice('vendor/'.length).split('/')[0]
|
|
229
|
+
if (!dir) return null
|
|
230
|
+
const bucketDir = `vendor/${dir}`
|
|
231
|
+
const pkg = parseCargoToml(join(baseDir, bucketDir, 'Cargo.toml'))
|
|
232
|
+
return pkg ? { bucketDir, name: pkg.name, version: pkg.version, ecosystem: 'cargo' } : null
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Assemble a scope=full code Bundle from a collected source set and its
|
|
237
|
+
// resolution graph. Shared by every non-JS bundler (Solidity/Bash/Rust): the
|
|
238
|
+
// language-specific work (collecting files, resolving the graph, deciding what
|
|
239
|
+
// counts as "missing") happens in the caller; here every file is bucketized
|
|
240
|
+
// the same way, tagged `format`, and the whole resolution graph is keyed under
|
|
241
|
+
// a single `conditionKey`.
|
|
242
|
+
//
|
|
243
|
+
// Files are partitioned into per-package buckets by the nearest package.json
|
|
244
|
+
// with name+version: node_modules files into `node_modules/<pkg>` buckets,
|
|
245
|
+
// workspace files into their package dir, and anything with no discoverable
|
|
246
|
+
// package.json into the "." bucket with the given placeholder identity (the
|
|
247
|
+
// Bundle layout requires every bucket to attest a name+version). A file under
|
|
248
|
+
// node_modules whose nearest package.json is the workspace root is rejected —
|
|
249
|
+
// that hides a misconfigured dependency rather than silently mislabeling it.
|
|
250
|
+
//
|
|
251
|
+
// Unlike the wildcard "*" JS bundles use, these formats don't vary by Node
|
|
252
|
+
// resolution condition, so every edge lands under the one `conditionKey`.
|
|
253
|
+
//
|
|
254
|
+
// Dependency buckets (those under node_modules) are tagged `ecosystem: 'npm'`:
|
|
255
|
+
// node_modules is npm's install layout, so that's where the package came from,
|
|
256
|
+
// regardless of the bundle's language — a Solidity or Bash import resolved out
|
|
257
|
+
// of node_modules is an npm package all the same. This mirrors State's own
|
|
258
|
+
// node_modules tagging (see stasis-core state.js) and lets consumers tell deps
|
|
259
|
+
// apart from the workspace's own packages; the workspace/`.` bucket carries no
|
|
260
|
+
// ecosystem. (PHP's Composer `vendor/` deps are tagged `composer` separately, by
|
|
261
|
+
// the PHP bucketizer.)
|
|
262
|
+
//
|
|
263
|
+
// `classifyDep(path)` is an optional language-specific hook: when it returns a
|
|
264
|
+
// { bucketDir, name, version, ecosystem } record the file is placed there
|
|
265
|
+
// directly, which is how non-node_modules ecosystems are attributed (e.g.
|
|
266
|
+
// Solidity's Soldeer `dependencies/` and github submodules). Returning null (or
|
|
267
|
+
// omitting the hook) defers to the node_modules/package.json/workspace logic.
|
|
268
|
+
// `format` tags every file uniformly (the single-language non-JS callers); pass
|
|
269
|
+
// `formats` (a Map<path, format>) instead to tag per file, as the JS resolver path
|
|
270
|
+
// does (a graph mixes module / commonjs / *-typescript / json). `resolutions`
|
|
271
|
+
// values may be a target string (a flat edge) or a Map<platform, target> (a `--metro`
|
|
272
|
+
// edge that resolves differently per platform) -- both round-trip through the bundle's
|
|
273
|
+
// recursive serializer untouched.
|
|
274
|
+
function assembleCodeBundle({
|
|
275
|
+
baseDir, entries, sources, resolutions, workspaceName, workspaceVersion, format, formats, conditionKey, classifyDep,
|
|
276
|
+
}) {
|
|
277
|
+
const modules = new Map()
|
|
278
|
+
const ensureBucket = (dir, name, version, bucketEcosystem) => {
|
|
279
|
+
if (!modules.has(dir)) {
|
|
280
|
+
modules.set(dir, bucketEcosystem === undefined
|
|
281
|
+
? { name, version, files: Object.create(null) }
|
|
282
|
+
: { name, version, ecosystem: bucketEcosystem, files: Object.create(null) })
|
|
283
|
+
}
|
|
284
|
+
return modules.get(dir)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
for (const [path, content] of sources) {
|
|
288
|
+
const dep = classifyDep?.(path)
|
|
289
|
+
if (dep) {
|
|
290
|
+
ensureBucket(dep.bucketDir, dep.name, dep.version, dep.ecosystem)
|
|
291
|
+
.files[path.slice(dep.bucketDir.length + 1)] = content
|
|
292
|
+
continue
|
|
293
|
+
}
|
|
294
|
+
const meta = findPackageMetadata(baseDir, path)
|
|
295
|
+
const inNodeModules = splitNodeModulesPath(path) !== null
|
|
296
|
+
if (meta) {
|
|
297
|
+
if (inNodeModules && !meta.pkgDir.includes('node_modules')) {
|
|
298
|
+
throw new Error(`No package.json with name+version found for ${path}`)
|
|
299
|
+
}
|
|
300
|
+
const rel = meta.pkgDir === '.' ? path : path.slice(meta.pkgDir.length + 1)
|
|
301
|
+
const bucketEcosystem = meta.pkgDir.includes('node_modules') ? 'npm' : undefined
|
|
302
|
+
ensureBucket(meta.pkgDir, meta.name, meta.version, bucketEcosystem).files[rel] = content
|
|
303
|
+
} else {
|
|
304
|
+
if (inNodeModules) throw new Error(`No package.json with name+version found for ${path}`)
|
|
305
|
+
ensureBucket('.', workspaceName, workspaceVersion).files[path] = content
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const formatsMap = new Map()
|
|
310
|
+
for (const path of sources.keys()) formatsMap.set(path, formats?.get(path) ?? format)
|
|
311
|
+
|
|
312
|
+
const importsForKey = new Map()
|
|
313
|
+
for (const [parent, specMap] of resolutions) importsForKey.set(parent, specMap)
|
|
314
|
+
const imports = new Map([[conditionKey, importsForKey]])
|
|
315
|
+
|
|
316
|
+
return new Bundle({
|
|
317
|
+
config: { scope: 'full' },
|
|
318
|
+
entries: new Set(entries),
|
|
319
|
+
modules,
|
|
320
|
+
formats: formatsMap,
|
|
321
|
+
imports,
|
|
322
|
+
})
|
|
323
|
+
}
|
|
324
|
+
|
|
78
325
|
// Build a stasis Bundle (in-memory) from a list of entry .sol files and
|
|
79
326
|
// an optional mapping file. The mapping file (foundry.toml or
|
|
80
327
|
// remappings.txt) is parsed for remappings but NOT included in the
|
|
@@ -109,49 +356,182 @@ export async function buildSolidityBundle({ cwd = process.cwd(), entries, mappin
|
|
|
109
356
|
throw new Error(`Solidity bundle has unresolved imports:\n${issues.map((s) => ` ${s}`).join('\n')}`)
|
|
110
357
|
}
|
|
111
358
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
359
|
+
return assembleCodeBundle({
|
|
360
|
+
baseDir,
|
|
361
|
+
entries: normalized,
|
|
362
|
+
sources,
|
|
363
|
+
resolutions,
|
|
364
|
+
workspaceName: SOLIDITY_WORKSPACE_NAME,
|
|
365
|
+
workspaceVersion: SOLIDITY_WORKSPACE_VERSION,
|
|
366
|
+
format: SOLIDITY_FORMAT,
|
|
367
|
+
conditionKey: 'solidity',
|
|
368
|
+
classifyDep: makeSolidityClassifier(baseDir),
|
|
369
|
+
})
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Build a stasis Bundle (in-memory) from a list of entry .sh/.bash files by
|
|
373
|
+
// walking the source/exec graph and reading every reachable script from disk.
|
|
374
|
+
// Mirrors buildSolidityBundle's bucketizing, with a bash-specific policy:
|
|
375
|
+
// - a .sh/.bash reference whose path lands INSIDE the bundle root
|
|
376
|
+
// (`source ./lib.sh`, `dir/x.sh`) but resolves to no bundled script is
|
|
377
|
+
// fatal — a self-contained bundle can't carry a dangling local source —
|
|
378
|
+
// as is a missing entry.
|
|
379
|
+
// - everything else is best-effort and dropped: PATH commands (`grep`,
|
|
380
|
+
// `node`), dynamic `$VAR` paths, extensionless sources, absolute system
|
|
381
|
+
// paths (`source /etc/profile.d/x.sh`), and `../` paths that escape the
|
|
382
|
+
// root — none can live in the bundle.
|
|
383
|
+
// - imports live under a dedicated "bash" condition key (like "solidity"),
|
|
384
|
+
// not the JS-bundle wildcard "*".
|
|
385
|
+
export async function buildBashBundle({ cwd = process.cwd(), entries } = {}) {
|
|
386
|
+
if (!Array.isArray(entries) || entries.length === 0) {
|
|
387
|
+
throw new Error('buildBashBundle: at least one entry .sh/.bash file is required')
|
|
388
|
+
}
|
|
389
|
+
for (const e of entries) {
|
|
390
|
+
if (!BASH_EXTS.has(extname(e))) throw new Error(`buildBashBundle: not a .sh/.bash file: ${e}`)
|
|
123
391
|
}
|
|
124
392
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
393
|
+
const baseDir = resolve(cwd)
|
|
394
|
+
const normalized = normalizeEntries(entries, cwd)
|
|
395
|
+
|
|
396
|
+
const sources = await collectBashFilesFromDisk(baseDir, normalized)
|
|
397
|
+
const { resolutions, missing } = buildBashTree(sources)
|
|
398
|
+
|
|
399
|
+
// Bundles must be self-contained: every entry must load, and every .sh/.bash
|
|
400
|
+
// reference pointing inside the bundle root must resolve to a bundled script.
|
|
401
|
+
// External/best-effort references (commands, $VAR paths, extensionless,
|
|
402
|
+
// absolute, or `../`-escaping sources) are left out by buildBashTree and
|
|
403
|
+
// never reach `missing`.
|
|
404
|
+
const issues = []
|
|
405
|
+
for (const entry of normalized) {
|
|
406
|
+
if (!sources.has(entry)) issues.push(`Missing entry: ${entry}`)
|
|
407
|
+
}
|
|
408
|
+
for (const { spec, from } of missing) {
|
|
409
|
+
issues.push(`Unresolved script: ${spec} from ${from}`)
|
|
410
|
+
}
|
|
411
|
+
if (issues.length > 0) {
|
|
412
|
+
throw new Error(`Bash bundle has unresolved scripts:\n${issues.map((s) => ` ${s}`).join('\n')}`)
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return assembleCodeBundle({
|
|
416
|
+
baseDir,
|
|
417
|
+
entries: normalized,
|
|
418
|
+
sources,
|
|
419
|
+
resolutions,
|
|
420
|
+
workspaceName: BASH_WORKSPACE_NAME,
|
|
421
|
+
workspaceVersion: BASH_WORKSPACE_VERSION,
|
|
422
|
+
format: BASH_FORMAT,
|
|
423
|
+
conditionKey: 'bash',
|
|
424
|
+
})
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Build a stasis Bundle (in-memory) from a list of entry .rs files by walking
|
|
428
|
+
// `mod` declarations from each crate root and reading every reachable module
|
|
429
|
+
// from disk. The bundle must be self-contained: an unresolvable `mod foo;`
|
|
430
|
+
// (no backing file) is fatal, as is a missing entry. `use crate::` edges are
|
|
431
|
+
// recorded in the import graph but stay best-effort — they never widen the
|
|
432
|
+
// file set, and their resolution is approximate (item-vs-module, re-exports),
|
|
433
|
+
// so they aren't gated; external crates and std are ignored outright. Imports
|
|
434
|
+
// live under a dedicated "rust" condition key.
|
|
435
|
+
export async function buildRustBundle({ cwd = process.cwd(), entries } = {}) {
|
|
436
|
+
if (!Array.isArray(entries) || entries.length === 0) {
|
|
437
|
+
throw new Error('buildRustBundle: at least one entry .rs file is required')
|
|
438
|
+
}
|
|
439
|
+
for (const e of entries) {
|
|
440
|
+
if (!RUST_EXTS.has(extname(e))) throw new Error(`buildRustBundle: not a .rs file: ${e}`)
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const baseDir = resolve(cwd)
|
|
444
|
+
const normalized = normalizeEntries(entries, cwd)
|
|
445
|
+
|
|
446
|
+
const sources = await collectRustFilesFromDisk(baseDir, normalized)
|
|
447
|
+
const { resolutions, missing } = buildRustTree(sources)
|
|
448
|
+
|
|
449
|
+
const issues = []
|
|
450
|
+
for (const entry of normalized) {
|
|
451
|
+
if (!sources.has(entry)) issues.push(`Missing entry: ${entry}`)
|
|
452
|
+
}
|
|
453
|
+
for (const { spec, from } of missing) {
|
|
454
|
+
issues.push(`Unresolved module: ${spec} from ${from}`)
|
|
455
|
+
}
|
|
456
|
+
if (issues.length > 0) {
|
|
457
|
+
throw new Error(`Rust bundle has unresolved modules:\n${issues.map((s) => ` ${s}`).join('\n')}`)
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
return assembleCodeBundle({
|
|
461
|
+
baseDir,
|
|
462
|
+
entries: normalized,
|
|
463
|
+
sources,
|
|
464
|
+
resolutions,
|
|
465
|
+
workspaceName: RUST_WORKSPACE_NAME,
|
|
466
|
+
workspaceVersion: RUST_WORKSPACE_VERSION,
|
|
467
|
+
format: RUST_FORMAT,
|
|
468
|
+
conditionKey: 'rust',
|
|
469
|
+
classifyDep: makeRustClassifier(baseDir),
|
|
470
|
+
})
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Build a stasis Bundle (in-memory) from a list of entry .php files by
|
|
474
|
+
// statically scanning their `require`/`include` graph -- and, when the project
|
|
475
|
+
// uses Composer, the class-autoload graph too -- reading every reachable file
|
|
476
|
+
// from disk. No PHP is ever executed. Mirrors buildSolidityBundle: files are
|
|
477
|
+
// bucketed per package.json, tagged with the `php` format, and edges are keyed
|
|
478
|
+
// under a dedicated "php" condition bucket.
|
|
479
|
+
//
|
|
480
|
+
// Composer autoloading is followed automatically when a composer.json (or
|
|
481
|
+
// generated vendor/composer/autoload_*.php map) is present: PSR-4/PSR-0/
|
|
482
|
+
// classmap/files config is read and the classes each file references (`use`
|
|
483
|
+
// imports, `new`/`extends`/`implements`, type hints, …) are resolved to their
|
|
484
|
+
// files the way Composer's ClassLoader would. That resolution is best-effort
|
|
485
|
+
// (unresolvable references are typically built-in or extension classes), so it
|
|
486
|
+
// never blocks the bundle; explicit `require`/`include` of a literal path is
|
|
487
|
+
// still strict.
|
|
488
|
+
//
|
|
489
|
+
// Refuses to write when an entry can't be loaded or any explicit include is
|
|
490
|
+
// unresolved -- a bundle with holes would silently operate on a partial set of
|
|
491
|
+
// sources.
|
|
492
|
+
export async function buildPhpBundle({ cwd = process.cwd(), entries } = {}) {
|
|
493
|
+
if (!Array.isArray(entries) || entries.length === 0) {
|
|
494
|
+
throw new Error('buildPhpBundle: at least one entry .php file is required')
|
|
495
|
+
}
|
|
496
|
+
for (const e of entries) {
|
|
497
|
+
if (!e.endsWith('.php')) throw new Error(`buildPhpBundle: not a .php file: ${e}`)
|
|
144
498
|
}
|
|
145
499
|
|
|
500
|
+
const baseDir = resolve(cwd)
|
|
501
|
+
const normalized = normalizeEntries(entries, cwd)
|
|
502
|
+
const autoload = loadComposerAutoload(baseDir)
|
|
503
|
+
|
|
504
|
+
// Laravel auto-discovers vendor/app service providers (via composer metadata
|
|
505
|
+
// and bootstrap/providers.php) rather than referencing them statically; seed
|
|
506
|
+
// them as extra roots so the config/route/view files they pull in get bundled.
|
|
507
|
+
const providerRoots = loadLaravelProviderFiles(baseDir, autoload)
|
|
508
|
+
|
|
509
|
+
const sources = await collectPhpFilesFromDisk(baseDir, [...normalized, ...providerRoots], { autoload })
|
|
510
|
+
const { resolutions, missing } = buildPhpTree(sources, { baseDir, autoload })
|
|
511
|
+
|
|
512
|
+
const issues = []
|
|
513
|
+
for (const entry of normalized) {
|
|
514
|
+
if (!sources.has(entry)) issues.push(`Missing entry: ${entry}`)
|
|
515
|
+
}
|
|
516
|
+
for (const { spec, from } of missing) {
|
|
517
|
+
issues.push(`Unresolved import: ${spec} from ${from}`)
|
|
518
|
+
}
|
|
519
|
+
if (issues.length > 0) {
|
|
520
|
+
throw new Error(`PHP bundle has unresolved imports:\n${issues.map((s) => ` ${s}`).join('\n')}`)
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Group per Composer package (vendor/<pkg> buckets with name+version from
|
|
524
|
+
// composer.json / installed.json), not the node_modules-based bucketizer.
|
|
525
|
+
const modules = bucketizePhpSources(baseDir, sources, PHP_WORKSPACE_NAME, PHP_WORKSPACE_VERSION)
|
|
526
|
+
|
|
146
527
|
const formats = new Map()
|
|
147
|
-
for (const path of sources.keys()) formats.set(path,
|
|
528
|
+
for (const path of sources.keys()) formats.set(path, PHP_FORMAT)
|
|
148
529
|
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
// JS code bundles.
|
|
530
|
+
// PHP includes don't depend on Node conditions; key them under a dedicated
|
|
531
|
+
// "php" bucket rather than the wildcard "*" used for JS code bundles.
|
|
152
532
|
const importsForKey = new Map()
|
|
153
533
|
for (const [parent, specMap] of resolutions) importsForKey.set(parent, specMap)
|
|
154
|
-
const imports = new Map([['
|
|
534
|
+
const imports = new Map([['php', importsForKey]])
|
|
155
535
|
|
|
156
536
|
return new Bundle({
|
|
157
537
|
config: { scope: 'full' },
|
|
@@ -162,27 +542,538 @@ export async function buildSolidityBundle({ cwd = process.cwd(), entries, mappin
|
|
|
162
542
|
})
|
|
163
543
|
}
|
|
164
544
|
|
|
545
|
+
// Classify a scanner's unresolved edges + parse errors into `fatal` (the bundle
|
|
546
|
+
// would be broken, or silently divergent from plain node, at load) vs tolerated
|
|
547
|
+
// (a miss runtime code can catch). The rationale for each rule lives at the call
|
|
548
|
+
// site in buildJsBundle; this is the shared computation so the plain and the
|
|
549
|
+
// resolver-driven (`--mainFields`) JS paths gate identically.
|
|
550
|
+
function analyzeScanner(scanner) {
|
|
551
|
+
// Static reachability: files reached from an entry through static ESM
|
|
552
|
+
// `import`/`export ... from` edges load eagerly, before any user code runs, so
|
|
553
|
+
// a failure there is uncatchable; below a require()/dynamic-import() boundary it
|
|
554
|
+
// surfaces as a catchable miss and only warns.
|
|
555
|
+
const staticKinds = new Set(['import', 'export-from'])
|
|
556
|
+
const staticReachable = new Set(scanner.entries)
|
|
557
|
+
const walk = [...scanner.entries]
|
|
558
|
+
while (walk.length > 0) {
|
|
559
|
+
for (const e of scanner.files.get(walk.shift())?.edges ?? []) {
|
|
560
|
+
if (staticKinds.has(e.kind) && e.child && !staticReachable.has(e.child)) {
|
|
561
|
+
staticReachable.add(e.child)
|
|
562
|
+
walk.push(e.child)
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
const fatalUnresolved = (u) => staticKinds.has(u.kind) && staticReachable.has(u.parentURL)
|
|
567
|
+
const fatal = scanner.unresolved
|
|
568
|
+
.filter((u) => fatalUnresolved(u))
|
|
569
|
+
.map((u) => `unresolved ${u.kind} ${u.spec} from ${fileURLToPath(u.parentURL)} (${u.reason})`)
|
|
570
|
+
// Parse failures whose edges are genuinely unknown: a module-family file in the
|
|
571
|
+
// eagerly-linked set, or any file the parser couldn't process at all.
|
|
572
|
+
const fatalParse = (p) => staticReachable.has(p.url) && (p.format?.startsWith('module') === true || !p.recovered)
|
|
573
|
+
for (const p of scanner.parseErrors) {
|
|
574
|
+
if (fatalParse(p)) fatal.push(`parse error in ${fileURLToPath(p.url)}: ${p.message}`)
|
|
575
|
+
}
|
|
576
|
+
// Edges resolving to a file a source bundle can't carry (extensionless, .node,
|
|
577
|
+
// .wasm): scan records the edge but never queues the child, so load would die.
|
|
578
|
+
for (const [, byParent] of scanner.imports) {
|
|
579
|
+
for (const [parentURL, specMap] of byParent) {
|
|
580
|
+
for (const [spec, childURL] of specMap) {
|
|
581
|
+
if (!scanner.files.has(childURL)) {
|
|
582
|
+
fatal.push(`${spec} from ${fileURLToPath(parentURL)} resolves to ${fileURLToPath(childURL)}, which a source bundle can't carry`)
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
const tolerated = scanner.unresolved.filter((u) => !fatalUnresolved(u))
|
|
588
|
+
const toleratedParse = scanner.parseErrors.filter((p) => !fatalParse(p))
|
|
589
|
+
return { fatal, tolerated, toleratedParse }
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Throw on any fatal scan issue (the bundle can't be written); warn on tolerated
|
|
593
|
+
// ones. `label`, when set, tags the scan pass in the message.
|
|
594
|
+
function reportScanIssues({ fatal, tolerated, toleratedParse }, { label = '' } = {}) {
|
|
595
|
+
const where = label ? ` (${label})` : ''
|
|
596
|
+
if (fatal.length > 0) {
|
|
597
|
+
const shown = fatal.slice(0, 10).map((s) => ` ${s}`).join('\n')
|
|
598
|
+
const more = fatal.length > 10 ? `\n ... and ${fatal.length - 10} more` : ''
|
|
599
|
+
throw new Error(`JS bundle would be broken at load time${where}:\n${shown}${more}`)
|
|
600
|
+
}
|
|
601
|
+
if (tolerated.length > 0) {
|
|
602
|
+
const summary = tolerated
|
|
603
|
+
.slice(0, 10)
|
|
604
|
+
.map((u) => ` ${u.kind} ${u.spec ?? '<dynamic>'} from ${fileURLToPath(u.parentURL)} (${u.reason})`)
|
|
605
|
+
.join('\n')
|
|
606
|
+
const more = tolerated.length > 10 ? `\n ... and ${tolerated.length - 10} more` : ''
|
|
607
|
+
console.warn(`[stasis] Bundle has ${tolerated.length} unresolved import(s)${where}; they will fall through at load time:\n${summary}${more}`)
|
|
608
|
+
}
|
|
609
|
+
if (toleratedParse.length > 0) {
|
|
610
|
+
const summary = toleratedParse
|
|
611
|
+
.slice(0, 10)
|
|
612
|
+
.map((p) => ` ${fileURLToPath(p.url)}: ${p.message}`)
|
|
613
|
+
.join('\n')
|
|
614
|
+
const more = toleratedParse.length > 10 ? `\n ... and ${toleratedParse.length - 10} more` : ''
|
|
615
|
+
console.warn(`[stasis] Bundle has ${toleratedParse.length} file(s) with parse errors${where}; their recorded imports may be incomplete:\n${summary}${more}`)
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Build a stasis Bundle (in-memory) from a list of entry .js/.cjs/.mjs (or
|
|
620
|
+
// .ts/.cts/.mts) files by statically scanning the require/import graph and
|
|
621
|
+
// reading reachable file contents from disk -- no user code is ever loaded or
|
|
622
|
+
// executed. TypeScript files behave exactly like JS ones: sources are stored
|
|
623
|
+
// verbatim with Node's type-stripping formats recorded, and Node strips the
|
|
624
|
+
// types at load time. The bundle shape matches what @exodus/stasis-core/hooks
|
|
625
|
+
// records at runtime, so the result loads via `stasis run --bundle=load`
|
|
626
|
+
// interchangeably with a runtime-produced one.
|
|
627
|
+
//
|
|
628
|
+
// Refuses to write when the bundle is guaranteed broken (or silently divergent
|
|
629
|
+
// from plain node) at load time: an unresolved static ESM `import`/`export ...
|
|
630
|
+
// from` edge reachable from an entry through static edges alone (link-time,
|
|
631
|
+
// uncatchable), a module file that can't be parsed (its static edges are
|
|
632
|
+
// unknown), or an edge resolving to a file a source bundle can't carry
|
|
633
|
+
// (extensionless, .node, .wasm). Failures that runtime code can catch --
|
|
634
|
+
// unresolved require()/dynamic import() edges, including entire subtrees
|
|
635
|
+
// behind such a boundary -- only warn (see below).
|
|
636
|
+
//
|
|
637
|
+
// Scope comes from the project's stasis.config.json (or `EXODUS_STASIS_SCOPE`);
|
|
638
|
+
// pass `scope` explicitly to override.
|
|
639
|
+
//
|
|
640
|
+
// `conditions` are extra `exports`/`imports` resolution conditions, merged on top
|
|
641
|
+
// of the format-derived base set Node always asserts (`node`, `import`/`require`,
|
|
642
|
+
// `module-sync`, `node-addons`). They change which branch of a package's
|
|
643
|
+
// conditional `exports` the static scan follows -- and therefore which file lands
|
|
644
|
+
// in the bundle -- exactly as if Node resolved with those conditions active. Empty
|
|
645
|
+
// (the default) reproduces plain Node resolution.
|
|
646
|
+
//
|
|
647
|
+
// NOTE: conditions are only one input to module resolution. On their own they do
|
|
648
|
+
// NOT honour legacy package `mainFields` (`react-native`/`browser`/`main`) or
|
|
649
|
+
// platform-specific file suffixes (`.ios`/`.android`/`.native`), neither of which
|
|
650
|
+
// the `exports` field expresses; `--mainFields` (see buildResolvedJsBundle) adds
|
|
651
|
+
// the former.
|
|
652
|
+
export async function buildJsBundle({ cwd = process.cwd(), entries, scope, conditions = [] } = {}) {
|
|
653
|
+
if (!Array.isArray(entries) || entries.length === 0) {
|
|
654
|
+
throw new Error('buildJsBundle: at least one entry .js/.cjs/.mjs/.ts/.cts/.mts file is required')
|
|
655
|
+
}
|
|
656
|
+
for (const e of entries) {
|
|
657
|
+
if (!JS_EXTS.has(extname(e))) throw new Error(`buildJsBundle: not a JS/TS file: ${e}`)
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const baseDir = resolve(cwd)
|
|
661
|
+
const absEntries = entries.map((e) => resolve(baseDir, e))
|
|
662
|
+
|
|
663
|
+
// Normalize conditions the way the CLI already does (trim, drop empties), so the
|
|
664
|
+
// programmatic API (`buildBundle({ conditions })`) tolerates the same sloppy input.
|
|
665
|
+
// A '' or whitespace-only condition never matches an `exports` key, so dropping it
|
|
666
|
+
// is a no-op for clean input -- this just stops a bogus token reaching the resolver.
|
|
667
|
+
const scanConditions = conditions.map((c) => (typeof c === 'string' ? c.trim() : c)).filter(Boolean)
|
|
668
|
+
|
|
669
|
+
const scanner = scan(absEntries, { conditions: scanConditions })
|
|
670
|
+
|
|
671
|
+
// Fail closed exactly where the bundle is GUARANTEED broken (or silently
|
|
672
|
+
// divergent from plain node) at load time; warn where runtime code can catch
|
|
673
|
+
// the miss the same way it would without a bundle. Static ESM edges load
|
|
674
|
+
// eagerly (uncatchable) so unresolved ones / unknown parse holes there are
|
|
675
|
+
// fatal; require()/dynamic-import() misses surface at the callsite and only
|
|
676
|
+
// warn -- the optional-dependency pattern (`try { require(x) } catch {}`) keeps
|
|
677
|
+
// bundling, because the runtime loader never records those edges either and
|
|
678
|
+
// state.getImport throws the same catchable MODULE_NOT_FOUND at load. Edges
|
|
679
|
+
// resolving to a file a source bundle can't carry are always fatal. (See
|
|
680
|
+
// analyzeScanner for the precise rules.)
|
|
681
|
+
reportScanIssues(analyzeScanner(scanner))
|
|
682
|
+
|
|
683
|
+
// Use a non-preload State to materialise the bundle: addFile bucketizes by
|
|
684
|
+
// package.json + records hashes/sources/formats, and addImport replays the
|
|
685
|
+
// per-edge import map. State.s `serialize` then emits the same v1 layout
|
|
686
|
+
// the runtime loader writes — see @exodus/stasis-core/hooks for the matching pair.
|
|
687
|
+
//
|
|
688
|
+
// bundle=replace skips reading any pre-existing stasis.code.br on disk;
|
|
689
|
+
// bundle=add would merge it, leaking stale formats/imports entries for
|
|
690
|
+
// files the new bundle doesn't carry. lock=ignore tolerates a pre-existing
|
|
691
|
+
// stasis.lock.json without loading or validating it (the bundle path
|
|
692
|
+
// doesn't consume one), and refusing to coexist with one (lock=none) would
|
|
693
|
+
// make `stasis bundle` brittle in any project that's also using `stasis run`.
|
|
694
|
+
const state = new State(baseDir, { bundle: 'replace', lock: 'ignore', ...(scope ? { scope } : {}) })
|
|
695
|
+
for (const [url, info] of scanner.files) {
|
|
696
|
+
const isEntry = scanner.entries.has(url)
|
|
697
|
+
state.addFile(url, { format: info.format, isEntry })
|
|
698
|
+
}
|
|
699
|
+
// Static bundles can't anticipate the full condition set Node will pass to
|
|
700
|
+
// its resolve hook at load time (Node adds runtime conditions like
|
|
701
|
+
// 'module-sync', 'node-addons' that aren't accept-list conditions). Store
|
|
702
|
+
// every edge under the wildcard '*' key; State.getImport falls back to it
|
|
703
|
+
// when the runtime-condition lookup misses.
|
|
704
|
+
for (const [, byParent] of scanner.imports) {
|
|
705
|
+
for (const [parentURL, specs] of byParent) {
|
|
706
|
+
for (const [spec, childURL] of specs) {
|
|
707
|
+
state.addImport(parentURL, spec, childURL, { conditions: '*' })
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
return state
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// Source extensions probed when a resolved entry/redirect target names no extension.
|
|
715
|
+
// Limited to what a source bundle can actually carry (scan's RESOLVABLE_EXTS), so the
|
|
716
|
+
// resolver never resolves a file the bundle would then reject. `--mainFields` lets a
|
|
717
|
+
// caller pick its own fields, `--metro` presets the React Native fields, and
|
|
718
|
+
// `--conditions` adds its own extra exports conditions.
|
|
719
|
+
const SOURCE_EXTS = ['js', 'json', 'ts']
|
|
720
|
+
// React Native preset mainFields asserted by `--metro` (which sets these plus the
|
|
721
|
+
// react-native/browser conditions and platform suffixes, so the user supplies neither
|
|
722
|
+
// --mainFields nor --conditions alongside it).
|
|
723
|
+
const METRO_MAIN_FIELDS = ['react-native', 'browser', 'main']
|
|
724
|
+
// Synthetic, project-relative path for the empty module a browser/react-native
|
|
725
|
+
// `false` redirect resolves to. Carried in the bundle as a real (empty) CJS file
|
|
726
|
+
// so the edge points at attestable bytes rather than a sentinel.
|
|
727
|
+
const EMPTY_MODULE_PATH = '.stasis/empty-module.js'
|
|
728
|
+
|
|
729
|
+
// Build a JS/TS Bundle (and companion Lockfile) through the legacy-field resolver
|
|
730
|
+
// (src/resolve-fields.js) rather than Node's resolver -- the `--mainFields` and
|
|
731
|
+
// `--metro` paths. The graph is scanned once PER platform (just `[null]` in
|
|
732
|
+
// `--mainFields` mode, where there are no platform suffixes); the file set is the union
|
|
733
|
+
// across platforms, and each `(parent, specifier)` edge is recorded FLAT (a single
|
|
734
|
+
// target) when every platform that has it agrees, and UNFLATTENED to a
|
|
735
|
+
// `{ platform: target }` map only where platforms genuinely diverge. A single platform
|
|
736
|
+
// therefore never unflattens; platform keys are exactly the supplied platforms (no
|
|
737
|
+
// wildcard). A browser/react-native `false` redirect resolves to a synthetic empty
|
|
738
|
+
// module carried in the bundle. Returns { bundle, lockfile } (the lockfile attests the
|
|
739
|
+
// same files + the same -- possibly per-platform -- edges, by integrity).
|
|
740
|
+
async function buildResolvedJsBundle({ cwd = process.cwd(), entries, mainFields, platforms, conditions = [], metro = false }) {
|
|
741
|
+
const baseDir = resolve(cwd)
|
|
742
|
+
const absEntries = entries.map((e) => resolve(baseDir, e))
|
|
743
|
+
const normalized = normalizeEntries(entries, cwd)
|
|
744
|
+
|
|
745
|
+
const toRel = (abs) => {
|
|
746
|
+
const rel = relative(baseDir, abs).split(/[\\/]/u).join('/')
|
|
747
|
+
if (rel.startsWith('..') || isAbsolute(rel)) {
|
|
748
|
+
throw new Error(`Bundle would reach a file outside the project root: ${abs}`)
|
|
749
|
+
}
|
|
750
|
+
return rel
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// Normalize conditions like buildJsBundle does, so a sloppy programmatic caller
|
|
754
|
+
// (stray '' / whitespace) can't push a bogus token into the resolver.
|
|
755
|
+
const scanConditions = conditions.map((c) => (typeof c === 'string' ? c.trim() : c)).filter(Boolean)
|
|
756
|
+
|
|
757
|
+
const formatsByRel = new Map()
|
|
758
|
+
// parentRel -> specifier -> Map<platformKey, targetRel>. Collapsed into flat /
|
|
759
|
+
// platform-map edges after every platform has been scanned.
|
|
760
|
+
const edges = new Map()
|
|
761
|
+
const reached = new Set() // absolute paths of every real file reached on any platform
|
|
762
|
+
let usesEmpty = false
|
|
763
|
+
|
|
764
|
+
for (const platform of platforms) {
|
|
765
|
+
// `--metro` asserts the React Native preset's conditions (react-native, plus
|
|
766
|
+
// browser for the web platform); `--mainFields` (platform === null) carries the
|
|
767
|
+
// user's own `--conditions`. scan layers these on the format-derived base.
|
|
768
|
+
const extras = metro ? ['react-native', ...(platform === 'web' ? ['browser'] : [])] : scanConditions
|
|
769
|
+
const resolver = createFieldResolver({
|
|
770
|
+
mainFields,
|
|
771
|
+
platform,
|
|
772
|
+
preferNative: platform !== null && platform !== 'web',
|
|
773
|
+
sourceExts: SOURCE_EXTS,
|
|
774
|
+
conditions: resolveConditions('commonjs', extras),
|
|
775
|
+
})
|
|
776
|
+
const scanner = scan(absEntries, { conditions: extras, resolve: resolver })
|
|
777
|
+
reportScanIssues(analyzeScanner(scanner), { label: platform ?? 'mainFields' })
|
|
778
|
+
|
|
779
|
+
const platformKey = platform ?? '*' // '*' is a private placeholder for the single mainFields pass; it never unflattens
|
|
780
|
+
for (const [url, info] of scanner.files) {
|
|
781
|
+
const rel = toRel(fileURLToPath(url))
|
|
782
|
+
reached.add(fileURLToPath(url))
|
|
783
|
+
formatsByRel.set(rel, info.format)
|
|
784
|
+
for (const e of info.edges) {
|
|
785
|
+
if (e.builtin || e.dynamic) continue
|
|
786
|
+
let target
|
|
787
|
+
if (e.empty) { usesEmpty = true; target = EMPTY_MODULE_PATH }
|
|
788
|
+
else if (e.child) target = toRel(fileURLToPath(e.child))
|
|
789
|
+
else continue // unresolved (already warned/thrown by reportScanIssues)
|
|
790
|
+
if (!edges.has(rel)) edges.set(rel, new Map())
|
|
791
|
+
const bySpec = edges.get(rel)
|
|
792
|
+
if (!bySpec.has(e.spec)) bySpec.set(e.spec, new Map())
|
|
793
|
+
bySpec.get(e.spec).set(platformKey, target)
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Read every reached file's bytes once: content for the bundle, integrity for the
|
|
799
|
+
// lockfile (hash the raw bytes so it matches `stasis run --lock=frozen`). The stored
|
|
800
|
+
// source is UTF-8 text, so the bytes must BE valid UTF-8 -- otherwise toString('utf8')
|
|
801
|
+
// would lossily diverge from the hashed bytes.
|
|
802
|
+
const sources = new Map()
|
|
803
|
+
const integrities = new Map()
|
|
804
|
+
const realBase = realpathSync(baseDir)
|
|
805
|
+
for (const abs of reached) {
|
|
806
|
+
const rel = toRel(abs)
|
|
807
|
+
// A bundle must carry only in-tree, attestable bytes. The field resolver returns the
|
|
808
|
+
// lexical path it probed, so an in-tree-named symlink whose real target escapes the
|
|
809
|
+
// project root would slip past toRel's textual check -- realpath it and fail closed,
|
|
810
|
+
// the same way the State-based JS path and the non-JS loaders do.
|
|
811
|
+
assertRealPathWithinBase(realBase, baseDir, rel)
|
|
812
|
+
const buf = readFileSync(abs)
|
|
813
|
+
if (!isUtf8(buf)) throw new Error(`JS bundle source is not valid UTF-8: ${rel}`)
|
|
814
|
+
sources.set(rel, buf.toString('utf8'))
|
|
815
|
+
integrities.set(rel, sha512integrity(buf))
|
|
816
|
+
}
|
|
817
|
+
if (usesEmpty) {
|
|
818
|
+
// The empty module is synthetic; refuse to shadow a real reached file that
|
|
819
|
+
// happens to sit at the reserved path rather than silently clobber it.
|
|
820
|
+
if (formatsByRel.has(EMPTY_MODULE_PATH)) {
|
|
821
|
+
throw new Error(`Bundle needs the reserved empty-module path ${EMPTY_MODULE_PATH}, but the project has a real file there`)
|
|
822
|
+
}
|
|
823
|
+
sources.set(EMPTY_MODULE_PATH, '')
|
|
824
|
+
formatsByRel.set(EMPTY_MODULE_PATH, 'commonjs')
|
|
825
|
+
integrities.set(EMPTY_MODULE_PATH, sha512integrity(Buffer.alloc(0)))
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// Collapse each edge: one distinct target across the platforms that have it -> flat
|
|
829
|
+
// string; otherwise a Map<platform, target> (platform keys only, sorted).
|
|
830
|
+
const resolutions = new Map()
|
|
831
|
+
for (const [parent, bySpec] of edges) {
|
|
832
|
+
const specMap = new Map()
|
|
833
|
+
for (const [spec, byPlatform] of bySpec) {
|
|
834
|
+
const distinct = new Set(byPlatform.values())
|
|
835
|
+
if (distinct.size === 1) {
|
|
836
|
+
specMap.set(spec, [...distinct][0])
|
|
837
|
+
} else {
|
|
838
|
+
specMap.set(spec, new Map([...byPlatform].toSorted((a, b) => (a[0] < b[0] ? -1 : 1))))
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
resolutions.set(parent, specMap)
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
const rootPkg = readJson(join(baseDir, 'package.json')) ?? {}
|
|
845
|
+
const bundle = assembleCodeBundle({
|
|
846
|
+
baseDir,
|
|
847
|
+
entries: normalized,
|
|
848
|
+
sources,
|
|
849
|
+
resolutions,
|
|
850
|
+
formats: formatsByRel,
|
|
851
|
+
workspaceName: rootPkg.name ?? 'workspace',
|
|
852
|
+
workspaceVersion: rootPkg.version ?? '0.0.0',
|
|
853
|
+
conditionKey: '*',
|
|
854
|
+
})
|
|
855
|
+
|
|
856
|
+
// The companion lockfile mirrors the bundle exactly, swapping file content for
|
|
857
|
+
// its integrity. modules/imports/formats/entries are shared shapes, so a frozen
|
|
858
|
+
// lockfile attests the same resolution graph the bundle carries.
|
|
859
|
+
const lockModules = new Map()
|
|
860
|
+
for (const [dir, m] of bundle.modules) {
|
|
861
|
+
const files = Object.create(null)
|
|
862
|
+
for (const rel of Object.keys(m.files)) files[rel] = integrities.get(moduleFileKey(dir, rel))
|
|
863
|
+
lockModules.set(dir, { name: m.name, version: m.version, ...(m.ecosystem === undefined ? {} : { ecosystem: m.ecosystem }), files })
|
|
864
|
+
}
|
|
865
|
+
const lockfile = new Lockfile({
|
|
866
|
+
config: bundle.config,
|
|
867
|
+
entries: bundle.entries,
|
|
868
|
+
modules: lockModules,
|
|
869
|
+
imports: bundle.imports,
|
|
870
|
+
formats: bundle.formats,
|
|
871
|
+
})
|
|
872
|
+
|
|
873
|
+
return { bundle, lockfile }
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// Classify entries into the single bundle language they all share and check
|
|
877
|
+
// option applicability. Shared by buildBundle and bundleCommand; `name`
|
|
878
|
+
// prefixes error messages with the caller.
|
|
879
|
+
function classifyEntries(name, { entries, mappingFile, scope, lockfile, conditions, mainFields, platforms, metro }) {
|
|
880
|
+
if (!Array.isArray(entries) || entries.length === 0) {
|
|
881
|
+
throw new Error(`${name}: at least one entry file is required`)
|
|
882
|
+
}
|
|
883
|
+
let kind
|
|
884
|
+
if (entries.every((e) => e.endsWith('.sol'))) kind = 'sol'
|
|
885
|
+
else if (entries.every((e) => e.endsWith('.php'))) kind = 'php'
|
|
886
|
+
else if (entries.every((e) => JS_EXTS.has(extname(e)))) kind = 'js'
|
|
887
|
+
else if (entries.every((e) => BASH_EXTS.has(extname(e)))) kind = 'bash'
|
|
888
|
+
else if (entries.every((e) => RUST_EXTS.has(extname(e)))) kind = 'rust'
|
|
889
|
+
else {
|
|
890
|
+
throw new Error(`${name}: entries must all be .sol, all be .php, all be .js/.cjs/.mjs/.ts/.cts/.mts, all be .sh/.bash, or all be .rs (no mixing)`)
|
|
891
|
+
}
|
|
892
|
+
if (mappingFile && kind !== 'sol') {
|
|
893
|
+
throw new Error(`${name}: --mapping is only valid for .sol bundles`)
|
|
894
|
+
}
|
|
895
|
+
if (scope !== undefined && kind !== 'js') {
|
|
896
|
+
throw new Error(`${name}: --scope is only valid for JS bundles`)
|
|
897
|
+
}
|
|
898
|
+
if (lockfile !== undefined && kind !== 'js') {
|
|
899
|
+
throw new Error(`${name}: --lockfile is only valid for JS bundles`)
|
|
900
|
+
}
|
|
901
|
+
// Conditions only steer Node's `exports`/`imports` resolution, which the other
|
|
902
|
+
// languages' resolvers don't consult -- reject them rather than accept a flag
|
|
903
|
+
// that would silently do nothing.
|
|
904
|
+
if (Array.isArray(conditions) && conditions.length > 0 && kind !== 'js') {
|
|
905
|
+
throw new Error(`${name}: --conditions is only valid for JS bundles`)
|
|
906
|
+
}
|
|
907
|
+
// --mainFields and --metro/--platforms are legacy-/platform-resolution knobs; JS-only.
|
|
908
|
+
if (mainFields !== undefined && kind !== 'js') {
|
|
909
|
+
throw new Error(`${name}: --mainFields is only valid for JS bundles`)
|
|
910
|
+
}
|
|
911
|
+
if ((metro || (Array.isArray(platforms) && platforms.length > 0)) && kind !== 'js') {
|
|
912
|
+
throw new Error(`${name}: --metro is only valid for JS bundles`)
|
|
913
|
+
}
|
|
914
|
+
// `--metro` is a preset for conditions + mainFields + platform suffixes, so it can't
|
|
915
|
+
// be combined with explicit --conditions/--mainFields (they'd conflict), and it needs
|
|
916
|
+
// --platforms to know which suffixes to resolve. `--platforms` is meaningless without it.
|
|
917
|
+
if (metro) {
|
|
918
|
+
if (Array.isArray(conditions) && conditions.length > 0) {
|
|
919
|
+
throw new Error(`${name}: --conditions can't be combined with --metro (it sets its own conditions)`)
|
|
920
|
+
}
|
|
921
|
+
if (mainFields !== undefined) {
|
|
922
|
+
throw new Error(`${name}: --mainFields can't be combined with --metro (it sets its own mainFields)`)
|
|
923
|
+
}
|
|
924
|
+
if (!Array.isArray(platforms) || platforms.length === 0) {
|
|
925
|
+
throw new Error(`${name}: --metro requires --platforms (e.g. --platforms=ios,android)`)
|
|
926
|
+
}
|
|
927
|
+
// A platform name becomes a bundle/lockfile edge key (Bundle/Lockfile parse reject
|
|
928
|
+
// a '/'); '*' is the reserved private placeholder. Reject both so the writer can
|
|
929
|
+
// never emit an edge map the readers refuse -- a divergent-edge, data-dependent break.
|
|
930
|
+
for (const p of platforms) {
|
|
931
|
+
if (typeof p !== 'string' || p.length === 0 || p === '*' || p.includes('/')) {
|
|
932
|
+
throw new Error(`${name}: invalid platform '${p}' (a platform name can't be empty, contain '/', or be '*')`)
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
} else if (Array.isArray(platforms) && platforms.length > 0) {
|
|
936
|
+
throw new Error(`${name}: --platforms is only valid with --metro`)
|
|
937
|
+
}
|
|
938
|
+
// The field resolver always emits a full-scope bundle (no node_modules-only mode), so
|
|
939
|
+
// --scope alongside --mainFields/--metro would be silently ignored -- reject it.
|
|
940
|
+
if (scope !== undefined && (mainFields !== undefined || metro)) {
|
|
941
|
+
throw new Error(`${name}: --scope is not supported with --mainFields or --metro`)
|
|
942
|
+
}
|
|
943
|
+
return kind
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// Programmatic equivalent of `stasis bundle`: build and return an in-memory
|
|
947
|
+
// Bundle from a list of entry files, without writing anything to disk
|
|
948
|
+
// (serialize with bundle.serialize() to get the JSON that `stasis bundle`
|
|
949
|
+
// brotli-compresses into stasis.code.br).
|
|
950
|
+
//
|
|
951
|
+
// Dispatch by extension matches the CLI: all entries must be one language —
|
|
952
|
+
// .sol (Solidity), .php (PHP), .js/.cjs/.mjs/.ts/.cts/.mts (JS/TS, via static
|
|
953
|
+
// scan), .sh/.bash (Bash), or .rs (Rust); mixing fails fast. `mappingFile` is
|
|
954
|
+
// the optional Solidity remappings file (foundry.toml or remappings.txt) and
|
|
955
|
+
// is only valid for .sol entries; `scope` overrides the configured bundle
|
|
956
|
+
// scope and is only valid for JS/TS entries; `conditions` are extra `exports`
|
|
957
|
+
// resolution conditions (e.g. `['react-native']`) and are JS/TS-only too.
|
|
958
|
+
//
|
|
959
|
+
// `mainFields` (an array of legacy package fields, e.g. `['react-native','browser','main']`)
|
|
960
|
+
// selects the legacy-field resolver -- see buildResolvedJsBundle. It resolves
|
|
961
|
+
// non-`exports` packages via those fields, including the browser-field object
|
|
962
|
+
// redirection, and composes with `conditions`. `metro: true` is instead a preset that
|
|
963
|
+
// sets its OWN React Native fields + conditions + platform suffixes (so it is not
|
|
964
|
+
// combined with `mainFields`/`conditions`) and requires `platforms` (e.g.
|
|
965
|
+
// `['ios','android']`); the edge graph is then per-platform. JS/TS-only.
|
|
966
|
+
export async function buildBundle({ cwd = process.cwd(), entries, mappingFile, scope, conditions, mainFields, platforms, metro } = {}) {
|
|
967
|
+
const kind = classifyEntries('buildBundle', { entries, mappingFile, scope, conditions, mainFields, platforms, metro })
|
|
968
|
+
if (kind === 'sol') return buildSolidityBundle({ cwd, entries, mappingFile })
|
|
969
|
+
if (kind === 'php') return buildPhpBundle({ cwd, entries })
|
|
970
|
+
if (kind === 'bash') return buildBashBundle({ cwd, entries })
|
|
971
|
+
if (kind === 'rust') return buildRustBundle({ cwd, entries })
|
|
972
|
+
if (metro || mainFields !== undefined) {
|
|
973
|
+
const { bundle } = await buildResolvedJsBundle({
|
|
974
|
+
cwd,
|
|
975
|
+
entries,
|
|
976
|
+
mainFields: metro ? METRO_MAIN_FIELDS : mainFields,
|
|
977
|
+
platforms: metro ? platforms : [null],
|
|
978
|
+
conditions,
|
|
979
|
+
metro: Boolean(metro),
|
|
980
|
+
})
|
|
981
|
+
return bundle
|
|
982
|
+
}
|
|
983
|
+
const state = await buildJsBundle({ cwd, entries, scope, conditions })
|
|
984
|
+
return state.sourceBundle
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// Where `stasis bundle` writes when no --output is given: stasis.code.br, the
|
|
988
|
+
// same name `stasis run --bundle=load` discovers by default, so the two commands
|
|
989
|
+
// round-trip with no flags. A file (not stdout) is the common case; pass
|
|
990
|
+
// --output=- to stream the raw brotli bytes to stdout.
|
|
991
|
+
const DEFAULT_BUNDLE_FILE = 'stasis.code.br'
|
|
992
|
+
|
|
165
993
|
// Run the bundle CLI command end-to-end. Always produces a brotli-compressed
|
|
166
|
-
// stasis bundle (matching the on-disk format of `stasis.code.br`).
|
|
167
|
-
// `output`
|
|
168
|
-
// Prints a one-line `[stasis] Bundled <n> files
|
|
169
|
-
// to stderr so it doesn't interleave
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
994
|
+
// stasis bundle (matching the on-disk format of `stasis.code.br`). Writes to
|
|
995
|
+
// `output` when given, to stasis.code.br in `cwd` by default, or to stdout
|
|
996
|
+
// when `output` is `-`. Prints a one-line `[stasis] Bundled <n> files in <p>
|
|
997
|
+
// packages from <dir> to <dest>` summary to stderr so it doesn't interleave
|
|
998
|
+
// with binary output written to stdout.
|
|
999
|
+
//
|
|
1000
|
+
// Dispatch by extension: all entries must be one language — .sol (Solidity),
|
|
1001
|
+
// .php (PHP), .js/.cjs/.mjs/.ts/.cts/.mts (JS/TS, via static scan), .sh/.bash
|
|
1002
|
+
// (Bash, via source/exec graph walk), or .rs (Rust, via mod-declaration walk).
|
|
1003
|
+
// Mixing fails fast; --mapping is .sol only, --scope/--lockfile/--conditions are
|
|
1004
|
+
// JS/TS only.
|
|
1005
|
+
//
|
|
1006
|
+
// `conditions` are extra Node `exports`/`imports` resolution conditions to assert
|
|
1007
|
+
// during the static scan (e.g. `['react-native']` or `['browser']`). They only
|
|
1008
|
+
// affect `exports`/`imports` condition matching -- not legacy mainFields -- so on
|
|
1009
|
+
// their own they don't honour a package's legacy entry fields. See buildJsBundle.
|
|
1010
|
+
//
|
|
1011
|
+
// For JS bundles, an optional `lockfile` path writes a stasis.lock.json that
|
|
1012
|
+
// attests every file in the bundle. The static scan walks both branches of
|
|
1013
|
+
// conditional requires (e.g. debug/src/index.js' `if (browser) require('./browser')
|
|
1014
|
+
// else require('./node')`), so the runtime lockfile from `stasis run --lock=add`
|
|
1015
|
+
// won't attest the unused branch -- if the user wants `lock=frozen` against a
|
|
1016
|
+
// statically-built bundle, they need this companion lockfile.
|
|
1017
|
+
//
|
|
1018
|
+
// With `--conditions`, that companion lockfile attests the conditions-SELECTED
|
|
1019
|
+
// resolution graph. A plain `stasis run --lock=frozen` doesn't replay `--conditions`,
|
|
1020
|
+
// so it resolves different files on disk and fails closed (correctly -- the lockfile
|
|
1021
|
+
// genuinely attests a different graph). Pair such a lockfile with `--bundle=load`, or
|
|
1022
|
+
// replay the same `--conditions` at run time, so the attested and observed graphs agree.
|
|
1023
|
+
export async function bundleCommand({ cwd = process.cwd(), entries, mappingFile, output, scope, lockfile, conditions, mainFields, platforms, metro } = {}) {
|
|
1024
|
+
const kind = classifyEntries('bundleCommand', { entries, mappingFile, scope, lockfile, conditions, mainFields, platforms, metro })
|
|
1025
|
+
|
|
1026
|
+
let bundle
|
|
1027
|
+
let lockData
|
|
1028
|
+
if (kind === 'js' && (metro || mainFields !== undefined)) {
|
|
1029
|
+
// The legacy-field resolver path builds the Bundle (and the matching Lockfile)
|
|
1030
|
+
// directly -- it carries per-platform edges and a synthetic empty module (for a
|
|
1031
|
+
// browser/react-native `false` redirect) that State's disk-reading addFile can't
|
|
1032
|
+
// represent.
|
|
1033
|
+
const built = await buildResolvedJsBundle({
|
|
1034
|
+
cwd,
|
|
1035
|
+
entries,
|
|
1036
|
+
mainFields: metro ? METRO_MAIN_FIELDS : mainFields,
|
|
1037
|
+
platforms: metro ? platforms : [null],
|
|
1038
|
+
conditions,
|
|
1039
|
+
metro: Boolean(metro),
|
|
1040
|
+
})
|
|
1041
|
+
bundle = built.bundle
|
|
1042
|
+
if (lockfile) lockData = built.lockfile.serialize()
|
|
1043
|
+
} else if (kind === 'js' && lockfile) {
|
|
1044
|
+
// The companion lockfile attests file hashes, which only State carries —
|
|
1045
|
+
// a Bundle holds sources, not digests — so this path keeps the State.
|
|
1046
|
+
const state = await buildJsBundle({ cwd, entries, scope, conditions })
|
|
1047
|
+
bundle = state.sourceBundle
|
|
1048
|
+
lockData = state.lockData
|
|
177
1049
|
} else {
|
|
178
|
-
|
|
1050
|
+
bundle = await buildBundle({ cwd, entries, mappingFile, scope, conditions })
|
|
179
1051
|
}
|
|
180
|
-
|
|
181
|
-
// paths, so the summary reflects every file we wrote — workspace and
|
|
182
|
-
// node_modules alike — not just the workspace bucket.
|
|
1052
|
+
const serialized = bundle.serialize()
|
|
183
1053
|
const files = [...bundle.sources.keys()]
|
|
1054
|
+
const modules = bundle.modules
|
|
1055
|
+
|
|
1056
|
+
const data = brotliCompressSync(serialized, brotliOptions())
|
|
1057
|
+
// Default to writing stasis.code.br in cwd; `-` is the conventional opt-in
|
|
1058
|
+
// for streaming the raw brotli bytes to stdout (e.g. to pipe somewhere else).
|
|
1059
|
+
const target = output ?? DEFAULT_BUNDLE_FILE
|
|
1060
|
+
if (target === '-') {
|
|
1061
|
+
process.stdout.write(data)
|
|
1062
|
+
} else {
|
|
1063
|
+
const outAbs = resolve(cwd, target)
|
|
1064
|
+
mkdirSync(dirname(outAbs), { recursive: true })
|
|
1065
|
+
writeFileSync(outAbs, data)
|
|
1066
|
+
}
|
|
1067
|
+
if (lockData) {
|
|
1068
|
+
const lockAbs = resolve(cwd, lockfile)
|
|
1069
|
+
mkdirSync(dirname(lockAbs), { recursive: true })
|
|
1070
|
+
writeFileSync(lockAbs, lockData)
|
|
1071
|
+
}
|
|
184
1072
|
const fromDir = outermostDir(files, resolve(cwd))
|
|
185
|
-
const dest =
|
|
186
|
-
|
|
187
|
-
|
|
1073
|
+
const dest = target === '-' ? '<stdout>' : target
|
|
1074
|
+
// Count packages: every non-empty bucket, with the project's own source (the
|
|
1075
|
+
// "." workspace bucket) counting as one package alongside each dependency.
|
|
1076
|
+
const packages = [...modules.values()].filter((m) => Object.keys(m.files).length > 0).length
|
|
1077
|
+
const pkgLabel = `${packages} package${packages === 1 ? '' : 's'}`
|
|
1078
|
+
console.warn(`[stasis] Bundled ${files.length} files in ${pkgLabel} from ${fromDir} to ${dest}`)
|
|
188
1079
|
}
|