@exodus/stasis 1.0.0-beta.8 → 1.0.0-beta.9

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,20 +1,22 @@
1
1
  import { isUtf8 } from 'node:buffer'
2
2
  import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'
3
- import { dirname, extname, join, posix, relative, resolve } from 'node:path'
3
+ import { basename, 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
- import { Bundle } from '@exodus/stasis-core/bundle'
7
+ import { Bundle, isValidRepoField } from '@exodus/stasis-core/bundle'
8
8
  import { Lockfile } from '@exodus/stasis-core/lockfile'
9
9
  import { importsTypescriptByOutputName, scan } from '../scan.js'
10
10
  import { createFieldResolver, resolveConditions } from '../resolve-fields.js'
11
11
  import { discoverTsconfig, isDir, loadTsconfigPaths, packageTsconfigPaths } from '../resolve-typescript.js'
12
12
  import { createMetroResolver } from '../metro-resolver.js'
13
+ import { pinInstalledCommits } from '../installed-commits.js'
13
14
  import { State } from '@exodus/stasis-core/state'
14
15
  import { sha512integrity } from '@exodus/stasis-core/state-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'
16
+ import { checkoutCommit, detectRepo, findPackageMetadata, normalizeEntries, packageType, parseGithubRepository, readJson, readModuleManifest, readPackageJson, readRegularFileOrNull } from '@exodus/stasis-core/bundle-util'
17
+ import { EMPTY_MODULE_PATH, RN_CORE_INCLUDE_FILES, assertRealPathWithinBase, classifyNativeCapture, hasNodeModulesSegment, isDotEnvFile, isExcludedNativeDir, isExecutableFile, isNativeArtifact, isNativeManifest, isPathWithin, isPodspec, isSkippedNativeWalkDir, moduleFileKey, moduleInfo, parseResourcesOption, posixPathEscapes, refineNativeCapture, relativeEscapes, splitNodeModulesPath, toPosix } from '@exodus/stasis-core/util'
17
18
  import { diskHost } from '@exodus/stasis-core/host'
19
+ import { parseSoldeerLockfile } from '@preventive/lockfile/soldeer.js'
18
20
  import {
19
21
  SOLIDITY_PACKAGE_MANIFESTS,
20
22
  SOLIDITY_ROOT_MANIFESTS,
@@ -82,42 +84,105 @@ function parseSoldeerDir(seg) {
82
84
  return m ? { name: m[1], version: m[2] } : { name: seg, version: '0.0.0' }
83
85
  }
84
86
 
85
- // Extract `owner/repo` from a github.com remote (https/ssh/scp); null for non-github hosts.
86
- function githubSlug(url) {
87
- const m = /github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?\/?$/iu.exec(url)
88
- return m ? `${m[1]}/${m[2]}` : null
89
- }
90
-
91
- // The github.com ones of `submodules` (readGitmodules), as Map<submodulePath, { name, branch }>.
87
+ // A url as git fetches from a host: scp's `[user@]host:path`, with no scheme and a colon before its
88
+ // first slash, or a URL of a transport git has itself (`http`, `https`, `git`, `ssh` and its
89
+ // `git+ssh`/`ssh+git` aliases, lowercase, as git spells them), its authority, `[userinfo@]host[:port]`,
90
+ // ending at the first slash after it. Not, then, what a package.json spells GitHub's that git reads
91
+ // another way or not at all: `ssh://git@github.com:owner/name`, whose host is `github.com:owner` to
92
+ // git; `git+https://` or `HTTPS://`, transports git doesn't have; nor `owner/name`, a path, as is any
93
+ // url with no colon before its first slash.
94
+ const GIT_URL = /^(?:[^/]*:(?!\/\/)|(?:https?|git|ssh|git\+ssh|ssh\+git):\/\/(?:[^/@]*@)?[^/:@]*(?::\d*)?\/)/u
95
+
96
+ // The GitHub repository, `owner/name`, a .gitmodules `url` names (parseGithubRepository), else null:
97
+ // none for a host merely holding `github.com` (`https://notgithub.com/o/n`), nor for a url git
98
+ // doesn't fetch as one (GIT_URL), nor for npm's `github:owner/name`, the host `github`'s path to git,
99
+ // whatever an ssh alias or `insteadOf` makes of it, nor one with a `#`, which git keeps in the path
100
+ // it asks for (`owner/name.git#v1`) where npm takes a committish. git's `ssh+git://`, which a
101
+ // package.json doesn't spell, is its `ssh://`.
102
+ const submoduleGithub = (url) => (GIT_URL.test(url) && !/^github:/iu.test(url) && !url.includes('#') ? parseGithubRepository(url.replace(/^ssh\+git:/u, 'ssh:')) : null)
103
+
104
+ // The GitHub ones of `submodules` (readGitmodules), as Map<submodulePath, { name, branch }>.
92
105
  function githubSubmodules(submodules) {
93
106
  const byPath = new Map()
94
107
  for (const { path, url, branch } of submodules) {
95
- const name = url && githubSlug(url)
108
+ const name = url && submoduleGithub(url)
96
109
  if (name) byPath.set(path, { name, branch })
97
110
  }
98
111
  return byPath
99
112
  }
100
113
 
114
+ // The root of the GitHub repository `github` at `commit`, where both are valid; else undefined.
115
+ const repoRootAt = (github, commit) => (isValidRepoField('github', github) && isValidRepoField('commit', commit) ? { github, directory: '', commit } : undefined)
116
+
117
+ // The text of `rel`, a file of the project root, read as `ownership` vouches for it; null where it
118
+ // refuses it, nothing is there, or it isn't UTF-8.
119
+ function ownedText(baseDir, rel, ownership, host) {
120
+ const { reason, real, outside } = ownership.of(rel)
121
+ if (reason || real === null || outside) return null
122
+ try {
123
+ const buf = readRegularFileOrNull(join(host.realpath(baseDir), real), rel, host)
124
+ return buf === null ? null : decodeUtf8(buf, rel)
125
+ } catch {
126
+ return null
127
+ }
128
+ }
129
+
130
+ // Each git dependency the root's soldeer.lock installs from GitHub, by its folder under
131
+ // `dependencies/` (`<name>-<version>`, as Soldeer names it): the repository's root at the lockfile's
132
+ // `rev`, which Soldeer checks out there. None where the lockfile can't be read or parsed: it is
133
+ // metadata, which never stops a build.
134
+ function soldeerGitRepos(baseDir, ownership, host) {
135
+ const text = ownedText(baseDir, 'soldeer.lock', ownership, host)
136
+ let lock = null
137
+ try {
138
+ lock = text === null ? null : parseSoldeerLockfile(text)
139
+ } catch {
140
+ // Not one Soldeer writes: nothing is recorded from it.
141
+ }
142
+ const repos = new Map()
143
+ for (const dep of Object.values(lock?.dependencies ?? {})) {
144
+ const repo = dep.type === 'git' ? repoRootAt(parseGithubRepository(dep.git), dep.rev) : undefined
145
+ if (repo !== undefined) repos.set(`${dep.name}-${dep.version}`, repo)
146
+ }
147
+ return repos
148
+ }
149
+
101
150
  // Classify a Solidity file's dep bucket: Soldeer (`dependencies/<name>-<version>/`) or a
102
151
  // github submodule (`lib/`, via the `.gitmodules` `ownership` read), else null to defer to the
103
152
  // node_modules/workspace logic. `ownership.assert` vets a package.json path before it is read
104
- // through `host`.
153
+ // through `host`. A bucket's `repo` is the GitHub repository it is checked out from, at its root, at
154
+ // the commit its checkout's HEAD is at, as the bundle's own `repo` reads one: a submodule's, from
155
+ // the GitHub repository its .gitmodules url names; a Soldeer git dependency's, where it is its
156
+ // soldeer.lock `rev` (soldeerGitRepos), as Soldeer holds a checkout to the lockfile before taking it
157
+ // for installed: a folder left at another commit since the lockfile changed, or no checkout, records
158
+ // none.
105
159
  function makeSolidityClassifier(baseDir, ownership, host) {
106
160
  const submodules = githubSubmodules(ownership.submodules)
107
161
  const check = ownership.assert
108
162
  const versions = new Map() // a submodule's package.json version, read once
163
+ const commits = new Map() // a bucket's checkout's commit, read once
164
+ const commitOf = (dir) => {
165
+ if (!commits.has(dir)) commits.set(dir, checkoutCommit(join(baseDir, dir), host))
166
+ return commits.get(dir)
167
+ }
168
+ let soldeer // soldeerGitRepos, read at the first Soldeer file
109
169
  return (path) => {
110
170
  if (path.startsWith('dependencies/')) {
111
171
  const seg = path.slice('dependencies/'.length).split('/')[0]
112
172
  if (seg) {
113
173
  const { name, version } = parseSoldeerDir(seg)
114
- return { bucketDir: `dependencies/${seg}`, name, version, ecosystem: 'soldeer' }
174
+ const bucketDir = `dependencies/${seg}`
175
+ soldeer ??= soldeerGitRepos(baseDir, ownership, host)
176
+ const locked = soldeer.get(seg)
177
+ const repo = locked !== undefined && commitOf(bucketDir) === locked.commit ? locked : undefined
178
+ return { bucketDir, name, version, ecosystem: 'soldeer', ...(repo === undefined ? {} : { repo }) }
115
179
  }
116
180
  }
117
181
  for (const [sub, { name, branch }] of submodules) {
118
182
  if (path === sub || path.startsWith(`${sub}/`)) {
119
183
  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' }
184
+ const repo = repoRootAt(name, commitOf(sub))
185
+ return { bucketDir: sub, name, version: versions.get(sub) ?? branch ?? '0.0.0', ecosystem: 'github', ...(repo === undefined ? {} : { repo }) }
121
186
  }
122
187
  }
123
188
  return null
@@ -801,9 +866,11 @@ function innermostRootOf(baseDir, files, root, host) {
801
866
  const SOURCE_EXTS = ['js', 'jsx', 'json', 'ts', 'tsx']
802
867
  // React Native preset mainFields for `--metro` (which also sets the RN conditions + platform suffixes).
803
868
  const METRO_MAIN_FIELDS = ['react-native', 'browser', 'main']
804
- // Synthetic path for the empty module a browser/react-native `false` redirect resolves to,
805
- // carried as a real empty CJS file so the edge points at attestable bytes.
806
- const EMPTY_MODULE_PATH = '.stasis/empty-module.js'
869
+ // What esbuild and webpack 5 both assert on every platform besides `import`/`require` and
870
+ // `default`, and so `--mainFields` too: `module`, the bundler-only condition a package's ESM build
871
+ // sits under, from an import and a require alike. (esbuild drops it when given conditions of its
872
+ // own; webpack keeps it, as `--conditions` adds to it here.) Node and Metro never assert it.
873
+ const BUNDLER_CONDITIONS = ['module']
807
874
 
808
875
  // Recursively collect the files under `dirAbs` whose name `keep` takes, skipping build output,
809
876
  // symlinks (cycle/escape hazard) and, directly in `dirAbs`, the dirs `skipAtRoot` names. Absolute
@@ -848,10 +915,11 @@ function nativeModuleFiles(pkgAbs, host) {
848
915
 
849
916
  // The built-in field/suffix resolver (resolve-fields.js) the legacy-field build resolves with on
850
917
  // `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.
918
+ // it adds to the bundler base (`import`/`require` + `default`, never Node's `node`; see
919
+ // resolveConditions): --metro asserts the RN conditions (+ browser on web), Metro's own set;
920
+ // --mainFields asserts esbuild's and webpack's `module` (BUNDLER_CONDITIONS) and the user's --conditions.
853
921
  export function fieldResolverFor(platform, { mainFields, metro = false, conditions = [], typescript = false, typescriptPaths = null, host = diskHost }) {
854
- const extras = metro ? ['react-native', ...(platform === 'web' ? ['browser'] : [])] : conditions
922
+ const extras = metro ? ['react-native', ...(platform === 'web' ? ['browser'] : [])] : [...new Set([...BUNDLER_CONDITIONS, ...conditions])]
855
923
  const fields = metro ? METRO_MAIN_FIELDS : mainFields
856
924
  const resolver = createFieldResolver({
857
925
  mainFields: fields,
@@ -879,8 +947,17 @@ async function buildResolvedJsBundle({ cwd = process.cwd(), env = process.env, e
879
947
  const absEntries = entries.map((e) => resolve(baseDir, e))
880
948
  const normalized = normalizeEntries(entries, cwd)
881
949
 
950
+ // A resolved file arrives by its real path, an entry as spelled: under a cwd named through a link,
951
+ // the files the scan reaches lie below the root's real path, not below `baseDir` as spelled. Either
952
+ // is the root; a file below neither is outside it. (Realpathed only when needed, so a missing cwd
953
+ // is still the scan's missing entry.)
954
+ let realRoot
882
955
  const toRel = (abs) => {
883
- const rel = toPosix(relative(baseDir, abs))
956
+ let rel = toPosix(relative(baseDir, abs))
957
+ if (relativeEscapes(rel)) {
958
+ realRoot ??= host.realpath(baseDir)
959
+ rel = toPosix(relative(realRoot, abs))
960
+ }
884
961
  if (relativeEscapes(rel)) {
885
962
  throw new Error(`Bundle would reach a file outside the project root: ${abs}`)
886
963
  }
@@ -958,8 +1035,10 @@ async function buildResolvedJsBundle({ cwd = process.cwd(), env = process.env, e
958
1035
  const realBase = host.realpath(baseDir)
959
1036
  for (const abs of reached) {
960
1037
  const rel = toRel(abs)
961
- // Security: the field resolver returns the lexical path, so an in-tree-named symlink
962
- // escaping the root would slip past toRel's textual check -- realpath and fail closed.
1038
+ // Security: a resolved file arrives by its real path, which toRel already holds to the root,
1039
+ // but an entry is taken as spelled (and --metro-resolver keeps a path it can't realpath), so an
1040
+ // in-tree-named symlink escaping the root would slip past toRel's textual check -- realpath and
1041
+ // fail closed.
963
1042
  assertRealPathWithinBase(realBase, baseDir, rel, host)
964
1043
  const buf = host.readFile(abs)
965
1044
  if (resourceRels.has(rel)) {
@@ -978,25 +1057,89 @@ async function buildResolvedJsBundle({ cwd = process.cwd(), env = process.env, e
978
1057
  put(EMPTY_MODULE_PATH, '', 'commonjs', Buffer.alloc(0))
979
1058
  }
980
1059
 
1060
+ // The package a bundled file is in (findPackageMetadata: its pkgDir and identity, null where no
1061
+ // manifest names one), memoized per directory so a package's many files don't each re-walk to it.
1062
+ const packageOf = packageLookup(baseDir, { host })
1063
+
981
1064
  // --metro also carries each bundled dependency's native build-input surface (ios/android
982
- // sources + podspecs), scoped to the node_modules packages actually in the bundle. Native
983
- // source is stored as code under a language tag; other assets as 'resource'/'resource:base64'.
1065
+ // sources + podspecs), scoped to the packages in the bundle installed or linked into
1066
+ // node_modules. Native source is stored as code under a language tag; other assets as
1067
+ // 'resource'/'resource:base64'.
984
1068
  if (metro) {
985
- const pkgDirs = new Set()
1069
+ const pkgDirs = new Map() // package dir -> its package's name
1070
+ // Follow the CODE/module graph only: a package reached solely for an asset (--resources) is
1071
+ // not a linked native dependency, so it must not drag in its ios/android surface.
1072
+ const isCode = (rel) => !resourceRels.has(rel)
986
1073
  for (const abs of reached) {
987
- // Follow the CODE/module graph only: a package reached solely for an asset (--resources) is
988
- // not a linked native dependency, so it must not drag in its ios/android surface.
989
1074
  const rel = toRel(abs)
990
- if (resourceRels.has(rel)) continue
991
- const nm = splitNodeModulesPath(rel)
992
- if (nm) pkgDirs.add(nm.dir)
1075
+ const nm = isCode(rel) && splitNodeModulesPath(rel)
1076
+ if (nm) pkgDirs.set(nm.dir, nm.name)
1077
+ }
1078
+ // A package linked into node_modules from out of it (a workspace package) is reached by its real
1079
+ // path, out of node_modules, but is a dependency all the same, however an import reaches it (by
1080
+ // name or alias, through a browser/react-native map, by a relative path): one linked, under any
1081
+ // name, into a node_modules its importer finds packages in (the directories the resolver looks
1082
+ // in). One linked nowhere there is the project's own source.
1083
+ const linksIn = new Map() // directory -> Set of the real package dirs linked into a node_modules it finds packages in
1084
+ const linkedFrom = (dir) => {
1085
+ let links = linksIn.get(dir)
1086
+ if (links !== undefined) return links
1087
+ const parent = dirname(dir)
1088
+ links = new Set(parent === dir ? [] : linkedFrom(parent))
1089
+ const link = (abs) => {
1090
+ try {
1091
+ const rel = toRel(host.realpath(abs))
1092
+ if (!splitNodeModulesPath(rel)) links.add(rel)
1093
+ } catch { /* a dangling link, or one out of the root, whose files the scan pass refused already */ }
1094
+ }
1095
+ const listing = (abs) => {
1096
+ try {
1097
+ return host.readdir(abs)
1098
+ } catch {
1099
+ return []
1100
+ }
1101
+ }
1102
+ // As locatePackage: a directory named node_modules holds no node_modules of its own to look in.
1103
+ if (basename(dir) !== 'node_modules') {
1104
+ const nodeModules = join(dir, 'node_modules')
1105
+ for (const ent of listing(nodeModules)) {
1106
+ const abs = join(nodeModules, ent.name)
1107
+ if (ent.name.startsWith('@')) {
1108
+ // A scope holds packages, a directory or a link to one (a whole scope linked, where
1109
+ // every package in it is reached through the link).
1110
+ for (const scoped of listing(abs)) {
1111
+ if (ent.isSymbolicLink() || scoped.isSymbolicLink()) link(join(abs, scoped.name))
1112
+ }
1113
+ } else if (ent.isSymbolicLink()) {
1114
+ link(abs)
1115
+ }
1116
+ }
1117
+ }
1118
+ linksIn.set(dir, links)
1119
+ return links
993
1120
  }
994
- for (const pkgDir of [...pkgDirs].toSorted()) {
1121
+ for (const [parent, bySpec] of edges) {
1122
+ for (const byPlatform of bySpec.values()) {
1123
+ for (const target of byPlatform.values()) {
1124
+ if (!isCode(target) || splitNodeModulesPath(target)) continue
1125
+ const links = linkedFrom(dirname(join(baseDir, parent)))
1126
+ // The linked root holding the target, nearest first: its entry may lie below a manifest of
1127
+ // its own (a named dist/package.json), as a node_modules path names the package it's in.
1128
+ let root = posix.dirname(target)
1129
+ while (root !== '.' && !links.has(root)) root = posix.dirname(root)
1130
+ // The project's own source isn't a dependency, nor is an import within the package itself.
1131
+ if (root === '.' || parent.startsWith(`${root}/`) || pkgDirs.has(root)) continue
1132
+ // Named by its own manifest, whatever name it's linked under.
1133
+ pkgDirs.set(root, readPackageJson(baseDir, `${root}/package.json`, { host })?.name)
1134
+ }
1135
+ }
1136
+ }
1137
+ for (const [pkgDir, name] of [...pkgDirs].toSorted(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {
995
1138
  const pkgAbs = join(baseDir, pkgDir)
996
1139
  // react-native core isn't a Pod (config reports it via reactNativePath, not `dependencies`),
997
1140
  // so walk its whole tree for native source (React/, ReactCommon/, ReactAndroid/, ...) the same
998
1141
  // way a native dep's ios/android surface is walked; every other dep gets its ios/android + podspecs.
999
- const isRnCore = pkgDir.slice(pkgDir.lastIndexOf('node_modules/') + 'node_modules/'.length) === 'react-native'
1142
+ const isRnCore = name === 'react-native'
1000
1143
  const files = isRnCore ? [] : nativeModuleFiles(pkgAbs, host)
1001
1144
  if (isRnCore) walkNative(pkgAbs, files, host, isNativeSource)
1002
1145
  for (const abs of files) {
@@ -1036,12 +1179,9 @@ async function buildResolvedJsBundle({ cwd = process.cwd(), env = process.env, e
1036
1179
 
1037
1180
  // --package-json: fold each bundled module's package.json into `sources` (and its integrity into
1038
1181
  // 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 })
1182
+ // assembleCodeBundle derives, from the same `packageOf` (its pkgDir, else the '.' workspace
1183
+ // bucket). readModuleManifest applies the read/validate rules shared with the State path
1184
+ // (containment, UTF-8-aborts); no identity check here -- these buckets are all fresh from disk.
1045
1185
  if (packageJSON) {
1046
1186
  const pkgDirs = new Set()
1047
1187
  for (const abs of reached) {
@@ -1236,14 +1376,17 @@ async function buildJs({ mainFields, platforms, metro, metroResolver, innermostR
1236
1376
  return { bundle: state.sourceBundle.withReason('bundle'), lockfile: () => state.lockfile, stateBuilt: true, root: state.root }
1237
1377
  }
1238
1378
 
1239
- // The bundle of `kind` (classifyEntries') built from buildBundle's options.
1379
+ // The bundle of `kind` (classifyEntries') built from buildBundle's options, from disk: a JS bundle's
1380
+ // git dependencies at the commits their package managers' records name (pinInstalledCommits).
1240
1381
  // -> { bundle, lockfile: () => Lockfile, stateBuilt } (the last two of a JS bundle alone)
1241
1382
  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
1383
  if (kind === 'sol') return { bundle: await buildSolidityBundle({ cwd, env, entries, mappingFile, manifests }) }
1243
1384
  if (kind === 'php') return { bundle: await buildPhpBundle({ cwd, entries }) }
1244
1385
  if (kind === 'bash') return { bundle: await buildBashBundle({ cwd, entries }) }
1245
1386
  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 })
1387
+ const built = await buildJs({ cwd, env, entries, scope, conditions, mainFields, platforms, metro, metroResolver, jsx, flow, typescript, tsconfig, resources, packageJSON })
1388
+ pinInstalledCommits(built.bundle, built.root)
1389
+ return built
1247
1390
  }
1248
1391
 
1249
1392
  // buildVfsBundle's checks of `options`, which hold before anything is fetched (`host` the
@@ -1272,13 +1415,16 @@ export function checkVfsOptions(name, options) {
1272
1415
  // tree before the scan, leaving `vfs` as it is (vfs-bundle/prisma.js). `client`, a
1273
1416
  // @preventive/upstream/github.js client, is what a Soldeer git dependency is fetched through; without
1274
1417
  // 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.
1418
+ // `cache` is where npm packages' tarballs and version documents are kept (checkCache). Each
1419
+ // dependency is recorded at the commit the tree installs it from, where deptree names one
1420
+ // (pinCommits). `root` is the directory in the Vfs the bundle's paths are relative to: cwd, or for a
1421
+ // JS bundle built through a State, the State's root, which is at or above it.
1277
1422
  // -> { 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')
1423
+ export async function buildVfsBundle({ vfs, packageManager, cwd = '/', packageManagerVersion, os, cpu, libc, repo, innermostRoot = false, generate = [], client, cache, ...options } = {}) {
1424
+ const { checkCache, checkKind, checkTarget, checkVfs, loadTree, packageManagerFor, packageManagerOf, pinCommits, treeHost, vfsHost } = await import('../vfs-bundle/tree.js')
1280
1425
  checkVfs('buildVfsBundle', vfs)
1281
1426
  checkTarget('buildVfsBundle', { os, cpu, libc })
1427
+ checkCache('buildVfsBundle', cache)
1282
1428
  // Checked as the Bundle checks it, before anything is fetched.
1283
1429
  if (repo !== undefined) repo = new Bundle({ repo }).repo
1284
1430
  const project = vfsHost(vfs)
@@ -1294,7 +1440,7 @@ export async function buildVfsBundle({ vfs, packageManager, cwd = '/', packageMa
1294
1440
  if (pm.kind === 'sol' && !abs.endsWith('.sol')) continue
1295
1441
  if (!posix.relative(cwd, abs).split('/').includes(pm.installs) && project.stat(abs) === null) throw new Error(`entry not found: ${abs}`)
1296
1442
  }
1297
- const tree = await loadTree({ project, packageManager, cwd, packageManagerVersion, os, cpu, libc, client })
1443
+ const tree = await loadTree({ project, packageManager, cwd, packageManagerVersion, os, cpu, libc, client, cache })
1298
1444
  let { host } = tree
1299
1445
  if (generate.includes('prisma')) {
1300
1446
  const { generatePrismaClients, withPrismaClients } = await import('../vfs-bundle/prisma.js')
@@ -1306,6 +1452,7 @@ export async function buildVfsBundle({ vfs, packageManager, cwd = '/', packageMa
1306
1452
  if (repo !== undefined) bundle.repo = repo
1307
1453
  // Rooted at cwd, where `stasis bundle` detects its repo.
1308
1454
  else if (!stateBuilt) bundle.repo ??= detectRepo(cwd, host)
1455
+ pinCommits(bundle, root, tree)
1309
1456
  return { bundle, lockfile: lockfile?.(), stats: tree.stats, packageManager, root }
1310
1457
  }
1311
1458
 
@@ -0,0 +1,182 @@
1
+ import { hash } from 'node:crypto'
2
+ import { dirname, join, relative, resolve } from 'node:path'
3
+
4
+ import { toBase32 } from '@exodus/bytes/base32.js'
5
+ import { isValidRepoField } from '@exodus/stasis-core/bundle'
6
+ import { parseGithubRepository, readJson, readText } from '@exodus/stasis-core/bundle-util'
7
+ import { diskHost } from '@exodus/stasis-core/host'
8
+ import { isPlainObject, relativeEscapes, toPosix } from '@exodus/stasis-core/util'
9
+ import { parsePnpmLockfile } from '@preventive/lockfile/pnpm.js'
10
+ import { parseYarn1Lockfile } from '@preventive/lockfile/yarn1.js'
11
+
12
+ // The commit each git dependency in a node_modules on disk is installed at, as the package manager
13
+ // that laid it out recorded it there: npm in its hidden lockfile, pnpm in its copy of the lockfile,
14
+ // yarn 1 in yarn.lock, as its .yarn-integrity has it installed. Nothing on disk names a registry
15
+ // package's. A record missing, unreadable, or naming no full commit of a GitHub repository records
16
+ // nothing, and fails nothing.
17
+
18
+ // `{ github, commit }` of `repo`, a git URL, at `commit`, where that is a GitHub repository and a full
19
+ // commit id; else undefined.
20
+ function githubCommit(repo, commit) {
21
+ const github = parseGithubRepository(repo)
22
+ return github !== null && isValidRepoField('commit', commit) ? { github, commit } : undefined
23
+ }
24
+
25
+ // GitHub's tarball of a commit, as yarn 1 and pnpm fetch a GitHub dependency.
26
+ const CODELOAD = /^https:\/\/codeload\.github\.com\/([^/]+\/[^/]+)\/tar\.gz\/([^/]+)$/u
27
+ const githubTarball = (url) => {
28
+ const [, github, commit] = CODELOAD.exec(url) ?? []
29
+ return isValidRepoField('github', github) ? githubCommit(github, commit) : undefined
30
+ }
31
+
32
+ // githubCommit of a git or tarball resolution @preventive/lockfile reads.
33
+ const resolutionCommit = (resolution) => {
34
+ if (resolution?.type === 'git') return githubCommit(resolution.repo, resolution.commit)
35
+ return resolution?.type === 'tarball' ? githubTarball(resolution.tarball ?? '') : undefined
36
+ }
37
+
38
+ // The lookup of a record that finds nothing: one unreadable, or none.
39
+ const NONE = () => undefined
40
+
41
+ // npm's, from npm 7: the hidden lockfile it writes into `dir`'s node_modules as it lays it out, by
42
+ // each package's location from `dir`, where it records the version installed there. A git
43
+ // dependency's `resolved` is the URL npm clones, `#` and the commit.
44
+ function npmRecord(dir, host) {
45
+ const packages = readJson(join(dir, 'node_modules', '.package-lock.json'), host)?.packages
46
+ if (!isPlainObject(packages)) return NONE
47
+ return (location, version) => {
48
+ const entry = Object.hasOwn(packages, location) ? packages[location] : undefined
49
+ if (!isPlainObject(entry) || entry.version !== version || typeof entry.resolved !== 'string') return undefined
50
+ const { resolved } = entry
51
+ if (!/^git(?:\+[\w.-]+)?:/u.test(resolved)) return githubTarball(resolved)
52
+ const at = resolved.lastIndexOf('#')
53
+ return at === -1 ? undefined : githubCommit(resolved.slice(0, at), resolved.slice(at + 1))
54
+ }
55
+ }
56
+
57
+ // yarn 1's: yarn.lock in `dir`, by the name a package is installed as, the last of its location, and
58
+ // its version, where one entry alone has both. The .yarn-integrity yarn writes into the node_modules
59
+ // it lays out has to hold the entry's resolution for each of its patterns, as yarn.lock's `resolved`
60
+ // spells it: a yarn.lock changed since often moves a git dependency to another commit of one version.
61
+ function yarnRecord(dir, host) {
62
+ const installed = readJson(join(dir, 'node_modules', '.yarn-integrity'), host)?.lockfileEntries
63
+ const text = readText(host, join(dir, 'yarn.lock'))
64
+ if (!isPlainObject(installed) || text === null) return NONE
65
+ let packages
66
+ try {
67
+ packages = parseYarn1Lockfile(text, { checkVersions: false }).packages
68
+ } catch {
69
+ return NONE // whatever the reader refuses
70
+ }
71
+ const byVersion = new Map()
72
+ for (const { patterns, name, version, resolution } of new Set(Object.values(packages))) {
73
+ let commit = resolutionCommit(resolution)
74
+ const resolved = resolution?.type === 'git' ? `${resolution.repo}#${resolution.commit}` : resolution?.sha1 === undefined ? resolution?.tarball : `${resolution.tarball}#${resolution.sha1}`
75
+ if (!patterns.every((pattern) => Object.hasOwn(installed, pattern) && installed[pattern] === resolved)) commit = undefined
76
+ // Of two entries of one name and version, either may be the one installed.
77
+ const id = `${name}@${version}`
78
+ byVersion.set(id, byVersion.has(id) ? undefined : commit)
79
+ }
80
+ return (location, version) => byVersion.get(`${location.slice(location.lastIndexOf('node_modules/') + 'node_modules/'.length)}@${version}`)
81
+ }
82
+
83
+ // What npm and yarn leave in a node_modules they lay out, and the reader of each one's record.
84
+ const LAYOUTS = [['.package-lock.json', npmRecord], ['.yarn-integrity', yarnRecord]]
85
+
86
+ // The names pnpm 9 to 12 may give snapshot `key`'s directory in node_modules/.pnpm, as its
87
+ // depPathToFilename (@pnpm/dependency-path) spells them: each character a path can't hold made `+`,
88
+ // `#` too from pnpm 10, and a peer's parentheses `_`; where that is too long or has a capital, cut
89
+ // short before `_` and a hash of it, 26 base32 of an MD5 for pnpm 9, 32 hex of a SHA-256 from pnpm 10,
90
+ // of which the hash alone is kept, as where it is cut depends on the install's
91
+ // virtualStoreDirMaxLength.
92
+ const STORE_NAMES = [
93
+ [/[\\/:*?"<>|]/gu, (name) => toBase32(hash('md5', name, 'buffer'), { padding: false }).toLowerCase()],
94
+ [/[\\/:*?"<>|#]/gu, (name) => hash('sha256', name).slice(0, 32)],
95
+ ]
96
+ const storeNames = (key) => STORE_NAMES.flatMap(([unsafe, digest]) => {
97
+ let name = key.replace(unsafe, '+')
98
+ if (name.includes('(')) name = name.replace(/\)$/u, '').replace(/\)\(|\(|\)/gu, '_')
99
+ return [name, `_${digest(name)}`]
100
+ })
101
+ const STORE_HASH = /_(?:[\da-f]{32}|[a-z2-7]{26})$/u
102
+
103
+ // pnpm's: lock.yaml in `store`, a node_modules/.pnpm, the lockfile of what pnpm 9 to 12 installed
104
+ // there, by the directory there each snapshot is in. A git dependency's resolution is the repository
105
+ // and commit, or GitHub's tarball of the commit, and the package's `path` in it (`#path:`) where it is
106
+ // not at the root.
107
+ function pnpmRecord(store, host) {
108
+ const text = readText(host, join(store, 'lock.yaml'))
109
+ let packages
110
+ try {
111
+ packages = text === null ? undefined : parsePnpmLockfile(text).lockfile?.packages
112
+ } catch {
113
+ return NONE // whatever the reader refuses
114
+ }
115
+ const byName = new Map()
116
+ for (const [key, { resolution }] of Object.entries(packages ?? {})) {
117
+ const commit = resolutionCommit(resolution)
118
+ if (commit === undefined) continue
119
+ const directory = (resolution.path ?? '').replace(/^\/+|\/+$/gu, '')
120
+ const repo = { github: commit.github, ...(isValidRepoField('directory', directory) && { directory }), commit: commit.commit }
121
+ for (const name of storeNames(key)) byName.set(name, repo)
122
+ }
123
+ return (name) => byName.get(name) ?? byName.get(STORE_HASH.exec(name)?.[0])
124
+ }
125
+
126
+ // A pnpm package's real path, <store>/<dir>/node_modules/<name>, its store a node_modules/.pnpm.
127
+ const PNPM_PACKAGE = /^(.*\/node_modules\/\.pnpm)\/([^/]+)\/node_modules\/(?:@[^/]+\/)?[^/]+$/u
128
+
129
+ // Each npm dependency of `bundle`, its buckets' paths from `root`, that is a git dependency of a GitHub
130
+ // repository, recorded as its package manager's record has it installed: that repository, over the
131
+ // one its package.json names (a fork's, where that names the one forked), at its root (`''`) or the
132
+ // subdirectory pnpm records, at that commit. A dependency is found by its real path, through the link
133
+ // pnpm installs it by: in a node_modules/.pnpm, by its directory there; else by its location from the
134
+ // nearest directory whose node_modules npm or yarn laid out, from the one its outermost node_modules
135
+ // below the bundle root is in up to that root, whatever a directory above the root is named: a
136
+ // record outside the root is another project's. Metadata, as `repo` is: never checked against the
137
+ // files installed.
138
+ export function pinInstalledCommits(bundle, root, host = diskHost) {
139
+ const stores = new Map()
140
+ const layouts = new Map()
141
+ let top // the bundle root's real path, as each package's is compared with it
142
+ // The record of `dir`'s node_modules, or null where neither npm nor yarn laid it out; where both
143
+ // left theirs, neither's, as which was last is not known.
144
+ const layoutAt = (dir) => {
145
+ if (!layouts.has(dir)) {
146
+ const found = LAYOUTS.filter(([file]) => host.stat(join(dir, 'node_modules', file)) !== null)
147
+ layouts.set(dir, found.length === 0 ? null : found.length === 1 ? found[0][1](dir, host) : NONE)
148
+ }
149
+ return layouts.get(dir)
150
+ }
151
+ for (const [dir, info] of bundle.modules) {
152
+ if (info.ecosystem !== 'npm') continue
153
+ let real
154
+ try {
155
+ real = toPosix(host.realpath(resolve(root, dir)))
156
+ } catch {
157
+ continue
158
+ }
159
+ let repo
160
+ const pnpm = PNPM_PACKAGE.exec(real)
161
+ if (pnpm !== null) {
162
+ const [, store, name] = pnpm
163
+ if (!stores.has(store)) stores.set(store, pnpmRecord(store, host))
164
+ repo = stores.get(store)(name)
165
+ } else {
166
+ try {
167
+ top ??= resolve(host.realpath(resolve(root)))
168
+ } catch {
169
+ return
170
+ }
171
+ const rel = relative(top, real)
172
+ const parts = toPosix(rel).split('/')
173
+ const at = parts.indexOf('node_modules')
174
+ if (relativeEscapes(rel) || at === -1) continue
175
+ let from = resolve(top, ...parts.slice(0, at))
176
+ while (layoutAt(from) === null && from !== top) from = dirname(from)
177
+ const commit = (layoutAt(from) ?? NONE)(toPosix(relative(from, real)), info.version)
178
+ if (commit !== undefined) repo = { github: commit.github, directory: '', commit: commit.commit }
179
+ }
180
+ if (repo !== undefined) bundle.modules.set(dir, { ...info, repo })
181
+ }
182
+ }