@rightkit/release 0.6.8 → 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 (96) hide show
  1. package/README.md +30 -12
  2. package/admission-files.mjs +4 -1
  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 +105 -27
  7. package/assemble-app-command.mjs +151 -0
  8. package/assembled-app.mjs +445 -0
  9. package/bin/darwin/rightkit-swap +0 -0
  10. package/bin/darwin/rightkit-swap.sign.json +6 -0
  11. package/bin/rightkit-gate.mjs +1608 -345
  12. package/broker-pins.mjs +152 -0
  13. package/build-launch.mjs +88 -0
  14. package/build-ownership.mjs +1231 -0
  15. package/build-release.mjs +82 -28
  16. package/cargo-contract.mjs +3 -1
  17. package/cargo-ledger.mjs +62 -0
  18. package/cargo-target-shim.mjs +238 -0
  19. package/class-a-launch-guard.mjs +126 -0
  20. package/cli/right-release.mjs +31 -5
  21. package/clippy-equivalence.mjs +202 -0
  22. package/clippy-route.mjs +543 -0
  23. package/codesign-shim.mjs +11 -1
  24. package/committed-source.mjs +39 -5
  25. package/crate-workspaces.mjs +87 -0
  26. package/create-mac-updater.mjs +14 -2
  27. package/deterministic-scans.mjs +997 -71
  28. package/development-signing.mjs +4 -1
  29. package/development.mjs +120 -21
  30. package/direct-receipt.mjs +135 -0
  31. package/door-policy.mjs +71 -6
  32. package/door.mjs +6 -6
  33. package/freshness-census.mjs +257 -0
  34. package/gate-executor.mjs +14 -2
  35. package/gate-stages.mjs +91 -15
  36. package/heavy-command.mjs +3 -2
  37. package/hook-retirement.mjs +65 -0
  38. package/init-command.mjs +1 -1
  39. package/install-app.mjs +11 -2
  40. package/install-assembled-app.mjs +423 -0
  41. package/inventory-check.mjs +11 -8
  42. package/ios-dev.mjs +70 -3
  43. package/ios-upload.mjs +20 -15
  44. package/linux-artifacts.mjs +93 -0
  45. package/local-publish.mjs +224 -53
  46. package/local-qualify.mjs +5 -3
  47. package/local-runtime.mjs +5 -4
  48. package/mac-package-recipe.mjs +4 -1
  49. package/managed-cargo.mjs +47 -5
  50. package/managed-root-guard.mjs +34 -0
  51. package/mirror-root-artifact.mjs +18 -5
  52. package/native-helpers.mjs +355 -0
  53. package/ownership-class-rules.mjs +1 -1
  54. package/ownership-evidence.mjs +37 -13
  55. package/ownership-hooks.mjs +20 -59
  56. package/ownership-lockfiles.mjs +14 -1
  57. package/ownership-policy-bundle.mjs +3 -2
  58. package/ownership-staged.mjs +2 -1
  59. package/ownership.mjs +24 -0
  60. package/package-recipe-targets.mjs +84 -10
  61. package/package.json +4 -3
  62. package/pipeline-admission.mjs +4 -4
  63. package/pipeline-receipts.mjs +302 -302
  64. package/preflight.mjs +27 -4
  65. package/project-type.mjs +2 -2
  66. package/provenance-verify.mjs +115 -0
  67. package/publish-cargo.mjs +12 -9
  68. package/qa-admission-policy.json +10 -1
  69. package/recipe-build.mjs +106 -14
  70. package/release-artifact-path.mjs +341 -0
  71. package/release-config.mjs +1 -1
  72. package/release-invocation.mjs +8 -1
  73. package/release-qa-command.mjs +25 -9
  74. package/release-source-brand.mjs +17 -0
  75. package/release-source-identity.mjs +171 -0
  76. package/release.mjs +59 -22
  77. package/rightkit-versions.json +73 -47
  78. package/runtime-budgets.mjs +6 -2
  79. package/scan-digest.mjs +71 -5
  80. package/sign-updater.mjs +6 -3
  81. package/snapshot-broker.mjs +172 -0
  82. package/snapshot-check.mjs +312 -0
  83. package/snapshot-git.mjs +166 -0
  84. package/snapshot-request.mjs +363 -0
  85. package/snapshot-root-config.mjs +76 -0
  86. package/snapshot.mjs +969 -0
  87. package/source-split.mjs +73 -0
  88. package/stage-commands.mjs +60 -0
  89. package/stage-ledger.mjs +204 -0
  90. package/stage-sidecar-command.mjs +5 -1
  91. package/stage-sidecar.mjs +157 -27
  92. package/swap-helper-bundle.mjs +5 -2
  93. package/test-command.mjs +17 -4
  94. package/unit-gate.mjs +301 -0
  95. package/web-build.mjs +40 -6
  96. package/win-package-recipe.mjs +20 -7
@@ -0,0 +1,126 @@
1
+ // Class A launch guard: class A runs before any compile, so a class A test row may not launch a compiler or an emitting build.
2
+ //
3
+ // The gate runs every class A test row with NODE_OPTIONS carrying `--import=<this file>`. In every Node process of the row (the
4
+ // test runner, pnpm, workers, any Node child they launch, including one given a replacement environment) the child_process launchers are wrapped, and a launch is
5
+ // refused (it throws, so the row fails) when the program, or a command line it hands to a shell, is a compile or an emitting
6
+ // build: cargo, rustc, rustright, rightkit (broker client), tauri / cargo-tauri, tsc without --noEmit, tsup, esbuild, swc, rollup,
7
+ // webpack, `vite build`, `next build`, or a package-manager run of a build / build:* script.
8
+ //
9
+ // Scope, exactly: launches from Node processes of the row. Not covered: a compiler API called inside a process
10
+ // (typescript.createProgram().emit(), vite.build() / the rollup JS API), and launches by a non-Node program that does not load
11
+ // this hook (rightkit#1240). esbuild's JS API is covered: it launches the esbuild service binary.
12
+ //
13
+ // A Node child handed a replacement environment keeps the guard (withGuard restores it). There is no exemption: a unit whose tests
14
+ // launch stand-ins named like compilers (stub scripts can run anything) runs after class A instead (npm.<unit>.afterClassA).
15
+ import childProcess from "node:child_process";
16
+ import path from "node:path";
17
+ import { syncBuiltinESMExports } from "node:module";
18
+ import { fileURLToPath, pathToFileURL } from "node:url";
19
+
20
+ const INSTALLED = Symbol.for("rightkit.classA.launchGuard");
21
+ export const LAUNCH_GUARD_IMPORT = `--import=${pathToFileURL(fileURLToPath(import.meta.url)).href}`;
22
+ export const REFUSAL_CODE = "RK_CLASS_A_COMPILE_LAUNCH";
23
+ const COMPILERS = new Set(["cargo", "rustc", "rustright", "rightkit", "tauri", "cargo-tauri", "tsup", "esbuild", "swc", "rollup", "webpack"]);
24
+ const PACKAGE_MANAGERS = new Set(["pnpm", "npm", "yarn", "bun"]);
25
+ const SHELLS = new Set(["sh", "bash", "zsh", "dash", "cmd", "powershell", "pwsh"]);
26
+ const WRAPPERS = new Set(["env", "npx", "pnpx", "exec", "time", "nice"]);
27
+ // Options that take a value, per package manager: pnpm's `-w` is `--workspace-root` (a flag), npm's `-w` is `--workspace <name>`.
28
+ const OPTIONS_WITH_VALUES = Object.freeze({
29
+ pnpm: new Set(["--filter", "-F", "--dir", "-C"]),
30
+ npm: new Set(["--workspace", "-w", "--prefix"]),
31
+ yarn: new Set(["--cwd"]),
32
+ bun: new Set(["--cwd", "--filter"]),
33
+ });
34
+
35
+ const base = (p) => path.basename(String(p ?? "").replaceAll("\\", "/")).replace(/\.(exe|cmd|bat|ps1|js|mjs|cjs)$/i, "").toLowerCase();
36
+
37
+ /** A shell command line split into commands (on && || ; |) and words, quotes removed: enough to read what a `sh -c` text launches. */
38
+ export function shellWords(text) {
39
+ const commands = []; let words = []; let word = ""; let quote = null;
40
+ const end = () => { if (word) { words.push(word); word = ""; } };
41
+ const s = String(text);
42
+ for (let i = 0; i < s.length; i++) {
43
+ const c = s[i];
44
+ if (quote) { if (c === quote) quote = null; else word += c; continue; }
45
+ if (c === "'" || c === "\"") { quote = c; continue; }
46
+ if (/\s/.test(c)) { end(); continue; }
47
+ if (c === ";" || c === "|" || c === "&") { end(); if (words.length) commands.push(words); words = []; continue; }
48
+ word += c;
49
+ }
50
+ end(); if (words.length) commands.push(words);
51
+ return commands;
52
+ }
53
+
54
+ /** Why `[program, ...args]` is a compile or an emitting build, or null. */
55
+ export function compileLaunch(program, args = []) {
56
+ let words = [String(program), ...args.map(String)];
57
+ for (let guard = 0; guard < 8 && words.length; guard++) {
58
+ const name = base(words[0]);
59
+ if (name === "node" && words[1] && !String(words[1]).startsWith("-")) { words = words.slice(1); continue; }
60
+ if (WRAPPERS.has(name)) { words = words.slice(1).filter((w, i, a) => !(i === 0 && /^[A-Z_][A-Z0-9_]*=/.test(w))); continue; }
61
+ if (SHELLS.has(name)) {
62
+ const at = words.findIndex((w, i) => i > 0 && /^(-c|\/c|\/s|-Command|-command)$/i.test(w));
63
+ if (at < 0 || !words[at + 1]) return null;
64
+ for (const cmd of shellWords(words.slice(at + 1).join(" "))) { const why = compileLaunch(cmd[0], cmd.slice(1)); if (why) return why; }
65
+ return null;
66
+ }
67
+ const rest = words.slice(1);
68
+ if (COMPILERS.has(name)) return name;
69
+ if (name === "tsc") return rest.some((w) => /^--?noEmit(=true)?$/i.test(w)) ? null : "tsc (emits)";
70
+ if (name === "vite" || name === "next") return rest.find((w) => !w.startsWith("-")) === "build" ? `${name} build` : null;
71
+ if (PACKAGE_MANAGERS.has(name)) {
72
+ let next = null;
73
+ for (let j = 0; j < rest.length; j++) {
74
+ if (OPTIONS_WITH_VALUES[name].has(rest[j])) { j++; continue; }
75
+ if (rest[j].startsWith("-") || rest[j] === "run" || rest[j] === "run-script") continue;
76
+ if (/^build(:|$)/.test(rest[j])) return `${name} ${rest[j]}`;
77
+ if (rest[j] === "exec" || rest[j] === "dlx" || rest[j] === "x") next = rest.slice(j + 1);
78
+ break;
79
+ }
80
+ if (!next || !next.length) return null;
81
+ words = next; continue;
82
+ }
83
+ return null;
84
+ }
85
+ return null;
86
+ }
87
+
88
+ function refuse(why, program, args) {
89
+ const error = new Error(`class A refuses a compile/emit launch (${why}): ${[program, ...args].join(" ").slice(0, 300)}. Class A runs before any compile; this test row belongs after class A.`);
90
+ error.code = REFUSAL_CODE;
91
+ try { process.stderr.write(`rightkit-gate: ${error.message}\n`); } catch { /* stderr closed */ }
92
+ throw error;
93
+ }
94
+
95
+ function check(program, args, options) {
96
+ const argv = Array.isArray(args) ? args : [];
97
+ if (options?.shell) { for (const cmd of shellWords([program, ...argv].join(" "))) { const why = compileLaunch(cmd[0], cmd.slice(1)); if (why) refuse(why, program, argv); } return; }
98
+ const why = compileLaunch(program, argv);
99
+ if (why) refuse(why, program, argv);
100
+ }
101
+
102
+ /** `env` with the guard switched on and loaded (NODE_OPTIONS appended, never replaced). */
103
+ export function withGuard(env) {
104
+ const current = String(env.NODE_OPTIONS ?? "");
105
+ return { ...env, RIGHTKIT_CLASS_A_LAUNCH_GUARD: "1", NODE_OPTIONS: current.includes(LAUNCH_GUARD_IMPORT) ? current : `${current} ${LAUNCH_GUARD_IMPORT}`.trim() };
106
+ }
107
+
108
+ export function installLaunchGuard() {
109
+ if (globalThis[INSTALLED]) return;
110
+ globalThis[INSTALLED] = true;
111
+ const o = { ...childProcess };
112
+ const split = (args) => (Array.isArray(args[1]) ? [args[0], args[1], args[2]] : [args[0], [], args[1]]);
113
+ // A child handed a replacement environment keeps the guard: its NODE_OPTIONS (and the switch) are restored into that env.
114
+ // The caller's options object is never modified: the launch gets a copy carrying the restored env.
115
+ const keep = (a) => a.map((x) => (x && typeof x === "object" && !Array.isArray(x) && x.env && typeof x.env === "object" ? { ...x, env: withGuard(x.env) } : x));
116
+ childProcess.spawn = function (...a) { const [p, v, opt] = split(a); check(p, v, opt); return o.spawn.apply(this, keep(a)); };
117
+ childProcess.spawnSync = function (...a) { const [p, v, opt] = split(a); check(p, v, opt); return o.spawnSync.apply(this, keep(a)); };
118
+ childProcess.execFile = function (...a) { const [p, v, opt] = split(a); check(p, v, opt); return o.execFile.apply(this, keep(a)); };
119
+ childProcess.execFileSync = function (...a) { const [p, v, opt] = split(a); check(p, v, opt); return o.execFileSync.apply(this, keep(a)); };
120
+ childProcess.exec = function (cmd, ...a) { for (const c of shellWords(cmd)) { const why = compileLaunch(c[0], c.slice(1)); if (why) refuse(why, cmd, []); } return o.exec.call(this, cmd, ...keep(a)); };
121
+ childProcess.execSync = function (cmd, ...a) { for (const c of shellWords(cmd)) { const why = compileLaunch(c[0], c.slice(1)); if (why) refuse(why, cmd, []); } return o.execSync.call(this, cmd, ...keep(a)); };
122
+ childProcess.fork = function (modulePath, ...a) { const v = Array.isArray(a[0]) ? a[0] : []; check(modulePath, v, {}); return o.fork.call(this, modulePath, ...keep(a)); };
123
+ syncBuiltinESMExports();
124
+ }
125
+
126
+ if (process.env.RIGHTKIT_CLASS_A_LAUNCH_GUARD === "1") installLaunchGuard();
@@ -25,6 +25,7 @@ const commands = new Map([
25
25
  ["sign-candidate", "sign-candidate.mjs"],
26
26
  ["engine-locate", "engine-locator.mjs"],
27
27
  ["stage-sidecar", "stage-sidecar-command.mjs"],
28
+ ["assemble-app", "assemble-app-command.mjs"],
28
29
  ["init", "init-command.mjs"],
29
30
  ["pe", "pe-content.mjs"],
30
31
  ["icons", "icon-pack.mjs"],
@@ -58,8 +59,31 @@ const first = args[0];
58
59
  // The commands that compile, package, sign, ship or inspect a release run the app check (../app-check.mjs, CHECKED_COMMANDS) in this
59
60
  // process before they start a child. A red check prints its findings and exits 1; there is no flag that skips it.
60
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");
61
69
  async function requireGreenCheck() {
62
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
+ }
63
87
  // One door (Adrian, 2026-10-25): a commit-bound command (build, package, release, qa, upload, publish, signing) runs only when
64
88
  // rightkit-gate --to launched it for HEAD, after class A and the compile stage passed.
65
89
  const checked = checkedCommandOf(args);
@@ -128,13 +152,14 @@ if (first === "--version" || first === "-v") {
128
152
  const { main } = await import("../bin/rightkit-gate.mjs");
129
153
  process.exit(await main(["check", ...args.slice(1)], { cwd: appDirectory(args, process.cwd()) }));
130
154
  } else if (first === "hooks" && args[1] === "install") {
131
- // 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).
132
157
  const app = args.indexOf("--app");
133
158
  if (app >= 0 && (!args[app + 1] || args[app + 1].startsWith("-"))) {
134
159
  console.error("right-release hooks install: --app needs a directory");
135
160
  process.exit(2);
136
161
  }
137
- 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() });
138
163
  } else if (first === "hooks") {
139
164
  run("ownership-hooks.mjs", args.slice(1));
140
165
  } else if (first === "suite-doctor") {
@@ -269,6 +294,7 @@ Commands:
269
294
  icons ico|icns --out <file> --frame <PNG>... [--dir <folder>] Pack hand-tuned per-size PNG frames into an ICO or ICNS
270
295
  init --name <Product Name> --id <reverse.dns.bundle.id> [--slug <lowercase>] [--dir <root>] [--dry-run] Rename the desktop app template into a real app, in place
271
296
  stage-sidecar [--config <file>] [--target <triple>] [--json] Stage declarative sidecars into src-tauri/binaries (Tauri externalBin naming)
297
+ assemble-app --platform mac|win [--profile release|dev] [--build] [--out <dir>] | --verify-staged [--signed] | --package Assemble the declared composite app (assembly.<platform>) for production packaging; the same definition installs through dev
272
298
  recipe-build --plan <json> | --plan-file <path> [--app-root <dir>] Run a package recipe: prepare steps, the Tauri build, postBuild steps (argv, no shell, fail closed)
273
299
  dmg build --app <.app> --out <dmg> --volname <name> [--background <png>] [--spec <appdmg.json>]
274
300
  [--icon-size N] [--app-position x,y] [--applications-position x,y] [--window w,h] [--window-position x,y]
@@ -360,7 +386,7 @@ Commands:
360
386
  signing-agent install|uninstall|status Manage invisible Aqua-session signing service
361
387
  check [--repo <dir>] [--config <file>] [--commit <rev> | --worktree] [--only <rule,...>] [--list] [--print-baseline] [--baseline <file>]
362
388
  The offline app check, same as rightkit-gate check: committed files only, every finding in one run (exit 0 pass, 1 findings, 2 could not run).
363
- Build, package, dev, test, web-build, swift-package, recipe-build, stage-sidecar, qa, upload, publish, publish-update, github, doctor,
389
+ Build, package, dev, test, web-build, swift-package, recipe-build, assemble-app, stage-sidecar, qa, upload, publish, publish-update, github, doctor,
364
390
  sign-windows, sign-updater, sign-candidate, sign-dev-artifact, addon build and create-mac-updater run it first and start nothing when it is red; there is no flag that skips it.
365
391
  Baseline: .rightkit/app-check-baseline.json (entries carry issue, added and sunset; the sunset is at most 90 days after added)
366
392
  doctor [--platform mac|win] [--gates] [--inspect] [--strict]
@@ -371,8 +397,8 @@ Commands:
371
397
  doctor --all Verify all Right Suite app release contracts
372
398
  suite-doctor Verify all local Right Suite repositories
373
399
  ownership [--app <dir>] [--json] Fail on app copies of RightKit-owned capabilities
374
- ownership --staged [--app <dir>] [--json] Pre-commit gate: scan the staged index for block-tier findings
375
- 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
376
402
  ownership enroll [--root] [--name <device>] [--root-fingerprint <sha256>] [--app <dir>]
377
403
  Enroll one native key or create a signed-device request
378
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
+ }