@exodus/stasis 1.0.0-beta.5 → 1.0.0-beta.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cmd/bundle.js CHANGED
@@ -1,19 +1,19 @@
1
1
  import { isUtf8 } from 'node:buffer'
2
2
  import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'
3
- import { extname, join, posix, relative, resolve } from 'node:path'
3
+ import { dirname, extname, join, posix, relative, resolve } from 'node:path'
4
4
  import { fileURLToPath } from 'node:url'
5
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 } from '../resolve-typescript.js'
11
+ import { discoverTsconfig, isDir, loadTsconfigPaths, packageTsconfigPaths } from '../resolve-typescript.js'
12
12
  import { createMetroResolver } from '../metro-resolver.js'
13
13
  import { State } from '@exodus/stasis-core/state'
14
14
  import { sha512integrity } from '@exodus/stasis-core/state-util'
15
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, isPodspec, isSkippedNativeWalkDir, moduleFileKey, parseResourcesOption, posixPathEscapes, refineNativeCapture, relativeEscapes, splitNodeModulesPath, toPosix } from '@exodus/stasis-core/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
17
  import { diskHost } from '@exodus/stasis-core/host'
18
18
  import {
19
19
  SOLIDITY_PACKAGE_MANIFESTS,
@@ -37,7 +37,7 @@ import {
37
37
  } from '../loaders/php.js'
38
38
  import { DEFAULT_BUNDLE_FILE, bundledSummary, packagesLabel, writeBundle, writeFile } from './output.js'
39
39
 
40
- 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'])
41
41
  const BASH_EXTS = new Set(['.sh', '.bash'])
42
42
  const RUST_EXTS = new Set(['.rs'])
43
43
 
@@ -124,16 +124,26 @@ function makeSolidityClassifier(baseDir, ownership, host) {
124
124
  }
125
125
  }
126
126
 
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'
135
+ }
136
+
127
137
  // Classify a Rust file by the nearest Cargo.toml `[package]`: a `cargo vendor`ed crate under the
128
138
  // vendor dir (`vendor/<dir>/`, or where .cargo/config.toml points) is a dependency (tagged
129
- // `cargo`); any other package (the crate itself, a workspace member reached through a `path`
130
- // dependency) is first-party, so no ecosystem. Null (no manifest above the file) defers to the
131
- // package.json/placeholder logic.
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.
132
142
  function makeRustClassifier(cargo) {
133
143
  return (path) => {
134
144
  const pkg = cargo.packageInfo(path)
135
145
  if (!pkg) return null
136
- return { bucketDir: pkg.dir, name: pkg.name, version: pkg.version, ecosystem: cargo.isVendored(path) ? 'cargo' : undefined }
146
+ return { bucketDir: pkg.dir, name: pkg.name, version: pkg.version, ecosystem: cargo.isVendored(path) ? vendoredEcosystem(cargo.vendoredFrom(path)) : undefined }
137
147
  }
138
148
  }
139
149
 
@@ -195,19 +205,16 @@ function assembleCodeBundle({
195
205
  packageOf = packageLookup(baseDir, { host }),
196
206
  }) {
197
207
  const modules = new Map()
198
- const ensureBucket = (dir, name, version, bucketEcosystem) => {
199
- if (!modules.has(dir)) {
200
- modules.set(dir, bucketEcosystem === undefined
201
- ? { name, version, files: Object.create(null) }
202
- : { name, version, ecosystem: bucketEcosystem, files: Object.create(null) })
203
- }
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) }))
204
211
  return modules.get(dir)
205
212
  }
206
213
 
207
214
  for (const [path, content] of sources) {
208
215
  const dep = classifyDep?.(path)
209
216
  if (dep) {
210
- ensureBucket(dep.bucketDir, dep.name, dep.version, dep.ecosystem).files[fileInBucket(dep.bucketDir, path)] = content
217
+ ensureBucket(dep.bucketDir, dep).files[fileInBucket(dep.bucketDir, path)] = content
211
218
  continue
212
219
  }
213
220
  const meta = packageOf(path)
@@ -216,11 +223,10 @@ function assembleCodeBundle({
216
223
  if (inNodeModules && !hasNodeModulesSegment(meta.pkgDir)) {
217
224
  throw new Error(`No package.json with name+version found for ${path}`)
218
225
  }
219
- const bucketEcosystem = hasNodeModulesSegment(meta.pkgDir) ? 'npm' : undefined
220
- ensureBucket(meta.pkgDir, meta.name, meta.version, bucketEcosystem).files[fileInBucket(meta.pkgDir, path)] = 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
 
@@ -586,6 +592,13 @@ function displayPath(url, baseDir) {
586
592
  return rel && !relativeEscapes(rel) ? rel : abs
587
593
  }
588
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})`
600
+ }
601
+
589
602
  // Classify scanner unresolved edges + parse errors into fatal (broken/divergent at load)
590
603
  // vs tolerated (a catchable runtime miss). Shared so the plain and --mainFields JS paths
591
604
  // gate identically. `baseDir` relativizes the paths in the emitted messages (see displayPath).
@@ -607,7 +620,7 @@ function analyzeScanner(scanner, { baseDir }) {
607
620
  const fatalUnresolved = (u) => staticKinds.has(u.kind) && staticReachable.has(u.parentURL)
608
621
  const fatal = scanner.unresolved
609
622
  .filter((u) => fatalUnresolved(u))
610
- .map((u) => `unresolved ${u.kind} ${u.spec} from ${show(u.parentURL)} (${u.reason})`)
623
+ .map((u) => `unresolved ${describeUnresolved(u, show)}`)
611
624
  // Fatal parse: an eagerly-linked module-family file, or any file the parser couldn't process.
612
625
  const fatalParse = (p) => staticReachable.has(p.url) && (p.format?.startsWith('module') === true || !p.recovered)
613
626
  for (const p of scanner.parseErrors) {
@@ -643,7 +656,7 @@ function reportScanIssues({ fatal, tolerated, toleratedParse }, { label = '', ba
643
656
  throw new Error(`JS bundle would be broken at load time${where}:\n${listed(fatal)}`)
644
657
  }
645
658
  if (tolerated.length > 0) {
646
- const summary = listed(tolerated, (u) => `${u.kind} ${u.spec ?? '<dynamic>'} from ${show(u.parentURL)} (${u.reason})`)
659
+ const summary = listed(tolerated, (u) => describeUnresolved(u, show))
647
660
  console.warn(`[stasis] Bundle has ${tolerated.length} unresolved import(s)${where}; they will fall through at load time:\n${summary}`)
648
661
  }
649
662
  if (toleratedParse.length > 0) {
@@ -656,9 +669,15 @@ function reportScanIssues({ fatal, tolerated, toleratedParse }, { label = '', ba
656
669
  // token into the resolver.
657
670
  const cleanConditions = (conditions) => conditions.map((c) => (typeof c === 'string' ? c.trim() : c)).filter(Boolean)
658
671
 
659
- // --typescript honours tsconfig `paths` aliases: an explicit --tsconfig must exist, otherwise the
660
- // project root's tsconfig.json applies when present (null matcher = no aliases).
661
- const typescriptPathsFor = (typescript, baseDir, tsconfig, host) => (typescript ? loadTsconfigPaths(discoverTsconfig(baseDir, tsconfig, host), host) : null)
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
+ }
662
681
 
663
682
  // Build a JS/TS Bundle (in-memory) by statically scanning the require/import graph; no
664
683
  // user code is executed, and TS is stored verbatim (Node strips types at load). Scope comes
@@ -666,12 +685,15 @@ const typescriptPathsFor = (typescript, baseDir, tsconfig, host) => (typescript
666
685
  // extra `exports`/`imports` conditions; on their own they don't honour legacy mainFields or
667
686
  // platform suffixes (see `--mainFields` / buildResolvedJsBundle). `typescript` maps a failed
668
687
  // resolution to its on-disk TS source (tsc's rules; see resolve-typescript.js), honouring the
669
- // `paths` aliases of `tsconfig` (an explicit config path, default the project's tsconfig.json).
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).
670
690
  // Files are read through `host` (@exodus/stasis-core/host), the disk by default; EXODUS_STASIS_*
671
- // settings from `env`.
672
- 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 } = {}) {
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 } = {}) {
673
695
  if (!Array.isArray(entries) || entries.length === 0) {
674
- 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')
675
697
  }
676
698
  for (const e of entries) {
677
699
  if (!JS_EXTS.has(extname(e))) throw new Error(`buildJsBundle: not a JS/TS file: ${e}`)
@@ -684,7 +706,8 @@ export async function buildJsBundle({ cwd = process.cwd(), env = process.env, en
684
706
  // --resources: extensions/filenames carried as opaque assets instead of failing "can't carry".
685
707
  const resourceSet = parseResourcesOption('buildJsBundle', resources)
686
708
 
687
- const typescriptPaths = typescriptPathsFor(typescript, baseDir, tsconfig, host)
709
+ typescript ||= importsTypescriptByOutputName(absEntries, host)
710
+ const typescriptPaths = typescriptPathsFor(typescript, baseDir, tsconfig, host, env)
688
711
 
689
712
  const scanner = scan(absEntries, { conditions: scanConditions, jsx, flow, typescript, typescriptPaths, resources: resourceSet, host })
690
713
 
@@ -696,7 +719,23 @@ export async function buildJsBundle({ cwd = process.cwd(), env = process.env, en
696
719
  // bundle:'replace' skips reading any on-disk stasis.code.br (bundle:'add' would leak stale
697
720
  // entries); lock:'ignore' tolerates a pre-existing lockfile without consuming it; and it is never
698
721
  // written, so it claims no write target.
699
- const state = new State(baseDir, { bundle: 'replace', lock: 'ignore', ...(scope ? { scope } : {}), host, env, claim: false })
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
+ }
700
739
  for (const [url, info] of scanner.files) {
701
740
  // A resource carries bytes only: addFile derives resource vs resource:base64 from the content
702
741
  // and stores it under `resources` (a resource can't be an entry, so no isEntry).
@@ -740,14 +779,26 @@ export async function buildJsBundle({ cwd = process.cwd(), env = process.env, en
740
779
  return state
741
780
  }
742
781
 
743
- // Extensions probed when a resolved target names none. Limited to what a source bundle can
744
- // carry (scan's RESOLVABLE_EXTS) so the resolver never resolves a file the bundle would reject.
745
- // --jsx widens this to the JSX/TSX extensions (mirroring scan's jsx-gated RESOLVABLE_EXTS) so an
746
- // extensionless `import './Foo'` can land on Foo.jsx/Foo.tsx, as Metro's sourceExts do. `.js`/`.ts`
747
- // keep their existing precedence (base order preserved as a subsequence); collisions across the
748
- // added extensions are rare and, like the base list, don't track the project's real Metro order.
749
- const SOURCE_EXTS = ['js', 'json', 'ts']
750
- const SOURCE_EXTS_JSX = ['js', 'jsx', 'json', 'ts', 'tsx']
782
+ // The files that root a State in the directory holding one (state.js's discovery).
783
+ const STASIS_ROOT_FILES = ['stasis.config.json', 'stasis.lock.json', DEFAULT_BUNDLE_FILE]
784
+
785
+ // The innermost directory at or above `baseDir`, up to `root`, holding every one of `files` (real
786
+ // paths) and a package.json with a name, which a State can be rooted at; `root` where none below it
787
+ // does. One without a name, such as a `{"type":"module"}` marker, is no root: the State takes such a
788
+ // file's package to be the named one above it, which it then must hold.
789
+ function innermostRootOf(baseDir, files, root, host) {
790
+ for (let dir = baseDir; dir !== root && dirname(dir) !== dir; dir = dirname(dir)) {
791
+ if (files.every((file) => isPathWithin(dir, file)) && readPackageJson(dir, 'package.json', { host })?.name !== undefined) return dir
792
+ }
793
+ return root
794
+ }
795
+
796
+ // Extensions probed when a resolved target names none, in Metro's default sourceExts order.
797
+ // Limited to what a source bundle can carry (scan's RESOLVABLE_EXTS) so the resolver never
798
+ // resolves a file the bundle would reject; an extensionless `import './Foo'` can land on
799
+ // Foo.jsx/Foo.tsx, as it does under Metro. Like Metro's default, this doesn't track a project's
800
+ // own sourceExts.
801
+ const SOURCE_EXTS = ['js', 'jsx', 'json', 'ts', 'tsx']
751
802
  // React Native preset mainFields for `--metro` (which also sets the RN conditions + platform suffixes).
752
803
  const METRO_MAIN_FIELDS = ['react-native', 'browser', 'main']
753
804
  // Synthetic path for the empty module a browser/react-native `false` redirect resolves to,
@@ -799,15 +850,14 @@ function nativeModuleFiles(pkgAbs, host) {
799
850
  // `platform` (null for --mainFields), its `mainFields` (Metro's under --metro), and the conditions
800
851
  // it adds to Node's: --metro asserts the RN conditions (+ browser on web); --mainFields carries the
801
852
  // user's --conditions.
802
- export function fieldResolverFor(platform, { mainFields, metro = false, conditions = [], jsx = false, typescript = false, typescriptPaths = null, host = diskHost }) {
853
+ export function fieldResolverFor(platform, { mainFields, metro = false, conditions = [], typescript = false, typescriptPaths = null, host = diskHost }) {
803
854
  const extras = metro ? ['react-native', ...(platform === 'web' ? ['browser'] : [])] : conditions
804
855
  const fields = metro ? METRO_MAIN_FIELDS : mainFields
805
856
  const resolver = createFieldResolver({
806
857
  mainFields: fields,
807
858
  platform,
808
859
  preferNative: platform !== null && platform !== 'web',
809
- // Under --jsx the resolver probes .jsx/.tsx too, matching scan's jsx-widened carryable set.
810
- sourceExts: jsx ? SOURCE_EXTS_JSX : SOURCE_EXTS,
860
+ sourceExts: SOURCE_EXTS,
811
861
  conditions: resolveConditions('commonjs', extras),
812
862
  // Opt into Metro's package-entry browser-field quirks only on the --metro path.
813
863
  metro,
@@ -824,7 +874,7 @@ export function fieldResolverFor(platform, { mainFields, metro = false, conditio
824
874
  // Build a JS/TS Bundle + companion Lockfile via the legacy-field resolver (`--mainFields`/
825
875
  // `--metro`). Scanned once per platform; each edge is recorded flat when the platforms that
826
876
  // have it agree, or as a `{ platform: target }` map where they diverge. Returns { bundle, lockfile }.
827
- async function buildResolvedJsBundle({ cwd = process.cwd(), entries, mainFields, platforms, conditions = [], metro = false, metroResolver = false, jsx = false, flow = false, typescript = false, tsconfig, resources = [], packageJSON = false, host = diskHost }) {
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 }) {
828
878
  const baseDir = resolve(cwd)
829
879
  const absEntries = entries.map((e) => resolve(baseDir, e))
830
880
  const normalized = normalizeEntries(entries, cwd)
@@ -839,10 +889,11 @@ async function buildResolvedJsBundle({ cwd = process.cwd(), entries, mainFields,
839
889
 
840
890
  const scanConditions = cleanConditions(conditions)
841
891
 
842
- // Under --jsx the resolver probes .jsx/.tsx too, matching scan's jsx-widened carryable set.
843
- const sourceExts = jsx ? SOURCE_EXTS_JSX : SOURCE_EXTS
892
+ // Taken as given where the entries import TS sources by output name, but under --metro-resolver,
893
+ // which has no tsc mapping (classifyEntries rejects --typescript there).
894
+ if (!metroResolver) typescript ||= importsTypescriptByOutputName(absEntries, host)
844
895
  // --typescript's tsconfig `paths` matcher, shared by every per-platform resolver below.
845
- const typescriptPaths = typescriptPathsFor(typescript, baseDir, tsconfig, host)
896
+ const typescriptPaths = typescriptPathsFor(typescript, baseDir, tsconfig, host, env)
846
897
  // --resources: extensions/filenames carried as opaque assets instead of failing "can't carry".
847
898
  const resourceSet = parseResourcesOption('buildResolvedJsBundle', resources)
848
899
 
@@ -855,16 +906,19 @@ async function buildResolvedJsBundle({ cwd = process.cwd(), entries, mainFields,
855
906
  let usesEmpty = false
856
907
 
857
908
  for (const platform of platforms) {
858
- const field = fieldResolverFor(platform, { mainFields, metro, conditions: scanConditions, jsx, typescript, typescriptPaths, host })
909
+ const field = fieldResolverFor(platform, { mainFields, metro, conditions: scanConditions, typescript, typescriptPaths, host })
859
910
  const { extras } = field
860
911
  // --metro --metro-resolver delegates to the project's own metro-resolver for byte-for-byte Metro
861
912
  // fidelity; otherwise the built-in field/suffix resolver reproduces it. metro-resolver derives
862
913
  // default/require|import/platform conditions itself, so it takes only the extra `react-native`
863
914
  // condition (browser comes from its per-platform map, keyed on `web`).
864
915
  const resolver = metroResolver
865
- ? createMetroResolver({ projectDir: baseDir, platform, sourceExts, mainFields, conditionNames: ['react-native'], host })
916
+ ? createMetroResolver({ projectDir: baseDir, platform, sourceExts: SOURCE_EXTS, mainFields, conditionNames: ['react-native'], host })
866
917
  : field.resolver
867
- const scanner = scan(absEntries, { conditions: extras, resolve: resolver, jsx, flow, resources: resourceSet, host })
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 })
868
922
  reportScanIssues(analyzeScanner(scanner, { baseDir }), { baseDir, label: platform ?? 'mainFields' })
869
923
 
870
924
  const platformKey = platform ?? '*' // '*' is a private placeholder for the single mainFields pass; it never unflattens
@@ -1039,7 +1093,7 @@ async function buildResolvedJsBundle({ cwd = process.cwd(), entries, mainFields,
1039
1093
  for (const [dir, m] of bundle.modules) {
1040
1094
  const files = Object.create(null)
1041
1095
  for (const rel of Object.keys(m.files)) files[rel] = integrities.get(moduleFileKey(dir, rel))
1042
- lockModules.set(dir, { name: m.name, version: m.version, ...(m.ecosystem === undefined ? {} : { ecosystem: m.ecosystem }), files })
1096
+ lockModules.set(dir, moduleInfo({ ...m, files }))
1043
1097
  }
1044
1098
  const lockfile = new Lockfile({
1045
1099
  config: bundle.config,
@@ -1069,7 +1123,7 @@ function classifyEntries(name, { cwd = process.cwd(), entries, mappingFile, mani
1069
1123
  else if (entries.every((e) => BASH_EXTS.has(extname(e)))) kind = 'bash'
1070
1124
  else if (entries.every((e) => RUST_EXTS.has(extname(e)))) kind = 'rust'
1071
1125
  else {
1072
- 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)`)
1073
1127
  }
1074
1128
  if (mappingFile && kind !== 'sol') {
1075
1129
  throw new Error(`${name}: --mapping is only valid for .sol bundles`)
@@ -1117,7 +1171,8 @@ function classifyEntries(name, { cwd = process.cwd(), entries, mappingFile, mani
1117
1171
  if (mainFields !== undefined && kind !== 'js') {
1118
1172
  throw new Error(`${name}: --mainFields is only valid for JS bundles`)
1119
1173
  }
1120
- // --jsx toggles the scanner's JSX parsing for the .js family; only JS entries are scanned.
1174
+ // --jsx toggles the scanner's JSX parsing for the .js family (.jsx/.tsx need no flag); only JS
1175
+ // entries are scanned.
1121
1176
  if (jsx && kind !== 'js') {
1122
1177
  throw new Error(`${name}: --jsx is only valid for JS bundles`)
1123
1178
  }
@@ -1168,15 +1223,17 @@ function classifyEntries(name, { cwd = process.cwd(), entries, mappingFile, mani
1168
1223
 
1169
1224
  // A JS bundle as `stasis bundle` builds it: by the legacy-field resolver with `mainFields` or
1170
1225
  // `metro`, whose per-platform edges and synthetic empty module a State can't hold, else through a
1171
- // State, which detects the bundle's repo itself. -> { bundle, lockfile: () => Lockfile, stateBuilt }
1172
- async function buildJs({ mainFields, platforms, metro, metroResolver, ...options }) {
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 }) {
1173
1230
  if (metro || mainFields !== undefined) {
1174
1231
  const built = await buildResolvedJsBundle({ ...options, mainFields: metro ? METRO_MAIN_FIELDS : mainFields, platforms: metro ? platforms : [null], metro: Boolean(metro), metroResolver: Boolean(metroResolver) })
1175
- return { bundle: built.bundle, lockfile: () => built.lockfile, stateBuilt: false }
1232
+ return { bundle: built.bundle, lockfile: () => built.lockfile, stateBuilt: false, root: resolve(options.cwd) }
1176
1233
  }
1177
- const state = await buildJsBundle(options)
1234
+ const state = await buildJsBundle({ ...options, innermostRoot })
1178
1235
  // Stamp the `bundle` consumer (the static build carries none).
1179
- return { bundle: state.sourceBundle.withReason('bundle'), lockfile: () => state.lockfile, stateBuilt: true }
1236
+ return { bundle: state.sourceBundle.withReason('bundle'), lockfile: () => state.lockfile, stateBuilt: true, root: state.root }
1180
1237
  }
1181
1238
 
1182
1239
  // The bundle of `kind` (classifyEntries') built from buildBundle's options.
@@ -1190,11 +1247,15 @@ async function buildOfKind(kind, { cwd, env, entries, mappingFile, manifests, sc
1190
1247
  }
1191
1248
 
1192
1249
  // buildVfsBundle's checks of `options`, which hold before anything is fetched (`host` the
1193
- // project's, or an empty tree's with `fetched: false`): no metro-resolver, which reads the disk.
1250
+ // project's, or an empty tree's with `fetched: false`): no metro-resolver, which reads the disk;
1251
+ // `generate` only for JS.
1194
1252
  // -> the kind of bundle its entries make
1195
1253
  export function checkVfsOptions(name, options) {
1196
1254
  const kind = classifyEntries(name, options)
1197
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`)
1198
1259
  return kind
1199
1260
  }
1200
1261
 
@@ -1203,13 +1264,19 @@ export function checkVfsOptions(name, options) {
1203
1264
  // would install, or its Solidity options, through the dependencies folder 'soldeer' would install; with
1204
1265
  // nothing read from disk but tarballs and zips, nor from the environment: a Solidity bundle is
1205
1266
  // built with foundry.toml's default profile, whatever FOUNDRY_PROFILE or remappings one sets, and no
1206
- // EXODUS_STASIS_* setting is read. `repo`, the informational `{ github, directory | root, commit }`,
1267
+ // EXODUS_STASIS_* setting is read. `repo`, the informational `{ github, directory, commit }`,
1207
1268
  // is the Bundle's, over what is detected in the Vfs as `stasis bundle` detects it on disk. `os`,
1208
1269
  // `cpu` and `libc` are loadNodeModules'. Without a `packageManager`, it is the one whose lockfile
1209
- // installs cwd, where only one's does.
1210
- // -> { bundle: Bundle, lockfile: Lockfile (of a JS bundle), stats, packageManager }
1211
- export async function buildVfsBundle({ vfs, packageManager, cwd = '/', packageManagerVersion, os, cpu, libc, repo, ...options } = {}) {
1212
- const { checkKind, checkTarget, checkVfs, loadTree, packageManagerFor, packageManagerOf, vfsHost } = await import('../vfs-bundle/tree.js')
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')
1213
1280
  checkVfs('buildVfsBundle', vfs)
1214
1281
  checkTarget('buildVfsBundle', { os, cpu, libc })
1215
1282
  // Checked as the Bundle checks it, before anything is fetched.
@@ -1219,7 +1286,7 @@ export async function buildVfsBundle({ vfs, packageManager, cwd = '/', packageMa
1219
1286
  cwd = project.realpath(posix.resolve('/', cwd))
1220
1287
  packageManager = packageManagerFor('buildVfsBundle', project, cwd, { packageManager, packageManagerVersion, os })
1221
1288
  const pm = packageManagerOf('buildVfsBundle', packageManager)
1222
- checkKind('buildVfsBundle', checkVfsOptions('buildVfsBundle', { ...options, cwd, host: project }), [packageManager])
1289
+ checkKind('buildVfsBundle', checkVfsOptions('buildVfsBundle', { ...options, generate, cwd, host: project }), [packageManager])
1223
1290
  // Checked before anything is fetched: an entry out of what the tree installs is in the project
1224
1291
  // already. (A Solidity entry that is no .sol file is a directory, skipped where it is missing.)
1225
1292
  for (const entry of options.entries) {
@@ -1227,13 +1294,19 @@ export async function buildVfsBundle({ vfs, packageManager, cwd = '/', packageMa
1227
1294
  if (pm.kind === 'sol' && !abs.endsWith('.sol')) continue
1228
1295
  if (!posix.relative(cwd, abs).split('/').includes(pm.installs) && project.stat(abs) === null) throw new Error(`entry not found: ${abs}`)
1229
1296
  }
1230
- const { host, stats } = await loadTree({ project, packageManager, cwd, packageManagerVersion, os, cpu, libc })
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
+ }
1231
1304
  const args = { ...options, cwd, host, env: {} }
1232
- const { bundle, lockfile, stateBuilt } = pm.kind === 'sol' ? { bundle: await buildSolidityBundle(args) } : await buildJs(args)
1305
+ const { bundle, lockfile, stateBuilt, root } = pm.kind === 'sol' ? { bundle: await buildSolidityBundle(args), root: cwd } : await buildJs({ ...args, innermostRoot })
1233
1306
  if (repo !== undefined) bundle.repo = repo
1234
1307
  // Rooted at cwd, where `stasis bundle` detects its repo.
1235
1308
  else if (!stateBuilt) bundle.repo ??= detectRepo(cwd, host)
1236
- return { bundle, lockfile: lockfile?.(), stats, packageManager }
1309
+ return { bundle, lockfile: lockfile?.(), stats: tree.stats, packageManager, root }
1237
1310
  }
1238
1311
 
1239
1312
  // Programmatic equivalent of `stasis bundle`: build and return an in-memory Bundle without
@@ -1255,14 +1328,20 @@ export async function bundleCommand({ cwd = process.cwd(), env = process.env, en
1255
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 }
1256
1329
  const kind = classifyEntries('bundleCommand', { ...options, lockfile })
1257
1330
 
1258
- const target = output ?? DEFAULT_BUNDLE_FILE
1259
1331
  // --add has nothing to merge into on stdout (write-only).
1260
- if (add && target === '-') {
1332
+ if (add && output === '-') {
1261
1333
  throw new Error('bundleCommand: --add cannot be combined with --output=- (nothing to merge into on stdout)')
1262
1334
  }
1263
1335
 
1264
1336
  const built = await buildOfKind(kind, options)
1265
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))
1266
1345
  // Only a JS bundle has a lockfile (classifyEntries refuses one for the others).
1267
1346
  let lockData = lockfile ? built.lockfile().serialize() : undefined
1268
1347
 
@@ -1306,7 +1385,8 @@ export async function bundleCommand({ cwd = process.cwd(), env = process.env, en
1306
1385
 
1307
1386
  const dest = writeBundle(cwd, target, serialized, brotliQuality)
1308
1387
  if (lockData) writeFile(lockAbs, lockData)
1309
- const fromDir = outermostDir(files, resolve(cwd))
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))
1310
1390
  // On a merge, report newly-added files alongside the totals; a fresh write keeps the plain line.
1311
1391
  if (mergedFrom !== undefined) {
1312
1392
  const added = files.length - mergedFrom
package/src/diff.js CHANGED
@@ -1,4 +1,4 @@
1
- import { sortPaths } from '@exodus/stasis-core/util'
1
+ import { reposAgree, sortPaths } from '@exodus/stasis-core/util'
2
2
 
3
3
  // `@exodus/stasis/diff` — compare two parsed stasis artifacts (Bundle/Lockfile) at the module and
4
4
  // file level. Never reads disk. Comparing across kinds works by reducing every file to the same
@@ -21,7 +21,7 @@ function digestOf(kind, format, value, hash) {
21
21
 
22
22
  const projectPath = (dir, rel) => (dir === '.' ? rel : `${dir}/${rel}`)
23
23
 
24
- // Project an artifact onto a uniform shape: scope + Map<dir, { name, version, ecosystem,
24
+ // Project an artifact onto a uniform shape: scope + Map<dir, { name, version, ecosystem, repo,
25
25
  // files: Map<rel, digest> }>. `input` is { artifact, kind }; `hash` re-hashes bundle bytes only.
26
26
  export function normalizeArtifact(input, { hash } = {}) {
27
27
  const { artifact, kind } = input ?? {}
@@ -30,13 +30,13 @@ export function normalizeArtifact(input, { hash } = {}) {
30
30
  }
31
31
  const formats = artifact.formats ?? new Map()
32
32
  const modules = new Map()
33
- for (const [dir, { name, version, ecosystem, files }] of artifact.modules) {
33
+ for (const [dir, { name, version, ecosystem, repo, files }] of artifact.modules) {
34
34
  const digests = new Map()
35
35
  for (const [rel, value] of Object.entries(files)) {
36
36
  digests.set(rel, digestOf(kind, formats.get(projectPath(dir, rel)), value, hash))
37
37
  }
38
38
  // v0 bundles record no name/version; normalize undefined -> null for a single "unknown" sentinel.
39
- modules.set(dir, { name: name ?? null, version: version ?? null, ecosystem: ecosystem ?? null, files: digests })
39
+ modules.set(dir, { name: name ?? null, version: version ?? null, ecosystem: ecosystem ?? null, repo, files: digests })
40
40
  }
41
41
  // `executable` decides which files `stasis extract` chmods +x, so a change to it changes the tree
42
42
  // an artifact produces even when every digest matches -- it has to be part of "did these differ?".
@@ -45,8 +45,8 @@ export function normalizeArtifact(input, { hash } = {}) {
45
45
 
46
46
  // Diff two `{ artifact, kind }` operands (`left` = baseline/"from", `right` = "to"). Modules are
47
47
  // compared whole-package; files only within packages present on BOTH sides (a one-sided package is
48
- // reported once as an added/removed module, not per-file). A version/name change is reported only
49
- // when both sides record a non-null value. `{ imports: true }` also diffs the resolution graphs
48
+ // reported once as an added/removed module, not per-file). A version/name/repo change is reported
49
+ // only when both sides record a non-null value (an artifact from before `repo` records none). `{ imports: true }` also diffs the resolution graphs
50
50
  // (opt-in; verbose). `hash` is required only when a bundle is an operand.
51
51
  export function diffArtifacts(left, right, { imports = false, hash } = {}) {
52
52
  const L = normalizeArtifact(left, { hash })
@@ -80,7 +80,11 @@ export function diffArtifacts(left, right, { imports = false, hash } = {}) {
80
80
  if (l.name !== null && r.name !== null && l.name !== r.name) {
81
81
  change.nameChange = { from: l.name, to: r.name }
82
82
  }
83
- if (change.versionChange || change.nameChange) modulesChanged.push(change)
83
+ // A dependency's repo picks the GitHub advisories audit asks, so a change to it is one to review.
84
+ if (!reposAgree(l.repo, r.repo)) {
85
+ change.repoChange = { from: l.repo, to: r.repo }
86
+ }
87
+ if (change.versionChange || change.nameChange || change.repoChange) modulesChanged.push(change)
84
88
 
85
89
  const rels = new Set([...l.files.keys(), ...r.files.keys()])
86
90
  for (const rel of rels) {
@@ -203,10 +207,14 @@ const REMOVED = '-'
203
207
  const ADDED = '+'
204
208
  const CHANGED = '*'
205
209
 
206
- function describeModuleChange({ name, versionChange, nameChange }) {
210
+ // `owner/name`, its commit and directory where recorded: `owner/name@0123abc (packages/x)`.
211
+ const repoLabel = ({ github, commit, directory }) => `${github ?? '?'}${commit ? `@${commit.slice(0, 7)}` : ''}${directory ? ` (${directory})` : ''}`
212
+
213
+ function describeModuleChange({ name, versionChange, nameChange, repoChange }) {
207
214
  const segs = []
208
215
  if (versionChange) segs.push(`${versionChange.from} -> ${versionChange.to}`)
209
216
  if (nameChange) segs.push(`name ${nameChange.from} -> ${nameChange.to}`)
217
+ if (repoChange) segs.push(`repo ${repoLabel(repoChange.from)} -> ${repoLabel(repoChange.to)}`)
210
218
  return `${name ? `${name} ` : ''}${segs.join(', ')}`
211
219
  }
212
220
 
@@ -782,6 +782,12 @@ export function createCargoContext(baseDir, { entries = [], features = [], noDef
782
782
  // sha256, as `cargo vendor` listed them, path relative to the package.
783
783
  return value === null ? null : { text, git: (value?.package ?? null) === null, files: isPlainObject(value?.files) ? value.files : {} }
784
784
  })
785
+ // Where `cargo vendor` copied the vendored package at `dir` from, as its `.cargo-checksum.json`
786
+ // tells: `registry` or `git` (a git checkout); null without that file, when it could be either.
787
+ const copySource = (dir) => {
788
+ const git = checksumOf(dir)?.git
789
+ return git === undefined ? null : (git ? 'git' : 'registry')
790
+ }
785
791
  // Throws when `fileRel`, a file of the vendored package at `dir`, isn't byte for byte (`buf`) the
786
792
  // file its `.cargo-checksum.json` lists -- edited after `cargo vendor`: cargo refuses to build
787
793
  // the package, and so the bundle does. A file the list doesn't name, or a package without one,
@@ -900,8 +906,7 @@ export function createCargoContext(baseDir, { entries = [], features = [], noDef
900
906
  const lockKeysOf = (lk, m) => {
901
907
  const keys = lk.byId.get(`${normName(m.package.name)} ${version(m)}`) ?? []
902
908
  if (!isVendoredDir(m.dir)) return keys.filter((k) => lk.lock.packages[k].source === undefined)
903
- const git = checksumOf(m.dir)?.git
904
- const kind = git === undefined ? null : (git ? 'git' : 'registry')
909
+ const kind = copySource(m.dir)
905
910
  return keys.filter((k) => lk.lock.packages[k].source !== undefined && (kind === null || sourceKind(lk.lock.packages[k].source) === kind))
906
911
  }
907
912
  // Dependency → package, memoized per (package, request): the fixed-point loop asks many times.
@@ -997,16 +1002,20 @@ export function createCargoContext(baseDir, { entries = [], features = [], noDef
997
1002
 
998
1003
  // --- feature resolution
999
1004
 
1000
- // Cargo's feature resolver: v2 (edition 2021+, or `resolver = "2"`/`"3"`) leaves dev-dependencies
1001
- // out of a normal build's unification, resolves what is built for the host apart from what is
1002
- // built for the target, and ignores the tables of platforms not being built; v1 unifies them all.
1003
- // It is the workspace's setting: the build's workspace root's (buildWorkspaceRoot) `resolver`,
1004
- // else its edition -- never that of a vendored crate an entry is in, where cargo isn't run.
1005
+ // Cargo's feature resolver: v2 (edition 2021, or `resolver = "2"`) leaves dev-dependencies out of
1006
+ // a normal build's unification, resolves what is built for the host apart from what is built for
1007
+ // the target, and ignores the tables of platforms not being built; v1 unifies them all. v3
1008
+ // (edition 2024, or `resolver = "3"`) resolves features as v2 does, and picks versions by
1009
+ // `rust-version` too, which the lockfile and the vendored copies have decided here. It is the
1010
+ // workspace's setting: the build's workspace root's (buildWorkspaceRoot) `resolver`, else its
1011
+ // edition's, as linkCargo gives it -- never that of a vendored crate an entry is in, where cargo
1012
+ // isn't run.
1005
1013
  let resolverMemo = null
1006
1014
  const resolverVersion = () => {
1007
1015
  if (resolverMemo === null) {
1008
1016
  const root = buildWorkspaceRoot()
1009
- resolverMemo = root?.resolver ?? (Number(root?.package?.edition ?? 0) >= 2021 ? 2 : 1)
1017
+ const edition = Number(root?.package?.edition ?? 0)
1018
+ resolverMemo = root?.resolver ?? (edition >= 2024 ? 3 : (edition >= 2021 ? 2 : 1))
1010
1019
  }
1011
1020
  return resolverMemo
1012
1021
  }
@@ -1613,11 +1622,19 @@ export function createCargoContext(baseDir, { entries = [], features = [], noDef
1613
1622
  const m = packageFor(fileRel)
1614
1623
  if (m !== null && isVendoredDir(m.dir)) checkVendored(m.dir, fileRel, buf)
1615
1624
  },
1616
- // Whether `fileRel` belongs to a vendored package (a registry crate `cargo vendor` copied in).
1625
+ // Whether `fileRel` belongs to a vendored package (a crate `cargo vendor` copied in, a registry's
1626
+ // or a git checkout's: see vendoredFrom).
1617
1627
  isVendored(fileRel) {
1618
1628
  const m = packageFor(fileRel)
1619
1629
  return m !== null && isVendoredDir(m.dir)
1620
1630
  },
1631
+ // Where `cargo vendor` copied the vendored package owning `fileRel` from, as its
1632
+ // `.cargo-checksum.json` tells: `registry` (a package checksum) or `git` (a git checkout's: none);
1633
+ // null when it has no such file, and so could be either, or isn't vendored.
1634
+ vendoredFrom(fileRel) {
1635
+ const m = packageFor(fileRel)
1636
+ return m !== null && isVendoredDir(m.dir) ? copySource(m.dir) : null
1637
+ },
1621
1638
  // The build script of `fileRel`'s package, project-relative, when there is one on disk:
1622
1639
  // `[package] build = "…"`, else `build.rs` beside the manifest; `build = false` means none. A
1623
1640
  // vendored package's may not name a file outside the package (a published crate never does).