@exodus/stasis 1.0.0-beta.1 → 1.0.0-beta.10

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.
Files changed (48) hide show
  1. package/README.md +32 -8
  2. package/bin/stasis.js +386 -213
  3. package/package.json +52 -12
  4. package/src/audit-corrections.js +96 -0
  5. package/src/audit.js +289 -64
  6. package/src/babel-worker.cjs +65 -0
  7. package/src/babel.js +131 -0
  8. package/src/bundle-from-lockfile.js +14 -22
  9. package/src/bundle-reader.js +77 -0
  10. package/src/cmd/build.js +96 -128
  11. package/src/cmd/bundle.js +1121 -654
  12. package/src/cmd/diff.js +2 -19
  13. package/src/cmd/github-bundle.js +39 -0
  14. package/src/cmd/output.js +36 -0
  15. package/src/cmd/sbom.js +7 -19
  16. package/src/diff.js +80 -129
  17. package/src/installed-commits.js +182 -0
  18. package/src/json-stream.js +283 -0
  19. package/src/loader-mock.js +8 -15
  20. package/src/loaders/bash.js +49 -116
  21. package/src/loaders/cargo.js +1716 -0
  22. package/src/loaders/foundry.js +923 -0
  23. package/src/loaders/paths.js +33 -0
  24. package/src/loaders/php.js +375 -402
  25. package/src/loaders/rust.js +3254 -242
  26. package/src/loaders/solidity-ownership.js +531 -0
  27. package/src/loaders/solidity.js +412 -162
  28. package/src/loaders/toml.js +68 -0
  29. package/src/metro-resolver.js +150 -0
  30. package/src/metro-transformer.js +3 -2
  31. package/src/mock.js +57 -165
  32. package/src/parse.js +4 -16
  33. package/src/resolve-fields.js +370 -0
  34. package/src/resolve-node.js +419 -0
  35. package/src/resolve-typescript.js +541 -0
  36. package/src/rollup.js +5 -0
  37. package/src/sbom.js +120 -97
  38. package/src/scan.js +348 -180
  39. package/src/vfs-bundle/entries.js +144 -0
  40. package/src/vfs-bundle/github.js +202 -0
  41. package/src/vfs-bundle/prisma.js +439 -0
  42. package/src/vfs-bundle/soldeer.js +125 -0
  43. package/src/vfs-bundle/tree.js +647 -0
  44. package/src/vfs-bundle.js +80 -0
  45. package/src/why.js +434 -0
  46. package/src/apis/npm/index.js +0 -36
  47. package/src/apis/npm/semver.cjs +0 -44
  48. package/src/cmd/extract.js +0 -193
package/src/cmd/diff.js CHANGED
@@ -4,25 +4,8 @@ import { sha512integrity } from '@exodus/stasis-core/state-util'
4
4
  import { parseFileWithKind } from '../parse.js'
5
5
  import { diffArtifacts, formatDiffStat, hasDifferences } from '../diff.js'
6
6
 
7
- // Run the `stasis diff` CLI command end-to-end: read the two given
8
- // lockfiles/bundles from disk (this is the layer that pulls in brotli, via
9
- // `parseFileWithKind`), diff them through the file-free `@exodus/stasis/diff`
10
- // API, and print the requested view. `left` is the baseline ("from"), `right`
11
- // the comparison ("to"). Today only the `--stat` summary is implemented, so
12
- // `stat` is required; the report goes to `out` (stdout by default). With
13
- // `imports: true` (the `--imports` flag) the report also diffs the resolution
14
- // graphs — which import edges were added, removed, or redirected.
15
- //
16
- // This layer owns the canonical hash: the file-free diff API doesn't pull in
17
- // `node:crypto`, so we inject `sha512integrity` (the same digest `stasis run`
18
- // records) for it to re-hash bundle bytes with.
19
- //
20
- // Either side may be a lockfile or a bundle, in any combination: a bundle's
21
- // recorded bytes are re-hashed into the SRI digests a lockfile records, so a
22
- // lockfile and a bundle compare in the same digest space (see src/diff.js).
23
- //
24
- // Returns `{ diff, differences }` so the caller can set the process exit code
25
- // (non-zero when the artifacts differ, like `diff(1)`).
7
+ // Run `stasis diff`: read both lockfiles/bundles from disk and diff them through
8
+ // the file-free diff API. Returns { diff, differences } for the caller's exit code.
26
9
  export function diffCommand({ cwd = process.cwd(), left, right, stat, imports = false, out = process.stdout } = {}) {
27
10
  if (!left || !right) throw new Error('diff: two files (lockfile or bundle) are required')
28
11
  if (!stat) throw new Error('diff: only --stat is implemented; pass stat: true')
@@ -0,0 +1,39 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { resolve } from 'node:path'
3
+
4
+ import { createClient } from '@preventive/upstream/github.js'
5
+ import { buildGitHubBundle } from '../vfs-bundle/github.js'
6
+ import { bundledSummary, writeBundle, writeFile } from './output.js'
7
+
8
+ // The longest file name ext4, APFS and NTFS take: 255 bytes, or characters, ours being ASCII.
9
+ const NAME_MAX = 255
10
+ // Each character outside the portable filename set [A-Za-z0-9._-] made `_`.
11
+ const portable = (s) => s.replaceAll(/[^\w.-]/gu, '_')
12
+
13
+ // The default output of `stasis github-bundle`: `owner-name.<commit's first 7>.stasis.code.br` of
14
+ // the repo, or `owner-name.<directory, its / made ->.<commit's first 7>.stasis.code.br` of a
15
+ // directory in it, portable. A directory too deep for that to fit in NAME_MAX keeps what fits of
16
+ // its start, and `_` and the first 8 of its sha256 after it; a valid `github` (at most 140
17
+ // characters) leaves at least 91 for it.
18
+ export function githubBundleFile({ github, directory, commit }) {
19
+ const repo = portable(github.replace('/', '-'))
20
+ const tail = `.${portable(commit.slice(0, 7))}.stasis.code.br`
21
+ if (!directory) return `${repo}${tail}`
22
+ const room = NAME_MAX - `${repo}.${tail}`.length
23
+ let dir = portable(directory.replaceAll('/', '-'))
24
+ if (dir.length > room) dir = `${dir.slice(0, room - 9)}_${createHash('sha256').update(directory).digest('hex').slice(0, 8)}`
25
+ return `${repo}.${dir}${tail}`
26
+ }
27
+
28
+ // Run `stasis github-bundle`: the bundle of a GitHub repo at a commit, or the one a tag names (the
29
+ // default branch's head without either), as buildGitHubBundle builds it from `options`, written
30
+ // brotli-compressed to `output` (githubBundleFile's name for it by default, `-` for stdout), and a
31
+ // JS bundle's lockfile to `lockfile` where given.
32
+ // The tree is fetched with GITHUB_TOKEN from `env` where it is set; nothing else of `env` is read.
33
+ export async function githubBundleCommand({ cwd = process.cwd(), env = process.env, output, lockfile, brotliQuality, client, ...options } = {}) {
34
+ const built = await buildGitHubBundle({ ...options, lockfile, client: client ?? createClient({ token: env.GITHUB_TOKEN || null }) })
35
+ const dest = writeBundle(cwd, output ?? githubBundleFile({ github: options.github, directory: options.directory, commit: built.bundle.repo.commit }), built.bundle.serialize(), brotliQuality)
36
+ if (lockfile !== undefined) writeFile(resolve(cwd, lockfile), built.lockfile.serialize())
37
+ const from = `${options.github}@${built.bundle.repo.commit}${options.directory ? `/${options.directory}` : ''}`
38
+ console.warn(bundledSummary(built.bundle.sources.size, built.bundle.modules, from, dest))
39
+ }
@@ -0,0 +1,36 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs'
2
+ import { dirname, resolve } from 'node:path'
3
+ import { brotliCompressSync } from 'node:zlib'
4
+
5
+ import { brotliOptions } from '@exodus/stasis-core/brotli'
6
+
7
+ // Default bundle output: stasis.code.br, the same name `stasis run --bundle=load` discovers, so the two round-trip with no flags.
8
+ export const DEFAULT_BUNDLE_FILE = 'stasis.code.br'
9
+
10
+ // Write `data` to the absolute path `abs`, creating its directory.
11
+ export function writeFile(abs, data) {
12
+ mkdirSync(dirname(abs), { recursive: true })
13
+ writeFileSync(abs, data)
14
+ }
15
+
16
+ // Write `data` to `target` (resolved against `cwd`), or to stdout for `-`. -> where it went, for the summary
17
+ export function writeOutput(cwd, target, data) {
18
+ if (target === '-') {
19
+ process.stdout.write(data)
20
+ return '<stdout>'
21
+ }
22
+ writeFile(resolve(cwd, target), data)
23
+ return target
24
+ }
25
+
26
+ // Write a bundle's `serialized` text brotli-compressed (at `brotliQuality`) as writeOutput does. -> where it went
27
+ export const writeBundle = (cwd, target, serialized, brotliQuality) => writeOutput(cwd, target, brotliCompressSync(serialized, brotliOptions(brotliQuality)))
28
+
29
+ // `N package(s)`: every non-empty bucket of a bundle's `modules` (the "." workspace bucket counts as one).
30
+ export function packagesLabel(modules) {
31
+ const packages = [...modules.values()].filter((m) => Object.keys(m.files).length > 0).length
32
+ return `${packages} package${packages === 1 ? '' : 's'}`
33
+ }
34
+
35
+ // The summary of a bundle of `count` files in `modules` written from `from` to `dest`.
36
+ export const bundledSummary = (count, modules, from, dest) => `[stasis] Bundled ${count} files in ${packagesLabel(modules)} from ${from} to ${dest}`
package/src/cmd/sbom.js CHANGED
@@ -1,16 +1,10 @@
1
- import { mkdirSync, writeFileSync } from 'node:fs'
2
- import { dirname, resolve } from 'node:path'
1
+ import { resolve } from 'node:path'
3
2
 
4
3
  import { parseFile } from '../parse.js'
5
4
  import { collectComponents, generateSbom } from '../sbom.js'
5
+ import { writeOutput } from './output.js'
6
6
 
7
- // Run the `stasis sbom` CLI command end-to-end: read the given lockfiles/bundles
8
- // from disk (this is the layer that pulls in brotli, via `parseFile`), build an
9
- // SBOM in the requested `format` (spdx | cyclonedx) through the file-free
10
- // `@exodus/stasis/sbom` API, and write the pretty-printed JSON to `output` (a
11
- // file, or stdout when `output` is `-` or unset). The one-line summary goes to
12
- // stderr so it never corrupts a document streamed to stdout. `now`/`uuid` are
13
- // injectable for deterministic output.
7
+ // The one-line summary goes to stderr so it never corrupts an SBOM streamed to stdout.
14
8
  export function sbomCommand({ cwd = process.cwd(), files, format, output, now, uuid } = {}) {
15
9
  if (!Array.isArray(files) || files.length === 0) {
16
10
  throw new Error('sbom: at least one lockfile or bundle is required')
@@ -20,16 +14,10 @@ export function sbomCommand({ cwd = process.cwd(), files, format, output, now, u
20
14
  const doc = generateSbom(format, components, { now, uuid })
21
15
  const text = JSON.stringify(doc, undefined, 2) + '\n'
22
16
 
23
- const target = output ?? '-'
24
- if (target === '-') {
25
- process.stdout.write(text)
26
- } else {
27
- const outAbs = resolve(cwd, target)
28
- mkdirSync(dirname(outAbs), { recursive: true })
29
- writeFileSync(outAbs, text)
30
- }
31
- const dest = target === '-' ? '<stdout>' : target
17
+ const dest = writeOutput(cwd, output ?? '-', text)
32
18
  const n = components.length
33
- console.warn(`[stasis] Wrote ${format} SBOM with ${n} component${n === 1 ? '' : 's'} to ${dest}`)
19
+ const vendored = components.reduce((sum, c) => sum + (c.vendored?.length ?? 0), 0)
20
+ const inThem = vendored === 0 ? '' : ` (and ${vendored} vendored in them)`
21
+ console.warn(`[stasis] Wrote ${format} SBOM with ${n} component${n === 1 ? '' : 's'}${inThem} to ${dest}`)
34
22
  return { components, doc }
35
23
  }
package/src/diff.js CHANGED
@@ -1,34 +1,15 @@
1
- import { sortPaths } from '@exodus/stasis-core/util'
1
+ import { reposAgree, sortPaths } from '@exodus/stasis-core/util'
2
2
 
3
- // `@exodus/stasis/diff` — compare two already-parsed stasis artifacts
4
- // (`Bundle`/`Lockfile` instances) and report what changed, at both the module
5
- // (package) and the file level. Like `@exodus/stasis/sbom`, it never reads disk
6
- // and pulls in no brotli (`node:zlib`) or crypto (`node:crypto`): the caller
7
- // reads/decompresses the artifacts, hands over parsed instances, and — to
8
- // compare a bundle's bytes against a lockfile's digests — supplies the hash
9
- // function (see `hash` below). The CLI glue that reads files off disk and wires
10
- // in `sha512integrity` lives in `src/cmd/diff.js`.
11
- //
12
- // The key to comparing across kinds — diffing a lockfile against a bundle — is
13
- // that every artifact reduces to the same "digest space". A lockfile already
14
- // records each file as an SRI digest (`sha512-…`); a bundle records the file's
15
- // bytes, so we re-hash those bytes (via the injected `hash`) into the very
16
- // digest a lockfile would have recorded. A bundle stores code and 'resource'
17
- // files as UTF-8 text and 'resource:base64' files as base64 blobs, so the
18
- // hashing path is decided PER FILE from the bundle's `formats` map -- not
19
- // from a whole-bundle kind.
3
+ // `@exodus/stasis/diff` — compare two parsed stasis artifacts (Bundle/Lockfile) at the module and
4
+ // file level. Never reads disk. Comparing across kinds works by reducing every file to the same
5
+ // digest space: a lockfile already holds SRI digests; a bundle holds bytes, re-hashed via the
6
+ // injected `hash` — so a `hash` function is required whenever a bundle is an operand. Per-file
7
+ // encoding (resource:base64 vs text) is read from the bundle's `formats` map.
20
8
 
21
9
  const KINDS = new Set(['lockfile', 'bundle'])
22
10
 
23
- // Reduce one file's recorded value to the SRI digest a lockfile records for it.
24
- // 'lockfile' values are already digests; a bundle's values are file bytes that
25
- // must be re-hashed. The encoding is now per-file (one bundle holds both code and
26
- // resources): a 'resource:base64' file is a base64 blob decoded back to raw bytes
27
- // first, while code files and raw-UTF-8 'resource' files hash as their string. This
28
- // is the "rehash the bundle" step that lets a bundle and a lockfile be compared,
29
- // and the reason a `hash` function is required whenever a bundle is involved.
30
- // `hash` takes a string or Buffer and returns the SRI digest (e.g. the
31
- // `sha512integrity` the CLI injects); a lockfile needs none.
11
+ // Reduce one file's recorded value to an SRI digest: lockfile values already are digests; a
12
+ // bundle's are re-hashed, with a `resource:base64` file decoded to bytes first (others hash as text).
32
13
  function digestOf(kind, format, value, hash) {
33
14
  if (kind === 'lockfile') return value
34
15
  if (typeof hash !== 'function') {
@@ -38,12 +19,10 @@ function digestOf(kind, format, value, hash) {
38
19
  return hash(value)
39
20
  }
40
21
 
41
- // Project an artifact onto a uniform shape for diffing: its scope, plus a map of
42
- // package dir -> { name, version, ecosystem, files: Map<rel, digest> }. `input`
43
- // is `{ artifact, kind }` where `artifact` is a parsed Bundle/Lockfile and `kind`
44
- // is 'lockfile' | 'bundle' (see parseFileWithKind). `hash` is the digest function
45
- // used to re-hash a bundle's bytes; it is only consulted for bundles (a lockfile
46
- // already holds digests). Per-file encoding is read from the bundle's `formats`.
22
+ const projectPath = (dir, rel) => (dir === '.' ? rel : `${dir}/${rel}`)
23
+
24
+ // Project an artifact onto a uniform shape: scope + Map<dir, { name, version, ecosystem, repo,
25
+ // files: Map<rel, digest> }>. `input` is { artifact, kind }; `hash` re-hashes bundle bytes only.
47
26
  export function normalizeArtifact(input, { hash } = {}) {
48
27
  const { artifact, kind } = input ?? {}
49
28
  if (!artifact || !KINDS.has(kind)) {
@@ -51,50 +30,24 @@ export function normalizeArtifact(input, { hash } = {}) {
51
30
  }
52
31
  const formats = artifact.formats ?? new Map()
53
32
  const modules = new Map()
54
- for (const [dir, { name, version, ecosystem, files }] of artifact.modules) {
33
+ for (const [dir, { name, version, ecosystem, repo, files }] of artifact.modules) {
55
34
  const digests = new Map()
56
35
  for (const [rel, value] of Object.entries(files)) {
57
- const full = dir === '.' ? rel : `${dir}/${rel}`
58
- digests.set(rel, digestOf(kind, formats.get(full), value, hash))
36
+ digests.set(rel, digestOf(kind, formats.get(projectPath(dir, rel)), value, hash))
59
37
  }
60
- // v0 bundles record no name/version (null); normalize undefined to null too
61
- // so module-metadata comparisons have a single "unknown" sentinel.
62
- modules.set(dir, { name: name ?? null, version: version ?? null, ecosystem: ecosystem ?? null, files: digests })
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, repo, files: digests })
63
40
  }
64
- return { scope: artifact.config?.scope ?? 'full', modules }
41
+ // `executable` decides which files `stasis extract` chmods +x, so a change to it changes the tree
42
+ // an artifact produces even when every digest matches -- it has to be part of "did these differ?".
43
+ return { scope: artifact.config?.scope ?? 'full', modules, executable: artifact.executable ?? new Set() }
65
44
  }
66
45
 
67
- const projectPath = (dir, rel) => (dir === '.' ? rel : `${dir}/${rel}`)
68
-
69
- // Diff two artifacts. `left`/`right` are each `{ artifact, kind }`; `left` is the
70
- // baseline ("from"), `right` the comparison ("to"). Returns a structured report:
71
- //
72
- // {
73
- // scope: { left, right },
74
- // modules: {
75
- // added: [{ dir, name, version, ecosystem, files }], // only in right
76
- // removed: [{ dir, name, version, ecosystem, files }], // only in left
77
- // changed: [{ dir, name, versionChange?, nameChange? }] // in both, metadata differs
78
- // },
79
- // files: { added: [path], removed: [path], differing: [path] },
80
- // imports?: { attested: { left, right }, added, removed, changed } // only with { imports: true }
81
- // }
82
- //
83
- // Modules are compared whole-package; files are compared within packages present
84
- // on BOTH sides. A package that exists on only one side is reported once as an
85
- // added/removed module (carrying its file count) rather than re-listing each of
86
- // its files — so the two levels are complementary, not redundant. A module's
87
- // `version`/`name` change is only reported when both sides record a (non-null)
88
- // value, since a v0 bundle records neither.
89
- //
90
- // With `{ imports: true }` the diff also compares the artifacts' resolution
91
- // graphs (the `imports` edges both lockfiles and bundles carry) and reports
92
- // which edges were added, removed, or redirected — see `diffImports`. It is
93
- // opt-in because the edge graph is verbose and not always attested.
94
- //
95
- // `hash` is the digest function used to re-hash bundle bytes (the CLI passes
96
- // `sha512integrity`); it is required only when a bundle is one of
97
- // the operands — a lockfile↔lockfile diff needs none.
46
+ // Diff two `{ artifact, kind }` operands (`left` = baseline/"from", `right` = "to"). Modules are
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/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
+ // (opt-in; verbose). `hash` is required only when a bundle is an operand.
98
51
  export function diffArtifacts(left, right, { imports = false, hash } = {}) {
99
52
  const L = normalizeArtifact(left, { hash })
100
53
  const R = normalizeArtifact(right, { hash })
@@ -106,20 +59,20 @@ export function diffArtifacts(left, right, { imports = false, hash } = {}) {
106
59
  const filesRemoved = []
107
60
  const filesDiffering = []
108
61
 
62
+ const summary = (dir, m) => ({ dir, name: m.name, version: m.version, ecosystem: m.ecosystem, files: m.files.size })
109
63
  const dirs = new Set([...L.modules.keys(), ...R.modules.keys()])
110
64
  for (const dir of dirs) {
111
65
  const l = L.modules.get(dir)
112
66
  const r = R.modules.get(dir)
113
67
  if (l && !r) {
114
- modulesRemoved.push({ dir, name: l.name, version: l.version, ecosystem: l.ecosystem, files: l.files.size })
68
+ modulesRemoved.push(summary(dir, l))
115
69
  continue
116
70
  }
117
71
  if (!l && r) {
118
- modulesAdded.push({ dir, name: r.name, version: r.version, ecosystem: r.ecosystem, files: r.files.size })
72
+ modulesAdded.push(summary(dir, r))
119
73
  continue
120
74
  }
121
75
 
122
- // Present on both sides: compare package metadata, then files within.
123
76
  const change = { dir, name: r.name ?? l.name }
124
77
  if (l.version !== null && r.version !== null && l.version !== r.version) {
125
78
  change.versionChange = { from: l.version, to: r.version }
@@ -127,7 +80,11 @@ export function diffArtifacts(left, right, { imports = false, hash } = {}) {
127
80
  if (l.name !== null && r.name !== null && l.name !== r.name) {
128
81
  change.nameChange = { from: l.name, to: r.name }
129
82
  }
130
- 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)
131
88
 
132
89
  const rels = new Set([...l.files.keys(), ...r.files.keys()])
133
90
  for (const rel of rels) {
@@ -148,35 +105,32 @@ export function diffArtifacts(left, right, { imports = false, hash } = {}) {
148
105
  filesRemoved.sort(sortPaths)
149
106
  filesDiffering.sort(sortPaths)
150
107
 
108
+ // Execute-bit changes, reported for every path either side marks (not just files in shared
109
+ // modules): gaining or losing +x is a real change to the extracted tree, whatever bucket it sits in.
110
+ const onlyIn = (a, b) => [...a].filter((file) => !b.has(file)).toSorted(sortPaths)
111
+ const executableAdded = onlyIn(R.executable, L.executable)
112
+ const executableRemoved = onlyIn(L.executable, R.executable)
113
+
151
114
  const result = {
152
115
  scope: { left: L.scope, right: R.scope },
153
116
  modules: { added: modulesAdded, removed: modulesRemoved, changed: modulesChanged },
154
117
  files: { added: filesAdded, removed: filesRemoved, differing: filesDiffering },
118
+ executable: { added: executableAdded, removed: executableRemoved },
155
119
  }
156
120
  if (imports) result.imports = diffImports(left, right)
157
121
  return result
158
122
  }
159
123
 
160
- // The resolution graph an artifact attests, or null when it attests none. A
161
- // unified bundle always carries a (possibly empty) `imports` Map, so it attests
162
- // its resolution set -- empty for a resources-only bundle. A lockfile predating
163
- // resolution attestation records `imports === null` and attests none.
124
+ // The resolution graph an artifact attests, or null when it attests none (a legacy lockfile
125
+ // predating attestation); a bundle always carries a possibly-empty map.
164
126
  function attestedImports({ artifact }) {
165
127
  return artifact.imports ?? null
166
128
  }
167
129
 
168
- // Collapse an imports map (conditions -> parent -> specifier -> target) to a map
169
- // of (parent, specifier) -> Set<target>, unioning over every conditions key.
170
- // We diff at this *resolution* level — "what does this import resolve to?" —
171
- // rather than at the raw (conditions, parent, specifier) edge key, because the
172
- // two sides label conditions differently: a statically built bundle records
173
- // every edge under the wildcard "*", while a runtime lockfile records precise
174
- // condition sets like "node, import". Keying on conditions would then report a
175
- // byte-identical resolution as removed (the "*" edge) + added (the precise
176
- // edge); folding conditions away makes them compare equal. This mirrors how the
177
- // loader reconciles "*" against precise sets when verifying a resolution
178
- // (state.js #assertAttestedResolution): the final target is what matters, not
179
- // how the edge is keyed.
130
+ // Collapse an imports map (conditions -> parent -> specifier -> target) to (parent, specifier) ->
131
+ // Set<target>, unioning over conditions: a static bundle keys every edge under "*" while a runtime
132
+ // lockfile uses precise sets, so keying on conditions would report identical resolutions as
133
+ // removed+added. Folding conditions away makes them compare equal.
180
134
  function resolutionsOf(imports) {
181
135
  const byPair = new Map()
182
136
  for (const [, byParent] of imports) {
@@ -185,9 +139,7 @@ function resolutionsOf(imports) {
185
139
  const key = JSON.stringify([parent, specifier])
186
140
  let entry = byPair.get(key)
187
141
  if (!entry) byPair.set(key, (entry = { parent, specifier, targets: new Set() }))
188
- // A `--metro` edge target may be a { platform: file } map; fold each
189
- // platform's file into the target set, so a redirect is compared at the
190
- // "what files does this resolve to" level regardless of platform keying.
142
+ // A `--metro` edge target may be a { platform: file } map; fold each platform's file into the set.
191
143
  if (target instanceof Map) for (const f of target.values()) entry.targets.add(f)
192
144
  else entry.targets.add(target)
193
145
  }
@@ -201,17 +153,10 @@ const cmpPair = (a, b) => sortPaths(a.parent, b.parent) || cmpStr(a.specifier, b
201
153
  const sortedTargets = (set) => [...set].toSorted(sortPaths)
202
154
  const sameTargets = (a, b) => a.size === b.size && [...a].every((t) => b.has(t))
203
155
 
204
- // Diff the resolution graphs at the (parent, specifier) level (see
205
- // `resolutionsOf` for why conditions are folded away). Each side maps a
206
- // (parent, specifier) to the set of files it resolves to (usually one). Reports:
207
- // added — a (parent, specifier) only the right artifact resolves
208
- // removed — a (parent, specifier) only the left artifact resolves
209
- // changed — resolved on both sides, but to a different set of targets (a
210
- // redirect — the kind of change a byte/format diff can't see)
211
- // `from`/`to` are sorted arrays of target files. When either side does not
212
- // attest imports at all (a legacy lockfile predating `imports`), the
213
- // comparison is skipped and `attested` flags why, rather than reporting one
214
- // side's whole graph as added/removed.
156
+ // Diff resolution graphs at the (parent, specifier) level (conditions folded away — see
157
+ // resolutionsOf). Reports added/removed and changed (resolved on both sides to different targets —
158
+ // a redirect a byte diff can't see). When either side doesn't attest imports, the diff is skipped
159
+ // and `attested` flags why, rather than reporting one whole graph as added/removed.
215
160
  export function diffImports(left, right) {
216
161
  const li = attestedImports(left)
217
162
  const ri = attestedImports(right)
@@ -238,16 +183,17 @@ export function diffImports(left, right) {
238
183
  return { attested, added, removed, changed }
239
184
  }
240
185
 
241
- // True when the diff records any module-, file-, or (when present) import-level
242
- // change. Scope alone is informational (a scope difference shows up as the
243
- // modules/files only one side records) and does not, on its own, count as a
244
- // difference. Imports that can't be compared (one side doesn't attest them)
245
- // contribute nothing — their add/remove/change lists are empty.
186
+ // True when the diff records any module-, file-, or import-level change. Scope alone is
187
+ // informational and doesn't count (unattested imports contribute empty lists).
246
188
  export function hasDifferences(diff) {
247
- const { modules, files, imports } = diff
189
+ // `executable` is unguarded like modules/files -- diffArtifacts always sets it, and a shape guard
190
+ // here would make a renamed facet read as "no differences" and quietly pass a review gate.
191
+ // `imports` is genuinely opt-in (the `imports` option), so it keeps its guard.
192
+ const { modules, files, executable, imports } = diff
248
193
  let n =
249
194
  modules.added.length + modules.removed.length + modules.changed.length +
250
- files.added.length + files.removed.length + files.differing.length
195
+ files.added.length + files.removed.length + files.differing.length +
196
+ executable.added.length + executable.removed.length
251
197
  if (imports) n += imports.added.length + imports.removed.length + imports.changed.length
252
198
  return n > 0
253
199
  }
@@ -256,16 +202,19 @@ const KIND_LABEL = { lockfile: 'lockfile', bundle: 'bundle' }
256
202
  const plural = (n) => (n === 1 ? '' : 's')
257
203
  const ident = ({ name, version }) => (name ? (version ? `${name}@${version}` : name) : '')
258
204
 
259
- // Diff markers. `~` reads too close to `-` in a terminal, so a changed/redirected
260
- // entry is marked `*` (distinct from `-` removed / `+` added).
205
+ // Diff markers: `*` (changed) — `~` reads too close to `-` in a terminal.
261
206
  const REMOVED = '-'
262
207
  const ADDED = '+'
263
208
  const CHANGED = '*'
264
209
 
265
- 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 }) {
266
214
  const segs = []
267
215
  if (versionChange) segs.push(`${versionChange.from} -> ${versionChange.to}`)
268
216
  if (nameChange) segs.push(`name ${nameChange.from} -> ${nameChange.to}`)
217
+ if (repoChange) segs.push(`repo ${repoLabel(repoChange.from)} -> ${repoLabel(repoChange.to)}`)
269
218
  return `${name ? `${name} ` : ''}${segs.join(', ')}`
270
219
  }
271
220
 
@@ -274,20 +223,15 @@ const moduleLine = (mark, x) => {
274
223
  return ` ${mark} ${x.dir}${id ? ` ${id}` : ''} (${x.files} file${plural(x.files)})`
275
224
  }
276
225
 
277
- // `parent 'specifier'` — conditions are folded away (see resolutionsOf), so an
278
- // edge is identified by the importing file and the specifier it resolved.
226
+ // `parent 'specifier'` — an edge is identified by importer + specifier (conditions folded away).
279
227
  const edgeLabel = (e) => `${e.parent} '${e.specifier}'`
280
228
  const targetList = (files) => files.join(', ')
281
229
 
282
- // Render the `--stat` summary of a diff (module + file change lists with
283
- // counts, plus the import-edge changes when the diff carries them), in the
284
- // spirit of `git diff --stat`: it reports *what* changed, not the differing
285
- // text itself, and the whole report — including the trailing differ/no-differ
286
- // line — goes to stdout. `labels` is `{ left, right, leftKind, rightKind }` for
287
- // the header. Lines use `-` (only in left / baseline), `+` (only in right),
288
- // `*` (changed in place). Returns a string ending in a newline.
230
+ // Render the `--stat` summary (module + file change lists with counts, plus import-edge changes
231
+ // when present); reports what changed, not the text. `labels` is { left, right, leftKind, rightKind }.
232
+ // Returns a string ending in a newline.
289
233
  export function formatDiffStat(diff, { left = '(left)', right = '(right)', leftKind, rightKind } = {}) {
290
- const { modules: m, files: f, imports: im, scope } = diff
234
+ const { modules: m, files: f, executable: ex, imports: im, scope } = diff
291
235
  const lines = ['stasis diff --stat']
292
236
  const label = (kind) => (kind ? `${KIND_LABEL[kind]}, ` : '')
293
237
  lines.push(` ${REMOVED} ${left} (${label(leftKind)}scope=${scope.left})`)
@@ -303,15 +247,22 @@ export function formatDiffStat(diff, { left = '(left)', right = '(right)', leftK
303
247
  for (const x of m.changed) lines.push(` ${CHANGED} ${x.dir} ${describeModuleChange(x)}`)
304
248
 
305
249
  lines.push('')
306
- // Counts cover files *within modules present on both sides*; files inside a
307
- // wholly added/removed module are summarized by its module entry above (with
308
- // a file count), not re-counted here — so name the scope to avoid misreading
309
- // "0 removed" when a whole package vanished.
250
+ // Counts cover only files within modules present on both sides; a wholly added/removed module's
251
+ // files are summarized by its module entry above — hence the "within shared modules" label.
310
252
  lines.push(`Files (within shared modules): ${f.added.length} added, ${f.removed.length} removed, ${f.differing.length} differing`)
311
253
  for (const p of f.removed) lines.push(` ${REMOVED} ${p}`)
312
254
  for (const p of f.added) lines.push(` ${ADDED} ${p}`)
313
255
  for (const p of f.differing) lines.push(` ${CHANGED} ${p}`)
314
256
 
257
+ // Only rendered when something changed: an unchanged execute-bit set is the overwhelming norm,
258
+ // and a permanent "0 added, 0 removed" line would be noise in every diff.
259
+ if (ex.added.length > 0 || ex.removed.length > 0) {
260
+ lines.push('')
261
+ lines.push(`Executable: ${ex.added.length} added, ${ex.removed.length} removed`)
262
+ for (const p of ex.removed) lines.push(` ${REMOVED} ${p}`)
263
+ for (const p of ex.added) lines.push(` ${ADDED} ${p}`)
264
+ }
265
+
315
266
  if (im) {
316
267
  lines.push('')
317
268
  if (!im.attested.left || !im.attested.right) {