@exodus/stasis 1.0.0-beta.7 → 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.
@@ -6,38 +6,62 @@ import { satisfies, valid } from '@preventive/upstream/semver.js'
6
6
  // package pulled in only through corrected files is skipped by the audit (see
7
7
  // collectPackagesFromFile) -- none of its real code ships, so no advisory applies.
8
8
  //
9
- // Each entry names a package, the audit-irrelevant files (relative to the module
10
- // dir), and `range` -- the semver range VERIFIED to match the rationale. The
11
- // correction never applies outside it: a release could change the file, so the
12
- // range is widened only after re-checking the file in the new versions.
13
- const CORRECTIONS = [
9
+ // Exported as `@exodus/stasis/audit-corrections`, as plain data, so a tool that
10
+ // lays out or reads packages without stasis -- a tree @preventive/deptree builds,
11
+ // a server reading bundles -- can apply the same corrections without its own copy.
12
+ // Each entry names a package of `ecosystem`, one audit-irrelevant `file` (relative
13
+ // to the module dir), `range` -- the semver range VERIFIED to match the rationale --
14
+ // and `integrity`, the sha512 of every distinct copy of `file` the versions in
15
+ // `range` publish, as a stasis lockfile records a file: a holder of the file's bytes
16
+ // can tell they are one of the copies verified. The correction never applies outside
17
+ // the range: a release could change the file, so the range is widened only after
18
+ // re-checking the file in the new versions, adding any new copy's integrity. Frozen,
19
+ // as the audit reads these very objects.
20
+ export const CORRECTIONS = Object.freeze([
14
21
  {
15
22
  // ws's browser build is a noop stub -- `module.exports = function () { throw
16
23
  // new Error('ws does not work in the browser...') }` -- with none of the
17
- // WebSocket implementation in it. Verified against the ws 8.21.1 tarball
18
- // (latest at the time of writing); re-check browser.js before widening.
24
+ // WebSocket implementation in it. browser.js ships from 6.0.0 on; every copy
25
+ // through 8.22.0 (latest at the time of writing) is one of these two, which
26
+ // differ only in a space; re-check browser.js before widening.
27
+ ecosystem: 'npm',
19
28
  name: 'ws',
20
- files: new Set(['browser.js']),
21
- range: '<=8.21.1',
29
+ file: 'browser.js',
30
+ range: '<=8.22.0',
31
+ integrity: [
32
+ 'sha512-98uEc4/THUpHCA7AQwBKLHHmxV6leyGmPkDAYZsoBiigxjKL8//TIb/oUKDUJLc+s/pwdGA9LR0F9e7nZHfXUQ==', // `function ()`: 6.0.0 - 6.1.1, 7.3.1 - 8.22.0
33
+ 'sha512-F7vx6oiX9vsFoxNPTudk20Hjp7IzJePAJCYaSoX9FMJ2JaGVcFx4Bkw5W7+I3a1DsonitnosbpuG1qAMsUwH7w==', // `function()`: 6.1.2 - 7.3.0
34
+ ],
22
35
  },
23
36
  {
24
37
  // node-fetch's browser build re-exports the environment's native fetch
25
38
  // (`module.exports = globalObject.fetch`, plus Headers/Request/Response) --
26
39
  // none of the node-fetch implementation, where its advisories live, is in it.
27
- // browser.js ships only through 2.7.0 (3.x dropped it); verified 2.6.13/2.7.0.
40
+ // browser.js ships only from 2.0.0 through 2.7.0 (3.x dropped it); every copy
41
+ // in the range is one of these, which differ in how they find the global.
42
+ ecosystem: 'npm',
28
43
  name: 'node-fetch',
29
- files: new Set(['browser.js']),
44
+ file: 'browser.js',
30
45
  range: '<=2.7.0',
46
+ integrity: [
47
+ 'sha512-uCZB0X6rUVIf5lZU2NN9/Z1Fd6cPCLg4M9eduYxOgSP3QZNwi0/xSa/s6AUFtvLz4y4MK02SJihOIhDoFIzdiA==', // 2.0.0 - 2.1.1
48
+ 'sha512-toQ4RDdyjsAxTaxnaLs2AE56rXws+jMZV896U8pP0MSuKRiq8ZGKkOTMsvyCJoAJTbrgoQpr89H2n8+Z1ALcYA==', // 2.1.2
49
+ 'sha512-LOd+I1R3xYSCHFN9oj3xeN3wa7fYYx5XQKTiKkvWn4dSYVgHI2jsXZp0mJt5KlQg3ECXXh5UtpEW7kGu1dG/aQ==', // 2.2.0 - 2.2.1
50
+ 'sha512-2yo4Ysl+g09ocycwEBcN7IkSvM/KPpO9CW+1FI+0QfzvWJfaQepo+WEqn1/o5Og1weM14flcdv23hqZtxFXFUw==', // 2.3.0 - 2.6.0
51
+ 'sha512-WgHaZJVwFovVrKVySaKzxzXssn5kY8y3x8NJThodDRL9nwUxV7Q1z/RRzuLZ2lNkxabHLpG/l+vu7KQ2tGbP8g==', // 2.6.1 - 2.6.7
52
+ 'sha512-84sQFcu3uzgEsJOjIhz6tE8vInV1STCA4sX5EI9NK3Tt8n/Kb1serbKMz+YkdOAuCspHRiSUW2qV01z0wOgIXg==', // 2.6.8
53
+ 'sha512-SkBfLO20d2Ngr6nhxgh/Pt6Yqe6oW8EB99KSPq0lrW0se8jIiRF3YpOe7hHbfNWxuNCsKoZZT98uCJI9OJAa/Q==', // 2.6.9 - 2.7.0
54
+ ],
31
55
  },
32
- ]
56
+ ].map((correction) => Object.freeze({ ...correction, integrity: Object.freeze(correction.integrity) })))
33
57
 
34
58
  // Is `rel` (a file path relative to the module dir) audit-irrelevant for
35
- // `name@version`? Unknown or unparsable versions are never corrected (fail
36
- // closed: the package stays audited).
37
- function isCorrectedFile(name, version, rel) {
38
- for (const { name: pkg, files, range } of CORRECTIONS) {
39
- if (pkg !== name || !files.has(rel)) continue
40
- if (valid(version) && satisfies(version, range)) return true
59
+ // `name@version` of `ecosystem`? Unknown or unparsable versions are never
60
+ // corrected (fail closed: the package stays audited).
61
+ function isCorrectedFile(name, version, rel, ecosystem) {
62
+ for (const correction of CORRECTIONS) {
63
+ if (correction.ecosystem !== ecosystem || correction.name !== name || correction.file !== rel) continue
64
+ if (valid(version) && satisfies(version, correction.range)) return true
41
65
  }
42
66
  return false
43
67
  }
@@ -65,8 +89,8 @@ const isManifest = (rel, ecosystem) => MANIFESTS[ecosystem]?.has(rel.slice(rel.l
65
89
  // Is `rel` evidence that `name@version`'s REAL code is present, of a dependency of
66
90
  // `ecosystem` (npm by default)? This is the one rule every audit surface shares --
67
91
  // package presence, the reason column, and the --why chain graph all count a file
68
- // (or an edge targeting it) only when it passes. Manifests never do; corrected
69
- // files (npm's) don't within their verified range.
92
+ // (or an edge targeting it) only when it passes. Manifests never do; CORRECTIONS'
93
+ // files don't within their verified range.
70
94
  export function isEvidenceFile(name, version, rel, ecosystem = 'npm') {
71
- return !isManifest(rel, ecosystem) && (ecosystem !== 'npm' || !isCorrectedFile(name, version, rel))
95
+ return !isManifest(rel, ecosystem) && !isCorrectedFile(name, version, rel, ecosystem)
72
96
  }
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