@rightkit/release 0.6.10 → 0.6.11

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 (69) hide show
  1. package/README.md +7 -5
  2. package/admission-files.mjs +3 -0
  3. package/admission.mjs +2 -2
  4. package/advisory-exceptions.mjs +41 -5
  5. package/android-play-upload.mjs +16 -15
  6. package/app-check.mjs +13 -9
  7. package/bin/rightkit-gate.mjs +762 -345
  8. package/broker-pins.mjs +152 -0
  9. package/build-release.mjs +80 -27
  10. package/cargo-ledger.mjs +62 -0
  11. package/cargo-target-shim.mjs +98 -26
  12. package/cli/right-release.mjs +28 -4
  13. package/clippy-equivalence.mjs +202 -0
  14. package/clippy-route.mjs +543 -0
  15. package/committed-source.mjs +39 -5
  16. package/crate-workspaces.mjs +87 -0
  17. package/create-mac-updater.mjs +14 -2
  18. package/deterministic-scans.mjs +196 -21
  19. package/development.mjs +13 -4
  20. package/direct-receipt.mjs +135 -0
  21. package/door-policy.mjs +10 -0
  22. package/door.mjs +6 -6
  23. package/freshness-census.mjs +257 -0
  24. package/gate-stages.mjs +76 -10
  25. package/hook-retirement.mjs +65 -0
  26. package/init-command.mjs +1 -1
  27. package/inventory-check.mjs +11 -8
  28. package/ios-upload.mjs +20 -15
  29. package/local-publish.mjs +77 -41
  30. package/local-qualify.mjs +5 -3
  31. package/local-runtime.mjs +5 -4
  32. package/mac-package-recipe.mjs +4 -1
  33. package/mirror-root-artifact.mjs +18 -5
  34. package/ownership-class-rules.mjs +1 -1
  35. package/ownership-evidence.mjs +37 -13
  36. package/ownership-hooks.mjs +20 -59
  37. package/ownership-lockfiles.mjs +14 -1
  38. package/ownership-policy-bundle.mjs +3 -2
  39. package/ownership-staged.mjs +2 -1
  40. package/ownership.mjs +24 -0
  41. package/package-recipe-targets.mjs +35 -6
  42. package/package.json +1 -1
  43. package/pipeline-admission.mjs +4 -4
  44. package/preflight.mjs +27 -4
  45. package/publish-cargo.mjs +4 -3
  46. package/recipe-build.mjs +38 -14
  47. package/release-artifact-path.mjs +341 -0
  48. package/release-invocation.mjs +8 -1
  49. package/release-qa-command.mjs +25 -9
  50. package/release-source-brand.mjs +17 -0
  51. package/release-source-identity.mjs +171 -0
  52. package/release.mjs +44 -17
  53. package/rightkit-versions.json +62 -42
  54. package/runtime-budgets.mjs +6 -2
  55. package/scan-digest.mjs +71 -5
  56. package/sign-updater.mjs +6 -3
  57. package/snapshot-broker.mjs +172 -0
  58. package/snapshot-check.mjs +12 -6
  59. package/snapshot-request.mjs +207 -5
  60. package/snapshot.mjs +123 -10
  61. package/source-split.mjs +73 -0
  62. package/stage-commands.mjs +2 -2
  63. package/stage-ledger.mjs +204 -0
  64. package/stage-sidecar-command.mjs +5 -1
  65. package/stage-sidecar.mjs +100 -20
  66. package/swap-helper-bundle.mjs +5 -2
  67. package/unit-gate.mjs +45 -3
  68. package/web-build.mjs +6 -4
  69. package/win-package-recipe.mjs +20 -7
@@ -1,11 +1,21 @@
1
1
  #!/usr/bin/env node
2
- // The runtime cargo wrapper of a commit-snapshot compile (item 7, GATE-CONTRACT §1 "Broker interface", output directory). Scope: the
3
- // DIRECT POSIX (Linux) host only. On Mac and Dell the positive snapshot-compile path is the RustRight broker's snapshot carrier alone
4
- // (the gate refuses a snapshot compile on a broker host until it is wired); on a direct Windows host the static walk
5
- // (deterministic-scans.mjs cargoReachOf) is the fail-closed baseline, because a child started without a shell finds only an .exe.
2
+ // The runtime cargo wrapper of a commit-snapshot compile (item 7, GATE-CONTRACT §1 "Broker interface", output directory). Two modes:
3
+ // direct the DIRECT POSIX (Linux) host: each call runs the real cargo bound to its workspace's checkout target directory.
4
+ // broker the Mac (decision C, rows 7.10 and 7.24): each call goes through the RustRight client as its own snapshot request with a
5
+ // fresh carrier (snapshot-broker.mjs runBrokerCargo); no cargo ever runs outside the broker, and its signed receipt must verify.
6
+ // Both modes append every call to the gate-owned ledger (cargo-ledger.mjs) before and after it acts, and the gate fails the scan on any
7
+ // refused, unverified or unfinished call even when the test ignored the exit status. On a Windows host there is no wrapper: a child
8
+ // started without a shell finds only an .exe, so the static walk (deterministic-scans.mjs cargoReachOf) is the fail-closed baseline
9
+ // there (a refused unit is FAIL or BLOCKED with a code, never skipped).
6
10
  //
7
- // The gate puts this `cargo` first on PATH and names it in CARGO for every scan. Real cargo honours CARGO as its own path and hands
8
- // it to build scripts and tests (env!("CARGO"), $CARGO), so those come back here too. Each invocation resolves its Cargo workspace
11
+ // The gate puts this `cargo` first on PATH and names it in CARGO for every scan. A test, script or build tool that runs `cargo` or
12
+ // $CARGO comes back here, with one accepted exception (lead re-decision F7, row 7.10). MEASURED, cargo 1.98.0 on WSL2
13
+ // (/Volumes/D/scratch/rk-l7b/wsl-debug1.sh, wsl-debug3.sh): a real cargo hands a build script its OWN path in CARGO, not the file
14
+ // the CARGO variable names, and `--config env.CARGO` does not change that. A cargo a build script starts through $CARGO therefore
15
+ // bypasses this wrapper. It is accepted as follows: it inherits the parent's bound CARGO_TARGET_DIR (the same as a primary-checkout
16
+ // build), so its outputs land in the bound checkout target; the per-scan sentinel and the after-compile snapshot verify stay as
17
+ // backstops; and it is UNOBSERVED: never in the ledger, and every scan result of a wrapped compile carries a note that says so
18
+ // (deterministic-scans.mjs UNOBSERVED_NESTED_CARGO_NOTE). Each invocation resolves its Cargo workspace
9
19
  // (`cargo locate-project --workspace`, from its working directory or --manifest-path) and runs with that workspace's ORIGINAL checkout
10
20
  // target directory as CARGO_TARGET_DIR. Refused with an RK_* code (the unit fails): a workspace the compile did not resolve (outside
11
21
  // the snapshot, a fixture, a temporary project), an option that moves the workspace, configuration or output, a command that writes
@@ -13,17 +23,20 @@
13
23
  // outside the bound ones or may write its Cargo.lock (tree, fetch, pkgid... without --locked/--frozen; fmt only as --check). A cargo
14
24
  // that bypasses PATH and CARGO (an absolute path, `rustup run`, a reset PATH) inherits the scan-wide CARGO_TARGET_DIR: the one
15
25
  // workspace's checkout target, or with several workspaces a per-scan sentinel directory that must stay empty (runScans fails the
16
- // scan with RK_CARGO_UNBOUND otherwise). An absolute cargo that also passes its own --target-dir is deliberate evasion of the gate,
17
- // out of scope under the standing rule (the gate binds what a test does, not what a test sets out to hide).
26
+ // scan with RK_CARGO_UNBOUND otherwise). In broker mode the sentinel is always set: there is no checkout target to inherit, so any
27
+ // write into it is a cargo that skipped the wrapper. An absolute cargo that also passes its own absolute target directory has
28
+ // overridden the value the gate passed: a residual of the trusted per-user model, not refused (map 7.12).
18
29
  import fs from "node:fs";
19
30
  import path from "node:path";
20
31
  import { createHash } from "node:crypto";
21
- import { spawnSync } from "node:child_process";
32
+ import { spawnSync } from "./background-process.mjs";
22
33
  import { fileURLToPath } from "node:url";
34
+ import { SCAN_TOKEN_ENV, appendLedger } from "./cargo-ledger.mjs";
35
+ import { BROKER_REFUSAL_STATUS, runBrokerCargo } from "./snapshot-broker.mjs";
23
36
 
24
- export const SHIM_MAP_SCHEMA = "rightkit.cargo-target-shim/2";
37
+ export const SHIM_MAP_SCHEMA = "rightkit.cargo-target-shim/3";
25
38
  // Exit status of a refusal: cargo's own error status, so every caller reads it as a failed cargo.
26
- export const SHIM_REFUSAL_STATUS = 101;
39
+ export const SHIM_REFUSAL_STATUS = BROKER_REFUSAL_STATUS;
27
40
  // Cargo commands that compile or report on a workspace: run bound to its checkout target directory.
28
41
  const COMPILE = new Set(["build", "b", "check", "c", "test", "t", "bench", "run", "r", "doc", "d", "rustc", "rustdoc", "clippy", "metadata", "package"]);
29
42
  // Commands that only read a workspace but may resolve and write its Cargo.lock: bound, and only with --locked or --frozen.
@@ -77,6 +90,11 @@ export function decideCargoInvocation({ args, cwd, env, map, locate, realpath =
77
90
  return own[at].includes("=") ? own[at].slice(name.length + 1) : (own[at + 1] ?? "");
78
91
  };
79
92
  const manifestPath = valueOf("--manifest-path");
93
+ const broker = map.mode === "broker";
94
+ // The RustRight client submits the call from the working directory and refuses to rewrite it (attestation), and the broker pins the
95
+ // output: a manifest path or a target directory of the caller's has no place in a broker call.
96
+ if (broker && manifestPath !== undefined) return refusal("RK_CARGO_BROKER_MANIFEST_PATH", `\`cargo ${sub}\` passes --manifest-path; a broker call runs from the workspace's own directory (the client refuses to rewrite the invocation), so run it there`);
97
+ if (broker && valueOf("--target-dir") !== undefined) return refusal("RK_CARGO_REDIRECT", `\`cargo ${sub}\` passes --target-dir; the broker pins a snapshot compile's output directory`);
80
98
  let workspace;
81
99
  try {
82
100
  const manifest = locate(manifestPath === undefined ? null : path.resolve(cwd, manifestPath), cwd, toolchain);
@@ -88,6 +106,8 @@ export function decideCargoInvocation({ args, cwd, env, map, locate, realpath =
88
106
  const targetDir = Object.hasOwn(map.workspaces ?? {}, workspace) ? map.workspaces[workspace] : null;
89
107
  if (!targetDir) return refusal("RK_CARGO_UNBOUND_WORKSPACE", `\`cargo ${sub}\` builds the Cargo workspace ${workspace}, which is not one this snapshot compile resolved (${Object.keys(map.workspaces ?? {}).join(", ") || "none"}); its outputs would not be the checkout's`);
90
108
  if (LOCKFILE.has(sub) && !exists(path.join(workspace, "Cargo.lock"))) return refusal("RK_CARGO_UNBOUND_COMMAND", `\`cargo ${sub}\` has no --locked, and workspace ${workspace} has no committed Cargo.lock it would only read; it may write one`);
109
+ // Broker mode: `targetDir` is the workspace's counterpart in the primary checkout (the gate's snapshot-to-checkout map).
110
+ if (broker) return { exec: { args: words, env: { ...env }, workspace, checkoutWorkspace: targetDir } };
91
111
  const explicit = valueOf("--target-dir");
92
112
  if (explicit !== undefined && path.resolve(cwd, explicit) !== targetDir) return refusal("RK_CARGO_REDIRECT", `\`cargo ${sub}\` passes --target-dir ${explicit}; workspace ${workspace} writes only to the checkout's ${targetDir}`);
93
113
  return { exec: { args: words, env: withCargo({ ...env, CARGO_TARGET_DIR: targetDir, CARGO_BUILD_TARGET_DIR: targetDir }), workspace, targetDir } };
@@ -99,45 +119,79 @@ export function decideCargoInvocation({ args, cwd, env, map, locate, realpath =
99
119
  * the same path (and the same CARGO in a scan's cache key) and any change gives another. `targetDirs` is the gate's { [cargoDir]:
100
120
  * absolute checkout target directory }; each workspace is keyed by its real path in the snapshot. `realCargo` is the absolute cargo
101
121
  * the wrapper runs (never itself). POSIX hosts only: a Windows child started without a shell finds only an .exe on PATH.
122
+ *
123
+ * Broker mode (`broker: { checkoutDirs, section }`, the Mac): `checkoutDirs` is the gate's { [cargoDir]: absolute path of that
124
+ * workspace in the primary checkout } (the snapshot-to-checkout map, the only source of a request's primaryCwd) and `section` the
125
+ * public part of the gate's snapshot broker (brokerSection). `realCargo` then only runs `version` and `help`; it may be null.
102
126
  */
103
- export function installCargoShim({ base, snapshotRoot, targetDirs, realCargo, node = process.execPath, platform = process.platform, realpath = (p) => fs.realpathSync.native(p), onDir = () => {}, write = (file, text, mode) => fs.writeFileSync(file, text, { mode }) }) {
127
+ export function installCargoShim({ base, snapshotRoot, targetDirs, broker = null, realCargo, node = process.execPath, platform = process.platform, realpath = (p) => fs.realpathSync.native(p), onDir = () => {}, write = (file, text, mode) => fs.writeFileSync(file, text, { mode }) }) {
104
128
  if (platform === "win32") throw Object.assign(new Error("the runtime cargo wrapper needs a POSIX host: a Windows child finds only an .exe on PATH"), { code: "RK_CARGO_SHIM_UNSUPPORTED" });
105
- if (typeof realCargo !== "string" || !path.isAbsolute(realCargo)) throw Object.assign(new Error(`no absolute cargo to wrap (${JSON.stringify(realCargo)})`), { code: "RK_CARGO_SHIM_UNSUPPORTED" });
129
+ const mode = broker ? "broker" : "direct";
130
+ if (mode === "direct" && (typeof realCargo !== "string" || !path.isAbsolute(realCargo))) throw Object.assign(new Error(`no absolute cargo to wrap (${JSON.stringify(realCargo)})`), { code: "RK_CARGO_SHIM_UNSUPPORTED" });
131
+ if (mode === "broker" && ((realCargo !== null && realCargo !== undefined && !path.isAbsolute(realCargo)) || !broker.section || !broker.checkoutDirs)) throw Object.assign(new Error("a broker wrapper needs the gate's checkout map and broker section"), { code: "RK_CARGO_SHIM_UNSUPPORTED" });
106
132
  const workspaces = {};
107
- for (const [cargoDir, targetDir] of Object.entries(targetDirs ?? {})) workspaces[realpath(path.join(snapshotRoot, ...(cargoDir === "." ? [] : cargoDir.split("/"))))] = targetDir;
133
+ for (const [cargoDir, targetDir] of Object.entries(broker ? broker.checkoutDirs : targetDirs ?? {})) workspaces[realpath(path.join(snapshotRoot, ...(cargoDir === "." ? [] : cargoDir.split("/"))))] = targetDir;
108
134
  const self = fileURLToPath(import.meta.url);
109
135
  const sorted = Object.fromEntries(Object.entries(workspaces).sort(([a], [b]) => (a < b ? -1 : 1)));
110
- const identity = createHash("sha256").update(JSON.stringify({ realCargo, node, workspaces: sorted })).update(fs.readFileSync(self)).digest("hex");
136
+ const identityHash = createHash("sha256").update(JSON.stringify({ realCargo: realCargo ?? null, node, workspaces: sorted, ...(broker ? { mode, section: broker.section } : {}) })).update(fs.readFileSync(self));
137
+ // The broker half of the wrapper is part of its identity: any change to the code that carries and verifies a call is another wrapper.
138
+ if (broker) for (const name of ["snapshot-broker.mjs", "snapshot-request.mjs", "provenance-verify.mjs", "cargo-ledger.mjs"]) identityHash.update(fs.readFileSync(new URL(`./${name}`, import.meta.url)));
139
+ const identity = identityHash.digest("hex");
111
140
  const dir = path.join(base, `rk-cargo-shim-${identity.slice(0, 16)}`);
112
141
  const bin = path.join(dir, "bin");
113
142
  const shim = path.join(bin, "cargo");
114
143
  const mapFile = path.join(dir, "cargo-target-map.json");
144
+ const ledgerDir = path.join(dir, "ledger");
115
145
  // The caller learns the directory before anything is written into it (its cleanup owns it from here), and a failed install removes
116
146
  // what it wrote before it throws.
117
147
  onDir(dir);
118
148
  try {
119
149
  fs.mkdirSync(bin, { recursive: true });
150
+ fs.mkdirSync(ledgerDir, { recursive: true, mode: 0o700 });
120
151
  const quote = (s) => `'${String(s).replaceAll("'", "'\\''")}'`;
121
152
  const put = (file, text, mode) => { const tmp = `${file}.${process.pid}.tmp`; write(tmp, text, mode); fs.renameSync(tmp, file); };
122
- put(mapFile, `${JSON.stringify({ schema: SHIM_MAP_SCHEMA, identity, realCargo, shim, workspaces: sorted }, null, 2)}\n`, 0o644);
153
+ const sentinel = mode === "broker" || Object.keys(sorted).length > 1 ? path.join(dir, "unbound") : null;
154
+ const section = broker ? { ...broker.section, sentinel } : null;
155
+ put(mapFile, `${JSON.stringify({ schema: SHIM_MAP_SCHEMA, mode, identity, realCargo: realCargo ?? null, shim, ledgerDir, workspaces: sorted, ...(section ? { broker: section } : {}) }, null, 2)}\n`, 0o644);
123
156
  put(shim, `#!/bin/sh\nexec ${quote(node)} ${quote(self)} --map ${quote(mapFile)} -- "$@"\n`, 0o755);
157
+ return { dir, bin, shim, mapFile, ledgerDir, mode, workspaces: sorted, identity, sentinel };
124
158
  } catch (error) {
125
159
  fs.rmSync(dir, { recursive: true, force: true });
126
160
  throw error;
127
161
  }
128
- return { dir, bin, shim, mapFile, workspaces: sorted, identity, sentinel: Object.keys(sorted).length > 1 ? path.join(dir, "unbound") : null };
129
162
  }
130
163
 
131
164
  /**
132
165
  * The environment every scan of a wrapped snapshot compile starts from: the wrapper first on PATH and named in CARGO, and, when the
133
- * snapshot has exactly one Cargo workspace, its checkout target as the scan-wide CARGO_TARGET_DIR, so a cargo that bypasses the
134
- * wrapper (an absolute path, `rustup run`, a reset PATH) still writes there. With several workspaces runScans gives each scan its own
166
+ * snapshot has exactly one Cargo workspace (direct mode), its checkout target as the scan-wide CARGO_TARGET_DIR, so a cargo that
167
+ * bypasses the wrapper (an absolute path, `rustup run`, a reset PATH) still writes there. Otherwise runScans gives each scan its own
135
168
  * empty sentinel as CARGO_TARGET_DIR instead (installed.sentinel; options.cargoSentinel), which must still be empty afterwards.
169
+ * Broker mode has no checkout target to inherit and always uses the sentinel.
136
170
  */
137
171
  export function shimScanEnv(env, installed) {
138
172
  const targets = Object.values(installed.workspaces);
139
173
  const pathKey = Object.keys(env).find((k) => k.toUpperCase() === "PATH") ?? "PATH";
140
- return { ...env, [pathKey]: `${installed.bin}${path.delimiter}${env[pathKey] ?? ""}`, CARGO: installed.shim, ...(targets.length === 1 ? { CARGO_TARGET_DIR: targets[0] } : {}) };
174
+ return { ...env, [pathKey]: `${installed.bin}${path.delimiter}${env[pathKey] ?? ""}`, CARGO: installed.shim, ...(installed.mode !== "broker" && targets.length === 1 ? { CARGO_TARGET_DIR: targets[0] } : {}) };
175
+ }
176
+
177
+ /**
178
+ * The workspace root Cargo resolves for a manifest or a directory, from the files alone (broker mode: no cargo runs outside the
179
+ * broker, not even `locate-project`): the nearest enclosing Cargo.toml that declares `[workspace]`, else the nearest Cargo.toml.
180
+ */
181
+ export function locateWorkspaceByWalk(manifestPath, cwd, { read = (f) => fs.readFileSync(f, "utf8"), exists = fs.existsSync } = {}) {
182
+ let nearest = null;
183
+ for (let dir = manifestPath ? path.dirname(manifestPath) : cwd; ; dir = path.dirname(dir)) {
184
+ const manifest = path.join(dir, "Cargo.toml");
185
+ if (exists(manifest)) {
186
+ nearest ??= manifest;
187
+ let text = "";
188
+ try { text = read(manifest); } catch { text = ""; }
189
+ if (/^\s*\[workspace(?:\.[^\]]*)?\]/m.test(text)) return manifest;
190
+ }
191
+ if (path.dirname(dir) === dir) break;
192
+ }
193
+ if (nearest) return nearest;
194
+ throw new Error("could not find `Cargo.toml` in the working directory or any parent");
141
195
  }
142
196
 
143
197
  /** The wrapper itself: `cargo-target-shim.mjs --map <file> -- <cargo args>`. Returns the exit status. */
@@ -148,18 +202,36 @@ export function runShim(argv, { env = process.env, cwd = process.cwd(), spawn =
148
202
  let map;
149
203
  try { map = JSON.parse(fs.readFileSync(argv[mapAt + 1], "utf8")); if (map?.schema !== SHIM_MAP_SCHEMA) throw new Error(`schema ${JSON.stringify(map?.schema)}`); }
150
204
  catch (error) { err(`rightkit-gate: RK_CARGO_SHIM_ERROR: the cargo target map cannot be read (${error.message}); nothing ran`); return SHIM_REFUSAL_STATUS; }
151
- // locate-project runs the real cargo with CARGO pointing at it, never at the wrapper.
205
+ const words = argv.slice(sep + 1);
206
+ const token = env[SCAN_TOKEN_ENV];
207
+ const id = `${process.pid}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
208
+ const broker = map.mode === "broker";
209
+ const base = { id, mode: broker ? "broker" : "direct", cwd, argv: words };
210
+ // A call that cannot be recorded does not run: the ledger is how the gate learns what a test did, whatever the test does with the status.
211
+ const record = (entry) => appendLedger(map.ledgerDir, token, entry);
212
+ try { record({ ...base, status: "pending" }); } catch (error) { err(`rightkit-gate: RK_CARGO_LEDGER_ERROR: the call cannot be recorded (${error.message}); it does not run`); return SHIM_REFUSAL_STATUS; }
213
+ const refuse = (why) => { record({ ...base, status: "refused", code: why.code, message: why.message }); err(`rightkit-gate: ${why.code}: ${why.message}`); return SHIM_REFUSAL_STATUS; };
214
+ // locate-project runs the real cargo with CARGO pointing at it, never at the wrapper (direct mode). Broker mode reads the files only.
152
215
  const locateEnv = { ...env, CARGO: map.realCargo };
153
- const locate = (manifestPath, from, toolchain) => {
216
+ const locate = broker ? (manifestPath, from) => locateWorkspaceByWalk(manifestPath, from) : (manifestPath, from, toolchain) => {
154
217
  const r = spawn(map.realCargo, [...(toolchain ? [toolchain] : []), "locate-project", "--workspace", "--message-format", "plain", ...(manifestPath ? ["--manifest-path", manifestPath] : [])], { cwd: from, env: locateEnv, encoding: "utf8" });
155
218
  if (r.error) throw r.error;
156
219
  if (r.status !== 0) throw new Error(String(r.stderr || `exit ${r.status}`).trim().split(/\r?\n/).at(-1));
157
220
  return String(r.stdout).trim();
158
221
  };
159
- const decision = decideCargoInvocation({ args: argv.slice(sep + 1), cwd, env, map, locate });
160
- if (decision.refuse) { err(`rightkit-gate: ${decision.refuse.code}: ${decision.refuse.message}`); return SHIM_REFUSAL_STATUS; }
161
- const r = spawn(map.realCargo, decision.exec.args, { cwd, env: decision.exec.env, stdio: "inherit" });
162
- if (r.error) { err(`rightkit-gate: RK_CARGO_SHIM_ERROR: ${map.realCargo} did not start (${r.error.message})`); return SHIM_REFUSAL_STATUS; }
222
+ let decision;
223
+ try { decision = decideCargoInvocation({ args: words, cwd, env, map, locate }); } catch (error) { return refuse({ code: "RK_CARGO_SHIM_ERROR", message: error.message }); }
224
+ if (decision.refuse) return refuse(decision.refuse);
225
+ if (broker && decision.exec.workspace) {
226
+ // The `pending` record above stays this call's until the broker call finishes: its records carry the same call id.
227
+ return runBrokerCargo({ words: decision.exec.args, cwd, env, section: map.broker, workspace: decision.exec.workspace, checkoutWorkspace: decision.exec.checkoutWorkspace, record: (entry) => record({ ...entry, id }), spawn, err });
228
+ }
229
+ // A call that reads no workspace (version, help) runs as it is: the real cargo in direct mode, the host's cargo or the client in broker mode.
230
+ const viaClient = broker && !map.realCargo;
231
+ const command = viaClient ? map.broker.client : map.realCargo;
232
+ const r = spawn(command, viaClient ? ["cargo", ...decision.exec.args] : decision.exec.args, { cwd, env: decision.exec.env, stdio: "inherit" });
233
+ if (r.error) return refuse({ code: "RK_CARGO_SHIM_ERROR", message: `${command} did not start (${r.error.message})` });
234
+ record({ ...base, status: "ran", exit: r.status ?? null });
163
235
  return r.status ?? (r.signal ? 128 : SHIM_REFUSAL_STATUS);
164
236
  }
165
237
 
@@ -59,8 +59,31 @@ const first = args[0];
59
59
  // The commands that compile, package, sign, ship or inspect a release run the app check (../app-check.mjs, CHECKED_COMMANDS) in this
60
60
  // process before they start a child. A red check prints its findings and exits 1; there is no flag that skips it.
61
61
  const WORKTREE_COMPILES = new Set(["dev", "test", "stage-sidecar"]);
62
+ // The commands that run inside a source split (source-split.mjs): the door's snapshot of a commit, with the checkout kept for the git
63
+ // context and every output. build, package, release and qa take their identity from release-source-identity.mjs (row 7.04): the selected
64
+ // commit's objects plus the door's verified snapshot, never the checkout's HEAD, status or branch (each re-proves it through
65
+ // assertReleaseSource). `upload` does so for the mobile platforms (the host's store upload); a desktop upload, publish and the signing
66
+ // commands still read the checkout's HEAD and refuse under a split rather than run on the wrong bytes.
67
+ const SPLIT_COMMANDS = new Set(["dev", "test", "stage-sidecar", "web-build", "build", "package", "release", "qa"]);
68
+ const MOBILE_UPLOAD = (argv) => argv.some((a, i) => (a === "--platform" && ["ios", "android"].includes(argv[i + 1])) || a === "--platform=ios" || a === "--platform=android");
62
69
  async function requireGreenCheck() {
63
70
  const { guardCommand, checkedCommandOf, checkModeFor, appDirectory } = await import("../app-check.mjs");
71
+ const { readSourceSplit, SOURCE_SPLIT_UNSUPPORTED } = await import("../source-split.mjs");
72
+ let split = null;
73
+ try { split = readSourceSplit(); } catch (error) { console.error(`right-release: ${error.code ?? "RK_SOURCE_SPLIT"}: ${error.message}; nothing was started`); process.exit(2); }
74
+ if (split) {
75
+ // A flag-first call (`right-release --platform mac`) is a build with no command word.
76
+ const key = checkedCommandOf(args) ?? (args[0]?.startsWith("-") ? "build" : null);
77
+ if (checkModeFor(args) && !SPLIT_COMMANDS.has(key) && !(key === "upload" && MOBILE_UPLOAD(args))) {
78
+ console.error(`right-release: ${SOURCE_SPLIT_UNSUPPORTED}: right-release ${key ?? args.join(" ")} cannot run from the snapshot of ${split.commit.slice(0, 12)} yet: it reads the checkout's clean HEAD (ownership evidence, source QA identity, the clean-source and dirty-input gates), and a dirty checkout or another commit must never reach it; nothing was started`);
79
+ process.exit(2);
80
+ }
81
+ // The door run itself was proven by readSourceSplit (live, this checkout, this commit); the app rules read the commit from the checkout.
82
+ const refusedSplit = guardCommand({ args, source: { repo: split.checkout, commit: split.commit } });
83
+ if (!refusedSplit) return;
84
+ for (const line of refusedSplit.lines) console.error(line);
85
+ process.exit(refusedSplit.exitCode);
86
+ }
64
87
  // One door (Adrian, 2026-10-25): a commit-bound command (build, package, release, qa, upload, publish, signing) runs only when
65
88
  // rightkit-gate --to launched it for HEAD, after class A and the compile stage passed.
66
89
  const checked = checkedCommandOf(args);
@@ -129,13 +152,14 @@ if (first === "--version" || first === "-v") {
129
152
  const { main } = await import("../bin/rightkit-gate.mjs");
130
153
  process.exit(await main(["check", ...args.slice(1)], { cwd: appDirectory(args, process.cwd()) }));
131
154
  } else if (first === "hooks" && args[1] === "install") {
132
- // One installer: `hooks install` is `rightkit-gate install-hook` (pre-push check and gate, ownership pre-commit step) run in the app.
155
+ // One installer: `hooks install` is `rightkit-gate install-hook` run in the app. It writes no git hook: it retires the pre-push gate, the
156
+ // forwarders and the ownership pre-commit hook an earlier release wrote (docs/GATE-CONTRACT.md: commit and push run no gate).
133
157
  const app = args.indexOf("--app");
134
158
  if (app >= 0 && (!args[app + 1] || args[app + 1].startsWith("-"))) {
135
159
  console.error("right-release hooks install: --app needs a directory");
136
160
  process.exit(2);
137
161
  }
138
- run("bin/rightkit-gate.mjs", ["install-hook", ...(args.includes("--force") ? ["--force"] : [])], { cwd: app >= 0 ? path.resolve(args[app + 1]) : process.cwd() });
162
+ run("bin/rightkit-gate.mjs", ["install-hook"], { cwd: app >= 0 ? path.resolve(args[app + 1]) : process.cwd() });
139
163
  } else if (first === "hooks") {
140
164
  run("ownership-hooks.mjs", args.slice(1));
141
165
  } else if (first === "suite-doctor") {
@@ -373,8 +397,8 @@ Commands:
373
397
  doctor --all Verify all Right Suite app release contracts
374
398
  suite-doctor Verify all local Right Suite repositories
375
399
  ownership [--app <dir>] [--json] Fail on app copies of RightKit-owned capabilities
376
- ownership --staged [--app <dir>] [--json] Pre-commit gate: scan the staged index for block-tier findings
377
- hooks install [--app <dir>] [--force] Alias of rightkit-gate install-hook: the pre-push check and gate, and the advisory pre-commit ownership gate (pinned @rightkit/release)
400
+ ownership --staged [--app <dir>] [--json] Manual check: scan the staged index for block-tier findings (no git hook runs it)
401
+ hooks install [--app <dir>] Alias of rightkit-gate install-hook: retires the pre-push gate and the pre-commit ownership hook an earlier release wrote; writes no git hook
378
402
  ownership enroll [--root] [--name <device>] [--root-fingerprint <sha256>] [--app <dir>]
379
403
  Enroll one native key or create a signed-device request
380
404
  ownership approve-key <fingerprint|request.json> Approve one pending device key from an active host
@@ -0,0 +1,202 @@
1
+ // Batch equivalence for the Clippy route (GATE-CONTRACT §1 "Batching", E14; item 8 rows 8.7 and 8.16). A batched Clippy invocation
2
+ // replaces the per-crate ones only when it is shown to say the same thing: the same diagnostics (package, target, code, span), the
3
+ // same resolved features for what it compiled (feature unification across the selected packages can change what compiles), and the
4
+ // same engine and toolchain. This module is the comparison; `run` below drives real cargo over a checkout (a gate-owned export, a
5
+ // Linux host) and writes the recorded comparison.
6
+ //
7
+ // node clippy-equivalence.mjs --repo <dir> --out <dir> [--units a,b,...] [--target-dir <dir>] [--cargo cargo]
8
+ //
9
+ // Exit 0 when every batch is equivalent to its per-crate runs (after any unified crate was split out and the split re-run), 1 when not.
10
+ import crypto from "node:crypto";
11
+ import fs from "node:fs";
12
+ import path from "node:path";
13
+ import { spawnSync } from "./background-process.mjs";
14
+ import { fileURLToPath } from "node:url";
15
+ import { clippyArgs, clippyBatchPlan, sidecarBoundCrate } from "./deterministic-scans.mjs";
16
+ import { rustCratePlan } from "./scan-digest.mjs";
17
+ import { parseCargoMessages, summarizeMessages, toolchainIdentity } from "./clippy-route.mjs";
18
+
19
+ export const EQUIVALENCE_SCHEMA = "rightkit.clippy-batch-equivalence/1";
20
+ const sha256 = (v) => crypto.createHash("sha256").update(v).digest("hex");
21
+
22
+ /** One diagnostic's identity for the comparison: package, target, level, code (else its first line) and primary span. */
23
+ export const diagnosticKey = (d) => JSON.stringify([d.package, d.kind, d.target, d.level, d.code ?? String(d.message).split("\n")[0], d.span ? [d.span.file, d.span.line, d.span.column, d.span.lineEnd, d.span.columnEnd] : null]);
24
+ const artifactKey = (a) => JSON.stringify([a.package, a.kind, a.target, a.test === true]);
25
+ const multiset = (keys) => { const m = new Map(); for (const k of keys) m.set(k, (m.get(k) ?? 0) + 1); return m; };
26
+ const diff = (a, b) => { const out = []; for (const [k, n] of a) { const d = n - (b.get(k) ?? 0); for (let i = 0; i < d; i += 1) out.push(k); } return out.sort(); };
27
+
28
+ /**
29
+ * Compares each batch with the per-crate runs of its members. `groups`: `[{ id, units, batch: { messages, toolchain }, perCrate:
30
+ * [{ unit, crate, messages, toolchain }] }]` (`messages` are parsed cargo JSON objects; `units` the batch's unit names, which
31
+ * `perCrate` covers one for one). Returns `{ equivalent, groups, separate, failures }`: `separate` maps each unit whose batched
32
+ * compile differed (a different feature set for itself, or a dependency built with features no per-crate run built it with) to the
33
+ * reason, for the planner to keep it as its own invocation; any difference in diagnostics, or a missing or unequal toolchain
34
+ * identity, is a failure of the run (row 8.16).
35
+ */
36
+ export function compareEquivalence({ groups }) {
37
+ const failures = []; const separate = new Map(); const rows = [];
38
+ for (const g of groups) {
39
+ const members = new Set(g.perCrate.map((p) => p.crate));
40
+ const batch = summarizeMessages(g.batch.messages);
41
+ const per = g.perCrate.map((p) => ({ ...p, run: summarizeMessages(p.messages) }));
42
+ const row = { id: g.id, units: g.units, crates: [...members].sort() };
43
+ // A member the batch never built (no artifact, no diagnostic) while another member of the batch failed is BLOCKED in the batch: with
44
+ // `-D warnings` a lint is a build failure, so a dependent of a failing member builds nothing under --keep-going where its own per-crate
45
+ // run (which lints only itself) would have run. That is the route's reported `blocked` state (row 8.4), not a clean result, and it is
46
+ // excluded from the comparison; a member the batch did not build while nothing in it failed is a difference.
47
+ const failedInBatch = new Set(batch.diagnostics.filter((d) => members.has(d.package) && d.level.startsWith("error")).map((d) => d.package));
48
+ const builtInBatch = new Set([...batch.artifacts.map((a) => a.package), ...batch.diagnostics.map((d) => d.package)]);
49
+ const blocked = [...members].filter((m) => !builtInBatch.has(m)).sort();
50
+ // Not built in the batch, nothing in it failed, and the member's own per-crate run did build it: a difference. A member neither run
51
+ // builds (a build script that fails on this host, a missing system library) is the same in both.
52
+ const builtOnItsOwn = (m) => per.some((p) => p.crate === m && p.run.artifacts.some((a) => a.package === m));
53
+ const unbuilt = failedInBatch.size === 0 ? blocked.filter(builtOnItsOwn) : [];
54
+ row.blockedInBatch = failedInBatch.size ? blocked : [];
55
+ const compared = (pkg) => members.has(pkg) && !row.blockedInBatch.includes(pkg);
56
+ // Diagnostics: the batch's, for its members, against each per-crate run's, for that crate.
57
+ const batchKeys = multiset(batch.diagnostics.filter((d) => compared(d.package)).map(diagnosticKey));
58
+ const perKeys = multiset(per.flatMap((p) => p.run.diagnostics.filter((d) => d.package === p.crate && compared(p.crate)).map(diagnosticKey)));
59
+ row.diagnostics = { batch: [...batchKeys.values()].reduce((a, n) => a + n, 0), perCrate: [...perKeys.values()].reduce((a, n) => a + n, 0), onlyInBatch: diff(batchKeys, perKeys), onlyInPerCrate: diff(perKeys, batchKeys) };
60
+ row.diagnosticsOfBlockedMembers = per.filter((p) => row.blockedInBatch.includes(p.crate)).reduce((n, p) => n + p.run.diagnostics.filter((d) => d.package === p.crate).length, 0);
61
+ if (row.diagnostics.onlyInBatch.length || row.diagnostics.onlyInPerCrate.length) failures.push(`${g.id}: the batch's diagnostics differ from its per-crate runs (${row.diagnostics.onlyInBatch.length} only in the batch, ${row.diagnostics.onlyInPerCrate.length} only per crate)`);
62
+ const unitOf = new Map(per.map((p) => [p.crate, p.unit]));
63
+ const byDiagnostics = new Set([...row.diagnostics.onlyInBatch, ...row.diagnostics.onlyInPerCrate].map((k) => JSON.parse(k)[0]));
64
+ for (const pkg of byDiagnostics) if (unitOf.has(pkg)) separate.set(unitOf.get(pkg), `batching changed the diagnostics of ${pkg} (item 8 row 8.7): kept as its own invocation`);
65
+ for (const pkg of unbuilt) if (unitOf.has(pkg)) separate.set(unitOf.get(pkg), `the batch never built ${pkg} although nothing in it failed (item 8 row 8.7)`);
66
+ // Resolved features: what each member was built with, in the batch against its own per-crate run, for every unit both built.
67
+ const unified = [];
68
+ for (const p of per) {
69
+ if (!compared(p.crate)) continue;
70
+ const own = (a) => a.package === p.crate;
71
+ const batchOf = new Map(); for (const a of batch.artifacts.filter(own)) batchOf.set(artifactKey(a), a.features);
72
+ const ownOf = new Map(); for (const a of p.run.artifacts.filter(own)) ownOf.set(artifactKey(a), a.features);
73
+ for (const [key, w] of ownOf) {
74
+ const b = batchOf.get(key);
75
+ if (b !== undefined && JSON.stringify(b) !== JSON.stringify(w)) unified.push({ unit: p.unit, crate: p.crate, unitKey: JSON.parse(key), batchFeatures: b, perCrateFeatures: w });
76
+ }
77
+ }
78
+ const seen = new Map();
79
+ for (const p of per) for (const a of p.run.artifacts.filter((x) => !members.has(x.package))) { const k = `${a.packageId}\0${artifactKey(a)}`; if (!seen.has(k)) seen.set(k, new Set()); seen.get(k).add(JSON.stringify(a.features)); }
80
+ const dependencies = [];
81
+ for (const a of batch.artifacts.filter((x) => !members.has(x.package))) {
82
+ const k = `${a.packageId}\0${artifactKey(a)}`;
83
+ const known = seen.get(k);
84
+ if (known && !known.has(JSON.stringify(a.features))) dependencies.push({ package: a.package, packageId: a.packageId, batchFeatures: a.features, perCrateFeatures: [...known].map((s) => JSON.parse(s)) });
85
+ }
86
+ // Third-party packages built with a union of the batch's features are recorded, not judged: cargo's features are additive, and the
87
+ // linted code is the members'. A member built with other features than its own run is what changes what compiles.
88
+ row.features = { unified, dependenciesBuiltWithOtherFeatures: dependencies.length };
89
+ row.dependenciesBuiltWithOtherFeatures = dependencies.map((d) => d.packageId).sort();
90
+ for (const u of unified) separate.set(u.unit, `batching changed the features ${u.crate} compiles with (${u.batchFeatures.join(",") || "none"} in the batch, ${u.perCrateFeatures.join(",") || "none"} on its own): feature unification (item 8 row 8.7)`);
91
+ // A batch that fails to build where every per-crate run builds, and whose failure no diagnostic of a member explains (a build script, a
92
+ // feature conflict): the unified features cannot all be on together (mutually exclusive ones). A failure a member's diagnostic explains
93
+ // is judged by the diagnostics and the unified-feature comparison above.
94
+ const exits = { batch: g.batch.exit ?? null, perCrate: per.map((p) => p.exit ?? null) };
95
+ row.exits = exits;
96
+ const exitDiffers = exits.batch !== null && exits.batch !== 0 && per.length > 0 && per.every((p) => p.exit === 0) && failedInBatch.size === 0;
97
+ if (exitDiffers) for (const p of per) separate.set(p.unit, "the batch fails to build where every per-crate run builds: its features cannot all be on together (item 8 row 8.7)");
98
+ // Toolchain identity (row 8.16): present and equal for the batch and every per-crate run.
99
+ const toolchains = [g.batch.toolchain, ...per.map((p) => p.toolchain)];
100
+ const equal = toolchains.every((t) => typeof t === "string" && t.trim() !== "") && new Set(toolchains).size === 1;
101
+ row.toolchain = { equal, batch: g.batch.toolchain ?? null, perCrate: [...new Set(per.map((p) => p.toolchain ?? null))] };
102
+ if (!equal) failures.push(`${g.id}: the toolchain identity of the batch and its per-crate runs is not one equal, present string (batch ${g.batch.toolchain ?? "missing"}; per crate ${row.toolchain.perCrate.join(", ")})`);
103
+ row.equivalent = !row.diagnostics.onlyInBatch.length && !row.diagnostics.onlyInPerCrate.length && !unified.length && !exitDiffers && !unbuilt.length && equal;
104
+ rows.push(row);
105
+ }
106
+ if (separate.size) failures.push(`${separate.size} unit(s) are kept separate: ${[...separate.keys()].sort().join(", ")}`);
107
+ return { schema: EQUIVALENCE_SCHEMA, equivalent: failures.length === 0, groups: rows, separate, failures };
108
+ }
109
+
110
+ // ---- the run over a checkout (real cargo) ---------------------------------------------------------------------------------------------
111
+
112
+ function cargoRun({ cargo, args, cwd, env, messagesFile, clean = [] }) {
113
+ // Each run lints its packages afresh: cargo replays the cached diagnostics of a fresh unit, and what it cached depends on which run
114
+ // compiled the unit first, so a comparison over a shared target directory would compare cache history. Cleaning only the packages
115
+ // the run selects keeps the dependencies' compiled outputs warm.
116
+ if (clean.length) {
117
+ const c = spawnSync(cargo, ["clean", ...clean.flatMap((n) => ["-p", n])], { cwd, env, encoding: "utf8" });
118
+ if (c.status !== 0) throw new Error(`clean of ${clean.join(", ")} failed: ${String(c.stderr).slice(-300)}`);
119
+ }
120
+ const fd = fs.openSync(messagesFile, "w");
121
+ const t = Date.now();
122
+ let r;
123
+ try { r = spawnSync(cargo, args, { cwd, env, stdio: ["ignore", fd, "pipe"], encoding: "utf8", maxBuffer: 256 * 1024 * 1024 }); } finally { fs.closeSync(fd); }
124
+ const body = fs.readFileSync(messagesFile, "utf8");
125
+ return { status: r.status, signal: r.signal, stderr: String(r.stderr ?? "").slice(-4000), messages: parseCargoMessages(body).messages, ms: Date.now() - t, bytes: Buffer.byteLength(body), error: r.error?.message ?? null };
126
+ }
127
+ const withKeepGoing = (args) => { const i = args.indexOf("--"); return [...args.slice(0, i < 0 ? undefined : i), "--keep-going", ...(i < 0 ? [] : args.slice(i))]; };
128
+
129
+ /**
130
+ * Runs the per-crate and the batched Clippy invocations of `units` (default: every workspace member) over `repo` with the same
131
+ * toolchain and one shared target directory, compares them, and when a batch is not equivalent because of feature unification
132
+ * splits the affected units out and runs and compares again. Writes `<out>/equivalence.json` (without raw messages) and returns it.
133
+ */
134
+ export function run({ repo, out, units = null, targetDir = path.join(out, "target"), cargo = "cargo", env = process.env, log = (m) => process.stderr.write(`${m}\n`) }) {
135
+ fs.mkdirSync(out, { recursive: true });
136
+ const runEnv = { ...env, CARGO_TARGET_DIR: targetDir };
137
+ const meta = spawnSync(cargo, ["metadata", "--no-deps", "--format-version", "1", "--locked"], { cwd: repo, env: runEnv, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
138
+ if (meta.status !== 0) throw new Error(`cargo metadata failed: ${String(meta.stderr).slice(-400)}`);
139
+ const metadata = JSON.parse(meta.stdout);
140
+ const byId = new Map(metadata.packages.map((p) => [p.id, p]));
141
+ const members = metadata.workspace_members.map((id) => byId.get(id)).filter(Boolean).sort((a, b) => (a.name < b.name ? -1 : 1));
142
+ const config = JSON.parse(fs.readFileSync(path.join(repo, "ci", "gate.config.json"), "utf8")).rust ?? {};
143
+ let solo = []; try { solo = JSON.parse(fs.readFileSync(path.join(repo, "ci", "shards.json"), "utf8")).solo ?? []; } catch { solo = []; }
144
+ const wanted = units ? new Set(units) : null;
145
+ const entries = [];
146
+ for (const p of members) {
147
+ if (wanted && !wanted.has(p.name)) continue;
148
+ const { lintSets } = rustCratePlan(config[p.name] ?? {});
149
+ for (const set of lintSets) entries.push({ name: p.name, crate: p.name, ws: ".", set, count: lintSets.length, env: null });
150
+ }
151
+ const sidecarBound = new Set(entries.filter((e) => sidecarBoundCrate({ dir: path.dirname(byId.get(members.find((m) => m.name === e.name).id).manifest_path) }).bound).map((e) => e.name));
152
+ const toolchainNow = () => toolchainIdentity({ spawn: (c, a, o) => spawnSync(c, a, { ...o, encoding: "utf8" }), env: runEnv, cwd: repo });
153
+ const toolchain = toolchainNow();
154
+ log(`equivalence: ${entries.length} crate configuration(s) of ${members.length} workspace member(s); toolchain ${toolchain}`);
155
+
156
+ const perCrateRuns = new Map();
157
+ const perCrate = (entry) => {
158
+ const key = `${entry.name}\0${entry.set.join(" ")}`;
159
+ if (!perCrateRuns.has(key)) {
160
+ const args = withKeepGoing(clippyArgs(entry.crate, { features: entry.set, messageFormat: "json", locked: true }));
161
+ const r = { toolchain: toolchainNow(), ...cargoRun({ cargo, args, cwd: repo, env: runEnv, clean: [entry.crate], messagesFile: path.join(out, `percrate-${sha256(key).slice(0, 10)}.jsonl`) }) };
162
+ log(`per-crate ${entry.name} [${entry.set.join(" ") || "default"}]: exit ${r.status} ${r.ms} ms ${r.bytes} bytes`);
163
+ perCrateRuns.set(key, { ...r, argv: args });
164
+ }
165
+ return perCrateRuns.get(key);
166
+ };
167
+ const runPass = (separate) => {
168
+ const plan = clippyBatchPlan(entries, { solo, separate, sidecarBound, toolchain: cargo });
169
+ const groups = [];
170
+ for (const b of plan.batches) {
171
+ const args = withKeepGoing(clippyArgs(b.crates, { features: b.set, messageFormat: "json", locked: true }));
172
+ const r = { toolchain: toolchainNow(), ...cargoRun({ cargo, args, cwd: repo, env: runEnv, clean: b.crates, messagesFile: path.join(out, `batch-${sha256(b.key).slice(0, 10)}.jsonl`) }) };
173
+ log(`batch ${b.id} (${b.crates.length} crate(s), ${b.set.join(" ") || "default"}): exit ${r.status} ${r.ms} ms ${r.bytes} bytes`);
174
+ const own = entries.filter((e) => b.units.includes(e.name) && e.set.join(" ") === b.set.join(" "));
175
+ groups.push({ id: b.id, units: b.units, reason: b.reason, argv: args, exit: r.status, batch: { messages: r.messages, toolchain: r.toolchain, exit: r.status }, perCrate: own.map((e) => { const p = perCrate(e); return { unit: e.name, crate: e.crate, messages: p.messages, toolchain: p.toolchain, exit: p.status, argv: p.argv }; }) });
176
+ }
177
+ return { plan, groups, result: compareEquivalence({ groups }) };
178
+ };
179
+ const strip = (pass) => ({ batches: pass.plan.batches.map((b) => ({ id: b.id, crates: b.crates, set: b.set, reason: b.reason, order: b.order })), notes: pass.plan.notes, groups: pass.result.groups, failures: pass.result.failures, exits: pass.groups.map((g) => ({ id: g.id, batchExit: g.exit, perCrateExits: g.perCrate.map((p) => [p.unit, p.exit]) })) });
180
+ const first = runPass(new Map());
181
+ let final = first; let second = null;
182
+ if (first.result.separate.size) { second = runPass(first.result.separate); final = second; }
183
+ const report = {
184
+ schema: EQUIVALENCE_SCHEMA, at: new Date().toISOString(), repo, toolchain, cargo,
185
+ coverage: { workspaceMembers: members.length, crateConfigurations: entries.length, units: [...new Set(entries.map((e) => e.name))].sort(), sidecarBound: [...sidecarBound], perCrateRuns: perCrateRuns.size, firstPassBatches: first.plan.batches.length },
186
+ equivalent: final.result.failures.filter((f) => !/are kept separate/.test(f)).length === 0 && final.result.separate.size === 0,
187
+ firstPass: strip(first), firstPassSeparate: Object.fromEntries(first.result.separate),
188
+ afterSplit: second ? strip(second) : null,
189
+ };
190
+ fs.writeFileSync(path.join(out, "equivalence.json"), `${JSON.stringify(report, null, 2)}\n`);
191
+ return report;
192
+ }
193
+
194
+ const invokedDirectly = (() => { try { return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return false; } })();
195
+ if (invokedDirectly) {
196
+ const args = process.argv.slice(2);
197
+ const opt = (name) => { const i = args.indexOf(name); return i < 0 ? null : args[i + 1]; };
198
+ if (!opt("--repo") || !opt("--out")) { process.stderr.write("usage: clippy-equivalence.mjs --repo <dir> --out <dir> [--units a,b] [--target-dir <dir>] [--cargo cargo]\n"); process.exit(2); }
199
+ const report = run({ repo: path.resolve(opt("--repo")), out: path.resolve(opt("--out")), units: opt("--units") ? opt("--units").split(",") : null, ...(opt("--target-dir") ? { targetDir: path.resolve(opt("--target-dir")) } : {}), ...(opt("--cargo") ? { cargo: opt("--cargo") } : {}) });
200
+ process.stdout.write(`${JSON.stringify({ equivalent: report.equivalent, coverage: report.coverage, firstPassFailures: report.firstPass.failures, afterSplitFailures: report.afterSplit?.failures ?? null })}\n`);
201
+ process.exit(report.equivalent ? 0 : 1);
202
+ }