@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.
- package/README.md +30 -12
- package/admission-files.mjs +4 -1
- package/admission.mjs +2 -2
- package/advisory-exceptions.mjs +41 -5
- package/android-play-upload.mjs +16 -15
- package/app-check.mjs +105 -27
- package/assemble-app-command.mjs +151 -0
- package/assembled-app.mjs +445 -0
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/darwin/rightkit-swap.sign.json +6 -0
- package/bin/rightkit-gate.mjs +1608 -345
- package/broker-pins.mjs +152 -0
- package/build-launch.mjs +88 -0
- package/build-ownership.mjs +1231 -0
- package/build-release.mjs +82 -28
- package/cargo-contract.mjs +3 -1
- package/cargo-ledger.mjs +62 -0
- package/cargo-target-shim.mjs +238 -0
- package/class-a-launch-guard.mjs +126 -0
- package/cli/right-release.mjs +31 -5
- package/clippy-equivalence.mjs +202 -0
- package/clippy-route.mjs +543 -0
- package/codesign-shim.mjs +11 -1
- package/committed-source.mjs +39 -5
- package/crate-workspaces.mjs +87 -0
- package/create-mac-updater.mjs +14 -2
- package/deterministic-scans.mjs +997 -71
- package/development-signing.mjs +4 -1
- package/development.mjs +120 -21
- package/direct-receipt.mjs +135 -0
- package/door-policy.mjs +71 -6
- package/door.mjs +6 -6
- package/freshness-census.mjs +257 -0
- package/gate-executor.mjs +14 -2
- package/gate-stages.mjs +91 -15
- package/heavy-command.mjs +3 -2
- package/hook-retirement.mjs +65 -0
- package/init-command.mjs +1 -1
- package/install-app.mjs +11 -2
- package/install-assembled-app.mjs +423 -0
- package/inventory-check.mjs +11 -8
- package/ios-dev.mjs +70 -3
- package/ios-upload.mjs +20 -15
- package/linux-artifacts.mjs +93 -0
- package/local-publish.mjs +224 -53
- package/local-qualify.mjs +5 -3
- package/local-runtime.mjs +5 -4
- package/mac-package-recipe.mjs +4 -1
- package/managed-cargo.mjs +47 -5
- package/managed-root-guard.mjs +34 -0
- package/mirror-root-artifact.mjs +18 -5
- package/native-helpers.mjs +355 -0
- 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 +84 -10
- package/package.json +4 -3
- package/pipeline-admission.mjs +4 -4
- package/pipeline-receipts.mjs +302 -302
- package/preflight.mjs +27 -4
- package/project-type.mjs +2 -2
- package/provenance-verify.mjs +115 -0
- package/publish-cargo.mjs +12 -9
- package/qa-admission-policy.json +10 -1
- package/recipe-build.mjs +106 -14
- package/release-artifact-path.mjs +341 -0
- package/release-config.mjs +1 -1
- 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 +59 -22
- package/rightkit-versions.json +73 -47
- 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 +312 -0
- package/snapshot-git.mjs +166 -0
- package/snapshot-request.mjs +363 -0
- package/snapshot-root-config.mjs +76 -0
- package/snapshot.mjs +969 -0
- package/source-split.mjs +73 -0
- package/stage-commands.mjs +60 -0
- package/stage-ledger.mjs +204 -0
- package/stage-sidecar-command.mjs +5 -1
- package/stage-sidecar.mjs +157 -27
- package/swap-helper-bundle.mjs +5 -2
- package/test-command.mjs +17 -4
- package/unit-gate.mjs +301 -0
- package/web-build.mjs +40 -6
- 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();
|
package/cli/right-release.mjs
CHANGED
|
@@ -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`
|
|
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"
|
|
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]
|
|
375
|
-
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
|
|
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
|
+
}
|