@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.
- package/README.md +7 -5
- package/admission-files.mjs +3 -0
- package/admission.mjs +2 -2
- package/advisory-exceptions.mjs +41 -5
- package/android-play-upload.mjs +16 -15
- package/app-check.mjs +13 -9
- package/bin/rightkit-gate.mjs +762 -345
- package/broker-pins.mjs +152 -0
- package/build-release.mjs +80 -27
- package/cargo-ledger.mjs +62 -0
- package/cargo-target-shim.mjs +98 -26
- package/cli/right-release.mjs +28 -4
- package/clippy-equivalence.mjs +202 -0
- package/clippy-route.mjs +543 -0
- package/committed-source.mjs +39 -5
- package/crate-workspaces.mjs +87 -0
- package/create-mac-updater.mjs +14 -2
- package/deterministic-scans.mjs +196 -21
- package/development.mjs +13 -4
- package/direct-receipt.mjs +135 -0
- package/door-policy.mjs +10 -0
- package/door.mjs +6 -6
- package/freshness-census.mjs +257 -0
- package/gate-stages.mjs +76 -10
- package/hook-retirement.mjs +65 -0
- package/init-command.mjs +1 -1
- package/inventory-check.mjs +11 -8
- package/ios-upload.mjs +20 -15
- package/local-publish.mjs +77 -41
- package/local-qualify.mjs +5 -3
- package/local-runtime.mjs +5 -4
- package/mac-package-recipe.mjs +4 -1
- package/mirror-root-artifact.mjs +18 -5
- package/ownership-class-rules.mjs +1 -1
- package/ownership-evidence.mjs +37 -13
- package/ownership-hooks.mjs +20 -59
- package/ownership-lockfiles.mjs +14 -1
- package/ownership-policy-bundle.mjs +3 -2
- package/ownership-staged.mjs +2 -1
- package/ownership.mjs +24 -0
- package/package-recipe-targets.mjs +35 -6
- package/package.json +1 -1
- package/pipeline-admission.mjs +4 -4
- package/preflight.mjs +27 -4
- package/publish-cargo.mjs +4 -3
- package/recipe-build.mjs +38 -14
- package/release-artifact-path.mjs +341 -0
- package/release-invocation.mjs +8 -1
- package/release-qa-command.mjs +25 -9
- package/release-source-brand.mjs +17 -0
- package/release-source-identity.mjs +171 -0
- package/release.mjs +44 -17
- package/rightkit-versions.json +62 -42
- package/runtime-budgets.mjs +6 -2
- package/scan-digest.mjs +71 -5
- package/sign-updater.mjs +6 -3
- package/snapshot-broker.mjs +172 -0
- package/snapshot-check.mjs +12 -6
- package/snapshot-request.mjs +207 -5
- package/snapshot.mjs +123 -10
- package/source-split.mjs +73 -0
- package/stage-commands.mjs +2 -2
- package/stage-ledger.mjs +204 -0
- package/stage-sidecar-command.mjs +5 -1
- package/stage-sidecar.mjs +100 -20
- package/swap-helper-bundle.mjs +5 -2
- package/unit-gate.mjs +45 -3
- package/web-build.mjs +6 -4
- package/win-package-recipe.mjs +20 -7
package/cargo-target-shim.mjs
CHANGED
|
@@ -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).
|
|
3
|
-
// DIRECT POSIX (Linux) host
|
|
4
|
-
//
|
|
5
|
-
// (
|
|
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.
|
|
8
|
-
//
|
|
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).
|
|
17
|
-
//
|
|
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 "
|
|
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/
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
134
|
-
* wrapper (an absolute path, `rustup run`, a reset PATH) still writes there.
|
|
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
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
if (
|
|
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
|
|
package/cli/right-release.mjs
CHANGED
|
@@ -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`
|
|
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"
|
|
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]
|
|
377
|
-
hooks install [--app <dir>]
|
|
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
|
+
}
|