@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/README.md +9 -2
- package/bin/stasis.js +253 -22
- package/package.json +42 -10
- package/src/audit-corrections.js +72 -0
- package/src/audit.js +181 -112
- package/src/bundle-from-lockfile.js +3 -5
- package/src/bundle-reader.js +77 -0
- package/src/cmd/build.js +3 -5
- package/src/cmd/bundle.js +677 -402
- package/src/cmd/github-bundle.js +39 -0
- package/src/cmd/output.js +36 -0
- package/src/cmd/sbom.js +3 -11
- package/src/diff.js +22 -14
- package/src/json-stream.js +283 -0
- package/src/loaders/bash.js +17 -36
- package/src/loaders/cargo.js +1716 -0
- package/src/loaders/foundry.js +923 -0
- package/src/loaders/paths.js +33 -0
- package/src/loaders/php.js +212 -127
- package/src/loaders/rust.js +3234 -166
- package/src/loaders/solidity-ownership.js +531 -0
- package/src/loaders/solidity.js +412 -137
- package/src/loaders/toml.js +68 -0
- package/src/metro-resolver.js +9 -24
- package/src/mock.js +3 -5
- package/src/resolve-fields.js +338 -0
- package/src/resolve-node.js +416 -0
- package/src/resolve-typescript.js +541 -0
- package/src/rollup.js +5 -0
- package/src/sbom.js +35 -19
- package/src/scan.js +211 -90
- package/src/vfs-bundle/entries.js +140 -0
- package/src/vfs-bundle/github.js +199 -0
- package/src/vfs-bundle/prisma.js +439 -0
- package/src/vfs-bundle/soldeer.js +125 -0
- package/src/vfs-bundle/tree.js +613 -0
- package/src/vfs-bundle.js +75 -0
- package/src/why.js +434 -0
- package/src/apis/npm/index.js +0 -36
- package/src/apis/npm/semver.cjs +0 -42
package/src/cmd/bundle.js
CHANGED
|
@@ -1,35 +1,43 @@
|
|
|
1
1
|
import { isUtf8 } from 'node:buffer'
|
|
2
|
-
import { existsSync,
|
|
3
|
-
import { dirname, extname,
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
-
//
|
|
84
|
-
|
|
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
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
124
|
-
// workspace logic.
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
138
|
-
return { bucketDir: sub, name, version:
|
|
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
|
-
//
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
if (
|
|
152
|
-
|
|
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
|
|
162
|
-
//
|
|
163
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
|
189
|
-
//
|
|
190
|
-
//
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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.
|
|
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 =
|
|
220
|
+
const meta = packageOf(path)
|
|
213
221
|
const inNodeModules = splitNodeModulesPath(path) !== null
|
|
214
222
|
if (meta) {
|
|
215
|
-
if (inNodeModules && !meta.pkgDir
|
|
223
|
+
if (inNodeModules && !hasNodeModulesSegment(meta.pkgDir)) {
|
|
216
224
|
throw new Error(`No package.json with name+version found for ${path}`)
|
|
217
225
|
}
|
|
218
|
-
|
|
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
|
-
//
|
|
250
|
-
//
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
256
|
-
|
|
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
|
|
264
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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:
|
|
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
|
|
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
|
|
309
|
-
|
|
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
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
354
|
-
|
|
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
|
-
|
|
357
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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 && !
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
//
|
|
590
|
-
//
|
|
591
|
-
//
|
|
592
|
-
|
|
593
|
-
|
|
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
|
|
601
|
-
// (cycle/escape hazard)
|
|
602
|
-
|
|
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 =
|
|
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))
|
|
614
|
-
} else if (ent.isFile() &&
|
|
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
|
-
//
|
|
621
|
-
|
|
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
|
-
|
|
646
|
-
const hasIos =
|
|
647
|
-
const hasAndroid =
|
|
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)
|
|
651
|
-
if (hasAndroid)
|
|
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)
|
|
665
|
-
if (
|
|
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
|
-
|
|
672
|
-
const scanConditions = conditions.map((c) => (typeof c === 'string' ? c.trim() : c)).filter(Boolean)
|
|
890
|
+
const scanConditions = cleanConditions(conditions)
|
|
673
891
|
|
|
674
|
-
//
|
|
675
|
-
|
|
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
|
-
|
|
689
|
-
const extras =
|
|
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
|
|
692
|
-
//
|
|
693
|
-
//
|
|
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
|
-
:
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
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
|
|
711
|
-
|
|
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
|
-
|
|
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 =
|
|
963
|
+
assertRealPathWithinBase(realBase, baseDir, rel, host)
|
|
964
|
+
const buf = host.readFile(abs)
|
|
739
965
|
if (resourceRels.has(rel)) {
|
|
740
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
770
|
-
|
|
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)
|
|
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 =
|
|
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 (!
|
|
795
|
-
|
|
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
|
-
|
|
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 (
|
|
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 =
|
|
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
|
-
|
|
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
|
|
824
|
-
// assembleCodeBundle derives (findPackageMetadata -> pkgDir, else the
|
|
825
|
-
//
|
|
826
|
-
// same manifest. readModuleManifest applies the read/validate rules shared
|
|
827
|
-
// (containment, UTF-8-aborts); no identity check here -- these buckets are
|
|
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
|
|
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
|
-
|
|
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, {
|
|
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
|
|
898
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
985
|
-
//
|
|
986
|
-
//
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
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
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
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
|
-
//
|
|
1015
|
-
|
|
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
|
|
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 &&
|
|
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
|
-
|
|
1034
|
-
let
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
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
|
|
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
|
|
1099
|
-
if (
|
|
1100
|
-
|
|
1101
|
-
|
|
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 ${
|
|
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(
|
|
1395
|
+
console.warn(bundledSummary(files.length, modules, fromDir, dest))
|
|
1121
1396
|
}
|
|
1122
1397
|
}
|