@rightkit/release 0.6.6 → 0.6.8

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 (46) hide show
  1. package/README.md +132 -10
  2. package/admission-files.mjs +3 -2
  3. package/advisory-db.json +4 -0
  4. package/advisory-db.mjs +124 -0
  5. package/advisory-exceptions.mjs +13 -10
  6. package/app-check.mjs +257 -38
  7. package/bin/darwin/rightkit-swap +0 -0
  8. package/bin/rightkit-agent-hook.mjs +9 -0
  9. package/bin/rightkit-gate.mjs +524 -345
  10. package/build-release.mjs +13 -6
  11. package/cli/right-release.mjs +29 -6
  12. package/compile-inputs.mjs +66 -0
  13. package/consumer-contracts.json +1044 -0
  14. package/consumer-contracts.mjs +627 -0
  15. package/consumer-input-contracts.mjs +422 -0
  16. package/consumer-policy.mjs +588 -0
  17. package/consumer-source-contracts.mjs +469 -0
  18. package/deny-default.toml +42 -0
  19. package/deterministic-scans.mjs +240 -29
  20. package/disk-command.mjs +270 -30
  21. package/disk-policy.json +50 -10
  22. package/disk-policy.mjs +482 -35
  23. package/disk-report.mjs +129 -0
  24. package/door-policy.mjs +228 -0
  25. package/door.mjs +23 -0
  26. package/gate-executor.mjs +30 -0
  27. package/gate-stages.mjs +110 -0
  28. package/github-updater-release.mjs +48 -8
  29. package/init-command.mjs +41 -0
  30. package/legion-semantic-contracts.mjs +428 -0
  31. package/ownership-class-rules.mjs +277 -0
  32. package/ownership-key-authority.mjs +3 -1
  33. package/ownership-mandatory-set.mjs +3 -2
  34. package/package.json +11 -3
  35. package/publish-cargo.mjs +5 -2
  36. package/publish-update.mjs +14 -11
  37. package/release-config.mjs +51 -6
  38. package/rightkit-versions.json +172 -167
  39. package/shell-contract-bridge.json +251 -0
  40. package/shell-contract.mjs +1069 -0
  41. package/sign-updater.mjs +24 -4
  42. package/source-closure-rules.mjs +204 -0
  43. package/stage-sidecar-command.mjs +2 -1
  44. package/stage-sidecar.mjs +437 -114
  45. package/suite-api.generated.mjs +96 -6
  46. package/suite-trust-roots.mjs +198 -0
@@ -28,30 +28,31 @@
28
28
  // run the gate (default base origin/main); --plan prints the scans and runs none;
29
29
  // --no-cache (or RK_GATE_CACHE=0) reruns every scan
30
30
  // rightkit-gate check [--repo <dir>] [--commit <rev> | --worktree] [--only <rule,...>] [--list] [--print-baseline] [--baseline <file>]
31
- // the offline app check (../app-check.mjs): committed files only, no network, compile or
32
- // broker, every finding in one run. Exit 0 pass, 1 findings, 2 could not run
31
+ // the deterministic stage: the app check (../app-check.mjs), then every class A scan
32
+ // (lockfiles, cargo-deny/audit, secrets, licences, manifests, workflow lint, syntax) over the
33
+ // whole tree of the commit, not a diff. Nothing compiles; without a broker class A uses
34
+ // plain cargo for metadata/deny/audit only. The RustRight broker runs this before it admits
35
+ // any compile. --list/--print-baseline/--only show the app check alone. Exit 0 pass,
36
+ // 1 findings, 2 could not run
33
37
  // rightkit-gate install-hook [--force] write .githooks/pre-push and set core.hooksPath. core.hooksPath hides .git/hooks, so the
34
38
  // pre-push hook runs the repository's own .git/hooks/pre-push first, then `rightkit-gate
35
39
  // check --commit HEAD`, then the gate; every other hook present in .git/hooks gets a
36
40
  // .githooks/<name> trampoline that runs it; the hook finds the gate binary in the root
37
- // node_modules/.bin, then any workspace package's, then pnpm exec. Where @rightkit/release is
41
+ // node_modules/.bin, then any workspace package's (each package that declares @rightkit/release by
42
+ // name when the root does not declare it), then pnpm exec, then node at a stable path inside the
43
+ // repository (hookGatePlan: from the declarations, never from what is installed). Where @rightkit/release is
38
44
  // installed it also writes the advisory ownership pre-commit step (`right-release hooks install`
39
45
  // is an alias of this command)
40
46
  //
41
47
  // From the pre-push hook, git's ref lines arrive on stdin: the commit being pushed must be the checked-out HEAD (broker scans
42
48
  // read the live checkout), and the remote's current commit is the diff base. To push an earlier commit of the local stack,
43
49
  // check it out first (a detached HEAD is fine), push HEAD:<branch>, return.
44
- // A host with neither `rustright` nor the managed cargo shim (a cloud session) refuses the Rust gate, with one exception: when git's
45
- // stdin names only non-protected branches (not main, master, trunk, release/*, the .rightgit.json defaultBranch, or a tag) and the
46
- // process is not in CI (no CI or GITHUB_ACTIONS), the gate defers the Rust gate to host qualification. It reads the Rust units from the
47
- // tracked manifests (no cargo), runs every scan that needs no broker, prints "rust gate DEFERRED to host qualification" with the Rust
48
- // units and scans it did not run, exits 0 and writes nothing into the tree. An npm test scan whose package script chain runs `rustright` or
49
- // `cargo` (planScans marks it `brokerCall`) cannot run there either and is deferred the same way, named with its reason. The pull request then
50
- // needs a HOST RECEIPT before merge; that receipt stays the merge gate. RIGHTKIT_GATE_NO_DEFER=1 keeps the refusal on such a host. Every pushed ref is judged, whatever its place
51
- // in the push; git's stdin is split on single ASCII spaces (a ref name may hold Unicode whitespace) and a line that is not four fields defers
52
- // nothing. The default branch is named by the checkout, the base ref, the remote commits being replaced and origin/HEAD, so a commit cannot
53
- // unprotect it by editing .rightgit.json. A refusal prints the old message and, on a second line, why nothing was deferred.
54
- // ci/gate.config.json `rust` (optional) sets per-crate feature sets, exactly as CI's gate reads it.
50
+ // The executor is chosen by the gate, never the caller (gate-executor.mjs resolveExecutor): the RustRight broker where this machine
51
+ // has it (`rustright` on PATH, or its managed cargo shim), a refusal on a broker-managed machine whose broker is unreachable, and
52
+ // direct cargo on any other host (a cloud container, a CI runner). Every host runs the same gates in the same order (app rules, class A,
53
+ // the compile scans); an unmanaged host is never told to find a broker. The pre-push hook judges every pushed ref.
54
+ // ci/gate.config.json `rust` (optional) sets per-crate feature sets, exactly as CI's gate reads it; `npm.<unit>` sets `quietTests` and
55
+ // `requiresBroker: { "reason": "..." }` (a test the package scripts do not show reaching the broker: node -e, node scripts, sh -c, pnpm --dir).
55
56
  // Result cache: a passing class B scan is reused only when its scan id, command, tool version, environment and input digest
56
57
  // (the gated commit's git object ids for the crate's workspace closure, the shared build inputs, ci/gate.config.json and every
57
58
  // path the closure's sources include from outside their crate; the whole HEAD tree for Rust tests unless ci/gate.config.json
@@ -68,16 +69,21 @@ import path from "node:path";
68
69
  import { fileURLToPath } from "node:url";
69
70
  import { BIOME_CONFIG_FILES, ESLINT_CONFIG_FILES, WORKSPACE_RUST_FILE, ancestorConfigChanged, ancestorDirsOf, canonicalJson, defaultSpawn, digestScanSet, fileScanCache, findRustfmt, metadataArgs, planScans, runScans, scanDeferrals, scanFailures } from "../deterministic-scans.mjs";
70
71
  import { changedLockNames, parseLock, reach } from "../lock-graph.mjs";
72
+ import { resolveExecutor } from "../gate-executor.mjs";
73
+ import { bindingOf, endRun, readReceipt, RECEIPT_SCHEMA, sameBinding, sameSource, sealedFor, stagesTo, startRun, STAGES, writeReceipt } from "../gate-stages.mjs";
74
+ import { spawnSync as spawnInherit } from "../background-process.mjs";
75
+ import { readDiskPolicy, scratchRoots } from "../disk-policy.mjs";
71
76
 
72
77
  const ZERO = /^0+$/;
73
- const GRANT_FILE = ".rightkit-local-build.json";
74
78
  const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
75
79
  const posix = (p) => String(p).replaceAll("\\", "/");
76
80
  const text = (r) => String(r?.stdout ?? "");
77
81
  const failed = (r) => !r || Boolean(r.error) || r.status !== 0;
78
82
  const why = (r) => String(r?.error?.message ?? r?.stderr ?? "").trim().split(/\r?\n/).slice(-3).join(" | ");
79
83
 
80
- const USAGE = `usage: rightkit-gate [--base <ref>] [--plan] [--no-cache] [--pre-push] | rightkit-gate install-hook [--force] | rightkit-gate check [--repo <dir>] [--commit <rev> | --worktree] [--only <rule,...>] [--list] [--print-baseline] [--baseline <file>] | --version`;
84
+ // A green `check --only` view: app rules passed but no class A scan ran. Distinct from 0 so no caller can admit it as a full check.
85
+ export const PARTIAL_EXIT = 3;
86
+ const USAGE = `usage: rightkit-gate --to check|compile|package|qualify|release [--platform mac|win] [--tier patch|update] [--receipt-in <file>] [--receipt-out <file>] | rightkit-gate --to dev|test [-- <right-release args>] | rightkit-gate stage --platform mac|win [--tier patch|update] | rightkit-gate [--base <ref>] [--plan] [--no-cache] [--pre-push] | rightkit-gate install-hook [--force] | rightkit-gate check [--repo <dir>] [--commit <rev> | --worktree] [--only <rule,...>] [--list] [--print-baseline] [--baseline <file>] | --version`;
81
87
 
82
88
  // ---- pure helpers (exported for tests) ----------------------------------------------------------------------
83
89
 
@@ -229,6 +235,23 @@ function workspaceMetadata({ root, spawn, cargoCommand, dir, realpath }) {
229
235
  * here. The gate itself uses discoverRustWorkspaces, which handles every workspace of the repository. Returns
230
236
  * { present, units: [{ name, dir, publish }], cargoDir, uncovered }.
231
237
  */
238
+ /**
239
+ * Where the snapshot export lives. The RustRight broker refuses a job whose workspace is on the system volume
240
+ * (RK_INTERNAL_DRIVE_REFUSED: agents never write to the internal drive), and os.tmpdir() is on it on every Mac and Dell, so on a
241
+ * host with a disk-policy scratch root (/Volumes/D/scratch, D:\scratch) the export goes there. RK_GATE_TMPDIR overrides; os.tmpdir()
242
+ * is the fallback where no scratch root exists (CI runners, cloud containers), where plain cargo runs and nothing refuses the system
243
+ * volume. (rightkit#1215)
244
+ */
245
+ export function exportTmpRoot({ env = process.env, platform = process.platform, exists = fs.existsSync, policy = null } = {}) {
246
+ if (env.RK_GATE_TMPDIR) return env.RK_GATE_TMPDIR;
247
+ try {
248
+ const roots = scratchRoots(policy ?? readDiskPolicy(), { platform, env });
249
+ const found = roots.find((root) => exists(root));
250
+ if (found) return found;
251
+ } catch { /* no readable policy: the system temp dir */ }
252
+ return os.tmpdir();
253
+ }
254
+
232
255
  export function discoverRustUnits({ root, spawn, cargoCommand, tracked, readFile, realpath = fs.realpathSync }) {
233
256
  const manifests = tracked("Cargo.toml");
234
257
  if (!manifests.length) return { present: false, units: [], cargoDir: ".", uncovered: [] };
@@ -410,47 +433,7 @@ export function discoverRustWorkspaces({ root, spawn, cargoCommand, tracked, unt
410
433
  return { present: true, workspaces, units, cargoDir: workspaces[0]?.cargoDir ?? ".", skipped };
411
434
  }
412
435
 
413
- // ---- the Rust gate on a host without the broker: deferral to host qualification --------------------------------------
414
-
415
- // A push that only carries non-protected branches, from a host that has neither `rustright` nor the managed cargo shim and is not
416
- // CI, does not refuse on the missing broker: the Rust scans cannot run here, so the gate runs every other scan, names the Rust
417
- // units and scans it did not run, and exits 0. The pull request then needs a HOST RECEIPT (a host with the broker ran the full gate)
418
- // before it merges; that receipt, not this hook, is the merge gate. Everything else keeps today's behaviour: a push that includes a
419
- // protected branch, a host with the broker or the shim, CI, and RIGHTKIT_GATE_NO_DEFER=1 all refuse as before.
420
-
421
- /** Branches that never defer (compared case-insensitively); `release/*` and the repository's default branch join them. */
422
- export const PROTECTED_BRANCHES = Object.freeze(["main", "master", "trunk"]);
423
- /** Set to anything but ""/0/false to keep the refusal on a broker-less host. */
424
- export const DEFER_OPT_OUT_ENV = "RIGHTKIT_GATE_NO_DEFER";
425
-
426
- const envOn = (v) => v !== undefined && v !== null && !["", "0", "false"].includes(String(v).trim().toLowerCase());
427
-
428
- /**
429
- * True when `ref` (a pushed remote ref) is a plain branch the gate may defer for. A tag, a ref outside refs/heads/, main, master,
430
- * trunk, release/* and the `extra` names (the repository's own default branch, `.rightgit.json` defaultBranch) are not.
431
- */
432
- export function isDeferrableRef(ref, extra = []) {
433
- const m = /^refs\/heads\/(.+)$/.exec(String(ref ?? ""));
434
- if (!m) return false;
435
- const name = m[1].toLowerCase();
436
- return !(PROTECTED_BRANCHES.includes(name) || extra.some((x) => String(x).toLowerCase() === name) || name.startsWith("release/"));
437
- }
438
-
439
- /**
440
- * Whether the Rust gate defers to host qualification. `refs` are the remote refs of a git pre-push's stdin (deletions included); an
441
- * empty list is not a pre-push run. `unparsed` are the stdin lines parsePushLines could not read as four fields: a push whose refs
442
- * cannot all be read defers nothing (main refuses such a push before it gets here, on every host; this is the second lock). Every
443
- * pushed ref is judged, whatever its position. Returns { defer, why }. The caller asks only when no broker or shim resolved.
444
- */
445
- export function rustDeferral({ refs = [], env = {}, protectedBranches = [], unparsed = [] }) {
446
- if (envOn(env[DEFER_OPT_OUT_ENV])) return { defer: false, why: `${DEFER_OPT_OUT_ENV} is set` };
447
- if (envOn(env.CI) || envOn(env.GITHUB_ACTIONS)) return { defer: false, why: "running in CI" };
448
- if (unparsed.length) return { defer: false, why: `pre-push stdin line ${unparsed[0].number} is not "<local ref> <local sha> <remote ref> <remote sha>" (four fields separated by single spaces), so the pushed refs cannot all be read` };
449
- if (!refs.length) return { defer: false, why: "no pushed refs on stdin: not a git pre-push run" };
450
- const blocking = refs.find((r) => !isDeferrableRef(r, protectedBranches));
451
- if (blocking) return { defer: false, why: `${blocking} is not a plain non-protected branch (main, master, trunk, release/*, the repository's default branch and tags are never deferred)` };
452
- return { defer: true, why: `only non-protected branches are pushed (${refs.join(", ")}) and this is not CI` };
453
- }
436
+ // ---- pre-push input and lock scope ------------------------------------------------------------------------
454
437
 
455
438
  /**
456
439
  * git's pre-push stdin: one "<local ref> <local sha> <remote ref> <remote sha>" per line, the four fields joined by single ASCII spaces.
@@ -471,151 +454,6 @@ export function parsePushLines(stdin) {
471
454
  return { lines, malformed };
472
455
  }
473
456
 
474
- const rightgitDefaultBranch = (raw) => { try { const d = JSON.parse(String(raw).replace(/^\uFEFF/, "")).defaultBranch; return typeof d === "string" && d ? d : null; } catch { return null; } };
475
-
476
- /**
477
- * The names this repository calls its default branch, read from every side a push can be judged by, so a commit that edits
478
- * `.rightgit.json` cannot unprotect the branch it pushes: the manifest in the checkout, the manifest at origin/HEAD and at each of
479
- * `revisions` (the base ref and the remote commits being replaced), and the branch origin/HEAD names. `git(...args)` answers like
480
- * spawnSync; a revision git cannot show contributes nothing. Residual, not closable offline: with no origin/HEAD, no manifest at the base
481
- * and a replaced remote commit that is not in the local object store (a force-push whose tip was never fetched), the only manifest left
482
- * is the checkout's, which the pushed commit supplies. The union only ever adds names, so a local branch or tag called `origin/HEAD`
483
- * cannot remove one: the unambiguous `refs/remotes/origin/HEAD` symbolic-ref read still names the real branch.
484
- */
485
- export function defaultBranchNames({ git, readCheckout, revisions = [] }) {
486
- const names = new Set();
487
- const add = (n) => { if (n) names.add(n); };
488
- try { add(rightgitDefaultBranch(readCheckout())); } catch { /* no right-git manifest in the checkout */ }
489
- // A revision that starts with "-" would be read by git as an option, not a revision.
490
- for (const rev of new Set(["origin/HEAD", ...revisions.filter((r) => typeof r === "string" && r && !r.startsWith("-"))])) {
491
- const shown = git("show", `${rev}:.rightgit.json`);
492
- if (!failed(shown)) add(rightgitDefaultBranch(text(shown)));
493
- }
494
- const head = git("symbolic-ref", "--quiet", "refs/remotes/origin/HEAD");
495
- if (!failed(head)) add(/^refs\/remotes\/origin\/(.+)$/.exec(text(head).trim())?.[1]);
496
- return [...names];
497
- }
498
-
499
- // The path dependencies a manifest declares: `path = "..."` in a [dependencies], [dev-dependencies], [build-dependencies],
500
- // [target.*.dependencies] or [workspace.dependencies] table, inline (`foo = { path = "../foo" }`) or under `[dependencies.foo]`.
501
- function pathDependencies(tables) {
502
- const out = [];
503
- for (const [name, body] of tables) {
504
- if (!/(?:^|\.)(?:dev-|build-)?dependencies(?:\.|$)/.test(name)) continue;
505
- for (const m of body.matchAll(/(?:^|[\s{,.])path[ \t]*=[ \t]*(?:"((?:[^"\\\n]|\\.)*)"|'([^'\n]*)')/gm)) out.push(m[1] ?? m[2]);
506
- }
507
- return out;
508
- }
509
-
510
- /**
511
- * A cargo-free stand-in for `cargo metadata --no-deps` in one workspace directory, read from the tracked manifests: the same
512
- * { cargoDir, members } as workspaceMetadata. A workspace root lists its `[package]` and every tracked manifest below it that its
513
- * `members` globs name and its `exclude` does not, then (as cargo does) every tracked package below the root that a member, or the root's
514
- * [workspace.dependencies], reaches by a path dependency and `exclude` does not name. A manifest with no `[workspace]` is a standalone
515
- * package unless an ancestor workspace owns that directory (cargo refuses it there, so this throws and discoverRustWorkspaces skips it).
516
- * Strings may be "basic" or 'literal', arrays may carry comments, and `package.name = ...` / `workspace.members = [...]` dotted keys read like
517
- * their tables. It is a reader, not a TOML parser: a manifest a glob or a path dependency reaches but whose package name it cannot read is
518
- * returned in `unplaced` (beside { cargoDir, members }; empty for a standalone package, which has nothing to place), any other it cannot place
519
- * is reported by unmatchedManifests, and the host receipt runs the real metadata.
520
- */
521
- export function manifestWorkspaceMetadata({ root, dir, tracked, readFile }) {
522
- const manifestOf = (d) => (d === "." ? "Cargo.toml" : `${d}/Cargo.toml`);
523
- const cache = new Map();
524
- const read = (m) => { if (!cache.has(m)) cache.set(m, manifestTables(readFile(path.join(root, m)))); return cache.get(m); };
525
- const hasPackage = (m) => hasTomlTable(read(m), "package");
526
- const memberOf = (m) => {
527
- const tables = read(m);
528
- const name = tomlString(tomlValue(tables, "package", "name"));
529
- if (!name) return null;
530
- const publish = tomlValue(tables, "package", "publish");
531
- return { name, dir: dirOfManifest(m), manifest: m, publish: !(publish !== null && /^(?:false\b|\[\s*\])/.test(publish)) };
532
- };
533
- const all = tracked("Cargo.toml");
534
- const tables = read(manifestOf(dir));
535
- const byName = (a, b) => (a.name < b.name ? -1 : 1);
536
- if (!hasTomlTable(tables, "workspace")) {
537
- for (let up = dir; up !== "."; ) {
538
- up = path.posix.dirname(up);
539
- if (all.includes(manifestOf(up)) && hasTomlTable(read(manifestOf(up)), "workspace")) throw new Error(`current package believes it's in a workspace when it's not (the workspace root is ${up === "." ? "the repository root" : up})`);
540
- }
541
- const self = memberOf(manifestOf(dir));
542
- if (!self) throw new Error(`${manifestOf(dir)} has neither a [package] with a name nor a [workspace]`);
543
- return { cargoDir: dir, members: [self], unplaced: [] };
544
- }
545
- const patterns = tomlStrings(tomlValue(tables, "workspace", "members")).map((g) => globToRegExp(g));
546
- const excluded = workspaceExcludes(readFile, root, dir);
547
- const isExcluded = (d) => excluded.some((x) => d === x || d.startsWith(`${x}/`));
548
- const rel = (d) => (d === dir ? "." : dir === "." ? d : d.startsWith(`${dir}/`) ? d.slice(dir.length + 1) : null);
549
- const found = new Map(); const unplaced = new Set();
550
- const add = (m) => {
551
- if (found.has(m)) return false;
552
- const member = memberOf(m);
553
- if (member) found.set(m, member); else unplaced.add(m);
554
- return Boolean(member);
555
- };
556
- for (const m of all) {
557
- const d = dirOfManifest(m);
558
- const r = rel(d);
559
- if (r === null || r === "." || isExcluded(d) || !patterns.some((re) => re.test(r))) continue;
560
- add(m);
561
- }
562
- if (hasPackage(manifestOf(dir))) add(manifestOf(dir));
563
- // Path dependencies below the workspace root become members on their own, transitively.
564
- const queue = [manifestOf(dir), ...found.keys()];
565
- for (let m = queue.pop(); m !== undefined; m = queue.pop()) {
566
- for (const p of pathDependencies(read(m))) {
567
- const target = path.posix.normalize(path.posix.join(dirOfManifest(m), p)).replace(/\/$/, "");
568
- const tm = manifestOf(target);
569
- if (rel(target) === null || rel(target) === "." || isExcluded(target) || !all.includes(tm) || found.has(tm) || !hasPackage(tm)) continue;
570
- if (add(tm)) queue.push(tm);
571
- }
572
- }
573
- return { cargoDir: dir, members: [...found.values()].sort(byName), unplaced: [...unplaced].sort() };
574
- }
575
-
576
- /**
577
- * The tracked Cargo.toml with a package that no unit covers and discoverRustWorkspaces did not skip: what the manifest reader could
578
- * not place (an unreadable name, a shape it does not know). A manifest it cannot read at all is listed too. Sorted.
579
- */
580
- export function unmatchedManifests({ root, manifests, rust, readFile }) {
581
- const covered = new Set([...rust.units.map((u) => (u.dir === "." ? "Cargo.toml" : `${u.dir}/Cargo.toml`)), ...rust.skipped.map((k) => k.manifest)]);
582
- return manifests.filter((m) => {
583
- if (covered.has(m)) return false;
584
- try { return hasTomlTable(manifestTables(readFile(path.join(root, m))), "package"); } catch { return true; }
585
- }).sort();
586
- }
587
-
588
- /** `plan` without the scans in `dropped` (by id), its digests recomputed over what is left, and `note` appended. */
589
- export function withoutScans(plan, dropped, note) {
590
- const gone = new Set(dropped.map((s) => s.id));
591
- const scans = plan.scans.filter((s) => !gone.has(s.id));
592
- return {
593
- ...plan, scans, deferred: plan.deferred.filter((id) => !gone.has(id)),
594
- scanSetDigest: digestScanSet(scans),
595
- classDigests: { A: digestScanSet(scans.filter((s) => s.class === "A")), B: digestScanSet(scans.filter((s) => s.class === "B")) },
596
- notes: note ? [...plan.notes, note] : plan.notes,
597
- };
598
- }
599
-
600
- /**
601
- * The lines the gate prints when it defers: the Rust units and scans that did not run, the pushed refs, and who gates the merge.
602
- * `manifests` is the number of tracked Cargo.toml: when it is above zero and no unit was found, the block says the reader found none
603
- * and that the scans cannot be named, instead of claiming no Rust scan was due. `unmatched` lists the tracked manifests with a package
604
- * that no unit covers (unmatchedManifests); they get a line of their own. `npmCalls` are the npm test scans deferred with the Rust
605
- * gate because their package script runs `rustright` or `cargo` ([{ id, tool, via, command }]); `notRun` names them too, and a line of their
606
- * own gives each one's reason.
607
- */
608
- export function deferredBlock({ units, notRun, refs, manifests = 0, unmatched = [], tag = "rightkit-gate:", npmCalls = [] }) {
609
- const noUnits = units.length === 0 && manifests > 0;
610
- const lines = [
611
- `${tag} rust gate DEFERRED to host qualification (no RustRight broker on this host): rust units ${units.length ? units.join(", ") : noUnits ? `none found among the ${manifests} tracked Cargo.toml (read without cargo)` : "none found"}; scans not run ${notRun.length ? notRun.join(", ") : noUnits ? "unknown (no unit was found, so no Rust scan could be planned)" : "none (no Rust scan was due for this diff)"}; the pull request needs a HOST RECEIPT before merge`,
612
- `${tag} pushed ${refs.join(", ")} (non-protected, not CI). The HOST RECEIPT (a host with the RustRight broker ran the full gate) stays the merge gate; ${DEFER_OPT_OUT_ENV}=1 keeps the refusal on this host.`,
613
- ];
614
- if (unmatched.length) lines.push(`${tag} tracked Cargo.toml with a package that matched no unit (the manifest reader runs no cargo; the HOST RECEIPT does): ${unmatched.join(", ")}`);
615
- if (npmCalls.length) lines.push(`${tag} npm test scan(s) deferred with the Rust gate, never run and never passed, because the package script runs ${[...new Set(npmCalls.map((c) => c.tool))].join(" or ")} and this host has no broker: ${npmCalls.map((c) => `${c.id} (script ${c.via.join(" -> ")}: ${c.command})`).join("; ")}`);
616
- return lines;
617
- }
618
-
619
457
  /**
620
458
  * The crates a lock-reflected workspace change reaches (the lock graph CI's selection uses): a Cargo.lock change, a vendor/ change
621
459
  * (the vendored crate is a lock package), and a workspace manifest change confined to the tables the lock also records
@@ -661,8 +499,18 @@ export function lockScope({ changed, cargoDir = ".", unitNames, showBase, readLo
661
499
  * gate binary is the root node_modules/.bin/rightkit-gate, else any workspace package's (up to three levels down: tauri-app-next,
662
500
  * apps/*, packages/*), else `pnpm exec rightkit-gate`, else node with a package-relative path when pnpm is absent; the line it
663
501
  * chose is printed once. No absolute path is written: everything is resolved from git at run time.
502
+ *
503
+ * `packages` (default none) is the multi-root form: the repository-relative directories of the packages that declare @rightkit/release when the
504
+ * repository root does not declare it (CutRight's apps/cutright, HeardRight's tauri-app-next). Each one's node_modules/.bin/rightkit-gate is tried
505
+ * right after the root's (the glob above stops three levels down), and the pnpm fallback runs in the first of them (`pnpm --dir <package> exec`;
506
+ * pnpm at the root of such a repository has no rightkit-gate to run). With none, the text is exactly what 0.6.4 and 0.6.5 wrote
507
+ * (fixtures/published-gate-hooks.json holds its digest, and a repository whose root installs the package must keep getting it, byte for byte,
508
+ * or the committed hook of every app copied from the template stops being "already current"). `nodePath` and every package are written into
509
+ * a shell script: installHook passes only plain relative paths (hookGatePlan).
664
510
  */
665
- export function hookText(nodePath) {
511
+ export function hookText(nodePath, packages = []) {
512
+ const bins = packages.map((dir) => ` ${dir}/node_modules/.bin/rightkit-gate`).join("");
513
+ const pnpmExec = packages.length ? `pnpm --dir ${packages[0]} exec rightkit-gate` : "pnpm exec rightkit-gate";
666
514
  return `#!/bin/sh
667
515
  # Mandatory local check and gate before every push (rightkit-gate install-hook). Never bypass with --no-verify.
668
516
  cd "$(git rev-parse --show-toplevel)" || exit 1
@@ -675,17 +523,17 @@ if [ -x "$own" ]; then
675
523
  echo "rightkit-gate: running $own" >&2
676
524
  "$own" "$@" < "$stdin" || exit $?
677
525
  fi
678
- # The gate binary: the root install, any workspace package's install, then pnpm exec.
526
+ # The gate binary: the root install, ${packages.length ? "the install of each package that declares @rightkit/release, any other workspace package's install, then pnpm exec in the first of them" : "any workspace package's install, then pnpm exec"}.
679
527
  found=""
680
- for bin in node_modules/.bin/rightkit-gate */node_modules/.bin/rightkit-gate */*/node_modules/.bin/rightkit-gate */*/*/node_modules/.bin/rightkit-gate; do
528
+ for bin in node_modules/.bin/rightkit-gate${bins} */node_modules/.bin/rightkit-gate */*/node_modules/.bin/rightkit-gate */*/*/node_modules/.bin/rightkit-gate; do
681
529
  if [ -x "$bin" ]; then found="$bin"; break; fi
682
530
  done
683
531
  if [ -n "$found" ]; then
684
532
  echo "rightkit-gate: using $found" >&2
685
533
  rightkit_gate() { "$found" "$@"; }
686
534
  elif command -v pnpm >/dev/null 2>&1; then
687
- echo "rightkit-gate: using pnpm exec rightkit-gate" >&2
688
- rightkit_gate() { pnpm exec rightkit-gate "$@"; }
535
+ echo "rightkit-gate: using ${pnpmExec}" >&2
536
+ rightkit_gate() { ${pnpmExec} "$@"; }
689
537
  else
690
538
  echo "rightkit-gate: using node ${nodePath}" >&2
691
539
  rightkit_gate() { node "${nodePath}" "$@"; }
@@ -715,7 +563,6 @@ exit 0
715
563
  // names are chained; anything else is reported and skipped, never interpolated.
716
564
  const HOOK_FILE_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
717
565
  const chainableHook = (name) => !name.endsWith(".sample") && HOOK_FILE_NAME.test(name);
718
-
719
566
  // ---- result cache inputs ------------------------------------------------------------------------------------
720
567
 
721
568
  // The build inputs that change how every crate compiles, relative to the Cargo workspace root, plus the gate's own config.
@@ -1090,42 +937,67 @@ export function quietLeaseLane(o, { tag = "rightkit-gate:", reason = "rightkit-g
1090
937
  };
1091
938
  }
1092
939
 
1093
- // ---- command resolution -------------------------------------------------------------------------------------
1094
940
 
1095
- // The rustfmt this host can run: the one findRustfmt names, when it answers `--version` (a rustup proxy with no toolchain does not).
1096
- function usableRustfmt(o) {
1097
- const found = findRustfmt({ env: o.env, exists: o.exists, readdir: o.readdir, platform: o.platform, spawn: o.spawn });
1098
- return found && !failed(o.spawn(found, ["--version"], { cwd: o.cwd })) ? found : null;
1099
- }
1100
-
1101
- // Cargo only through the broker: `rustright cargo`, or a `cargo` on PATH that is the managed shim (it runs cargo-guard.mjs).
1102
- function resolveCargoCommand(o) {
1103
- const which = (cmd) => {
1104
- const r = o.spawn(o.platform === "win32" ? "where" : "which", [cmd]);
1105
- return failed(r) ? [] : text(r).split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
1106
- };
1107
- if (which("rustright").length) return ["rustright", "cargo"];
1108
- const first = which("cargo")[0];
1109
- if (first) { try { if (o.readFile(first).includes("cargo-guard.mjs")) return ["cargo"]; } catch { /* unreadable: not the shim */ } }
1110
- return null;
1111
- }
1112
941
 
1113
942
  // ---- install-hook -------------------------------------------------------------------------------------------
1114
943
 
1115
- /** The first tracked package.json (or the root one when git cannot list) that declares @rightkit/release, or is that package itself. */
1116
- function releaseProvider(o, root, git) {
944
+ /**
945
+ * The tracked package.json files (the root one when git cannot list) that matter to the hook: `provider` is the first that declares
946
+ * @rightkit/release or is that package itself, `declaring` the directories of every one that declares it ("." for the root; the package itself is
947
+ * not one: it installs nothing of itself).
948
+ */
949
+ function releasePackages(o, root, git) {
1117
950
  let files = ["package.json"];
1118
951
  const listed = git("ls-files", "-z", "--", ":(glob)**/package.json");
1119
952
  if (!failed(listed)) files = text(listed).split("\0").filter(Boolean).map(posix);
953
+ let provider = null; const declaring = [];
1120
954
  for (const file of files) {
1121
955
  const abs = path.join(root, file);
1122
956
  if (!o.exists(abs)) continue;
1123
957
  let manifest;
1124
958
  try { manifest = JSON.parse(o.readFile(abs).replace(/^\uFEFF/, "")); } catch { continue; }
1125
- if (manifest?.name === "@rightkit/release") return file;
1126
- for (const section of ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"]) if (manifest?.[section] && Object.hasOwn(manifest[section], "@rightkit/release")) return file;
959
+ if (manifest?.name === "@rightkit/release") { provider ??= file; continue; }
960
+ if (["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"].some((section) => manifest?.[section] && Object.hasOwn(manifest[section], "@rightkit/release"))) {
961
+ provider ??= file;
962
+ declaring.push(path.posix.dirname(file));
963
+ }
1127
964
  }
1128
- return null;
965
+ return { provider, declaring: [...new Set(declaring)].sort() };
966
+ }
967
+
968
+ // Where a package installs the gate, below its own directory. pnpm links it there for a package that declares @rightkit/release, so the path is the
969
+ // same for every version (node_modules/.pnpm/@rightkit+release@x.y.z/... is the store copy it points at, and goes stale with each bump).
970
+ const GATE_IN_PACKAGE = "node_modules/@rightkit/release/bin/rightkit-gate.mjs";
971
+ // What may be written into the committed shell script: a plain relative path of letters, digits and _ - . @ + /, no `..`, no `.` segment, no
972
+ // leading dash (it would read as an option of `pnpm --dir`).
973
+ const HOOK_PATH_SEGMENT = /^(?!\.{1,2}$)[A-Za-z0-9_@.+-]+$/;
974
+ export const hookPathIsPlain = (value) => typeof value === "string" && value !== "" && !value.startsWith("-") && value.split("/").every((segment) => HOOK_PATH_SEGMENT.test(segment));
975
+
976
+ /**
977
+ * What the pre-push hook is told about where the gate lives: { nodePath, packages, unsafe }. `nodePath` is the last resort the hook runs with
978
+ * node, `packages` the extra directories hookText searches (multi-root form), `unsafe` the declaring packages whose directory cannot be written
979
+ * into a shell script. `declaring` is releasePackages(...).declaring; `gateFile` is where this gate runs from.
980
+ *
981
+ * A function of what the repository's package.json files declare, never of what is installed: the same tree gives the same hook on a fresh
982
+ * clone, an exports checkout, after `pnpm install` and after a version bump, so `install-hook` (a `prepare` script) never meets a hook it
983
+ * wrote earlier as one that "already exists and differs". It takes no `exists`, so that cannot creep back in. Always a stable,
984
+ * version-independent path relative to the repository, never one outside it, absolute, or inside the pnpm store:
985
+ * 1. The root declares @rightkit/release (whatever else does): its link and no extra packages, the hook 0.6.4 and 0.6.5 wrote (Pulse declares
986
+ * it at the root and in hub/; RightKit itself at the root).
987
+ * 2. Only packages declare it (CutRight's apps/cutright, HeardRight's tauri-app-next): the link of the first of them in sorted order, whichever is
988
+ * installed, and every one of them is searched.
989
+ * 3. No package.json declares it (a checkout of the package itself that nothing depends on), or only under a directory the hook cannot name: if
990
+ * this gate runs from inside the repository and not from a node_modules, its path from the root, else the root's link, never a path
991
+ * outside the repository. This is the one case that reads where the gate runs from, because the repository says nothing.
992
+ */
993
+ export function hookGatePlan({ root, gateFile, declaring }) {
994
+ const plain = declaring.filter((d) => d !== "." && hookPathIsPlain(d));
995
+ const unsafe = declaring.filter((d) => d !== "." && !hookPathIsPlain(d));
996
+ if (declaring.includes(".")) return { nodePath: GATE_IN_PACKAGE, packages: [], unsafe };
997
+ if (plain.length) return { nodePath: `${plain[0]}/${GATE_IN_PACKAGE}`, packages: plain, unsafe };
998
+ const relative = posix(path.relative(root, gateFile));
999
+ const inside = relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative) && !relative.split("/").includes("node_modules") && hookPathIsPlain(relative);
1000
+ return { nodePath: inside ? relative : GATE_IN_PACKAGE, packages: [], unsafe };
1129
1001
  }
1130
1002
 
1131
1003
  // The advisory ownership pre-commit step (`right-release hooks install`, ownership-hooks.mjs) runs the pinned @rightkit/release, so
@@ -1137,9 +1009,10 @@ async function installHook(o, root, force) {
1137
1009
  const git = (...a) => o.spawn("git", a, { cwd: root });
1138
1010
  const hooksDir = path.join(root, ".githooks");
1139
1011
  const hookPath = path.join(hooksDir, "pre-push");
1140
- const installed = "node_modules/@rightkit/release/bin/rightkit-gate.mjs";
1141
- const nodePath = o.exists(path.join(root, installed)) ? installed : posix(path.relative(root, fileURLToPath(import.meta.url)));
1142
- const wanted = hookText(nodePath);
1012
+ const { provider, declaring } = releasePackages(o, root, git);
1013
+ const { nodePath, packages, unsafe } = hookGatePlan({ root, gateFile: o.gateFile, declaring });
1014
+ if (unsafe.length) o.err(`rightkit-gate: WARNING: the pre-push hook does not look for the gate in ${unsafe.join(", ")}: not a plain directory name (letters, digits and _ - . @ + only, with / between); it still finds a root or workspace install by glob.`);
1015
+ const wanted = hookText(nodePath, packages);
1143
1016
  const hasHook = o.exists(hookPath);
1144
1017
  const current = hasHook ? o.readFile(hookPath).replace(/\r\n/g, "\n") : null;
1145
1018
  if (hasHook && current !== wanted && !force) {
@@ -1200,18 +1073,261 @@ async function installHook(o, root, force) {
1200
1073
  if (failed(set)) { o.err(`rightkit-gate: git config core.hooksPath failed: ${why(set)}`); return 2; }
1201
1074
  done.push(`set core.hooksPath to .githooks${existingPath ? ` (was ${existingPath})` : ""}`);
1202
1075
  }
1076
+ // The agent side of the one door: a Claude Code PreToolUse hook in the repository's .claude/settings.json runs door-policy.mjs on every
1077
+ // shell command, on every host (the broker-less cloud included), and denies any build that does not go through rightkit-gate.
1078
+ const agentHookFile = "node_modules/@rightkit/release/bin/rightkit-agent-hook.mjs";
1079
+ const agentHook = o.exists(path.join(root, agentHookFile)) ? agentHookFile : posix(path.relative(root, fileURLToPath(new URL("./rightkit-agent-hook.mjs", import.meta.url))));
1080
+ const settingsPath = path.join(root, ".claude", "settings.json");
1081
+ let settings = {};
1082
+ if (o.exists(settingsPath)) { try { settings = JSON.parse(o.readFile(settingsPath)); } catch { o.err("rightkit-gate: .claude/settings.json is not JSON; the agent hook was not added"); settings = null; } }
1083
+ if (settings) {
1084
+ const command = `node ${agentHook}`;
1085
+ const pre = Array.isArray(settings.hooks?.PreToolUse) ? settings.hooks.PreToolUse : [];
1086
+ const has = pre.some((entry) => (entry.hooks ?? []).some((h) => typeof h.command === "string" && h.command.includes("rightkit-agent-hook")));
1087
+ if (has) done.push(".claude/settings.json agent hook already present");
1088
+ else {
1089
+ settings.hooks = { ...(settings.hooks ?? {}), PreToolUse: [...pre, { matcher: "Bash", hooks: [{ type: "command", command }] }] };
1090
+ try { o.mkdir(path.dirname(settingsPath), { recursive: true }); } catch { /* exists */ }
1091
+ o.writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
1092
+ done.push("added the one-door agent hook to .claude/settings.json (commit it)");
1093
+ }
1094
+ }
1203
1095
  for (const line of done) o.out(`rightkit-gate: ${line}`);
1204
1096
  const chained = own.length ? own.map((name) => (name === "pre-push" ? "pre-push (run first, then the gate)" : name)) : [];
1205
1097
  o.out(`rightkit-gate: chained hooks from .git/hooks: ${chained.length ? chained.join(", ") : "none"}${own.includes("pre-push") ? "" : "; the pre-push hook still runs .git/hooks/pre-push first if one appears later"}`);
1206
1098
  if (!ownership) o.out(`rightkit-gate: no ownership pre-commit step: ${PINNED_CLI} is not installed here; install @rightkit/release, then run install-hook again`);
1207
1099
  const files = [".githooks/pre-push", ...trampolines.map((t) => `.githooks/${t.name}`)];
1208
- o.out(`rightkit-gate: commit the hooks with executable mode (git add --chmod=+x ${files.join(" ")}); the pre-push hook finds rightkit-gate in node_modules/.bin of the repository root, else of any workspace package, else through \`pnpm exec\`.`);
1209
- const provider = releaseProvider(o, root, git);
1100
+ o.out(`rightkit-gate: commit the hooks with executable mode (git add --chmod=+x ${files.join(" ")}); the pre-push hook finds rightkit-gate in node_modules/.bin of the repository root, ${packages.length ? `else of ${packages.join(", ")} (the packages that declare @rightkit/release), else of any workspace package, else through \`pnpm --dir ${packages[0]} exec\`` : "else of any workspace package, else through \`pnpm exec\`"}.`);
1210
1101
  if (!provider) o.err("rightkit-gate: WARNING: no package.json in this repository depends on @rightkit/release, so the pre-push hook cannot find the rightkit-gate binary; add @rightkit/release as a devDependency of the root or of a workspace package and install.");
1211
1102
  else o.out(`rightkit-gate: @rightkit/release is declared in ${provider}`);
1212
1103
  return 0;
1213
1104
  }
1214
1105
 
1106
+ // ---- the stages: rightkit-gate --to <stage> --------------------------------------------------------------------------------
1107
+
1108
+ /**
1109
+ * Runs every stage up to `--to`, in order, for HEAD: check (class A), compile (the whole gate over every file), then, through the existing
1110
+ * owners, package (source QA, then `right-release build`, which seals the exact artifact set), qualify (installed QA on that sealed set)
1111
+ * and release (`right-release upload` of that same sealed set). Stops at the first stage that fails. check and compile are reused from
1112
+ * the receipt only when every bound input is unchanged; the right-release stages run under a live door run, so they refuse any other
1113
+ * caller (gate-stages.mjs requireDoorRun).
1114
+ */
1115
+ async function runStages(o, args, root, git, io) {
1116
+ const option = (name) => { const i = args.indexOf(name); return i < 0 ? undefined : args[i + 1]; };
1117
+ const tag = "rightkit-gate:";
1118
+ const to = option("--to");
1119
+ if (to === "dev" || to === "test") return await runWorktreeCompile(o, args, root, git, io, to);
1120
+ const stages = stagesTo(to);
1121
+ if (!stages) { o.err(`${tag} --to must be one of ${STAGES.join(", ")}`); return 2; }
1122
+ const platform = option("--platform");
1123
+ const tier = option("--tier");
1124
+ if (stages.includes("package") && !["mac", "win"].includes(platform)) { o.err(`${tag} --to ${to} needs --platform mac|win`); return 2; }
1125
+ if (stages.includes("release") && !["patch", "update"].includes(tier)) { o.err(`${tag} --to release needs --tier patch|update (chosen explicitly, never defaulted)`); return 2; }
1126
+ const commit = text(git("rev-parse", "HEAD")).trim();
1127
+ const tree = text(git("rev-parse", "HEAD^{tree}")).trim();
1128
+ const common = text(git("rev-parse", "--git-common-dir")).trim();
1129
+ if (!/^[0-9a-f]{40}$/.test(commit) || !tree || !common) { o.err(`${tag} cannot read HEAD and the git directory`); return 2; }
1130
+ const commonDir = path.resolve(root, common);
1131
+ const dirty = git("status", "--porcelain", "--untracked-files=no");
1132
+ if (failed(dirty) || text(dirty).trim()) { o.err(`${tag} BLOCKED: the checkout differs from HEAD; every stage works on the commit, so commit or stash first`); return 1; }
1133
+
1134
+ const which = (cmd) => { const r = o.spawn(o.platform === "win32" ? "where" : "which", [cmd]); return failed(r) ? [] : text(r).split(/\r?\n/).map((l) => l.trim()).filter(Boolean); };
1135
+ const executor = (o.resolveExecutor ?? resolveExecutor)({ env: o.env, platform: o.platform, exists: o.exists, which, readFile: o.readFile });
1136
+ if (executor.kind === "refuse") { o.err(`${tag} BLOCKED: ${executor.problem}`); return 1; }
1137
+ const version = (cmd, a) => { const r = o.spawn(cmd, a, { cwd: root }); return failed(r) ? null : text(r).trim().split(/\r?\n/)[0]; };
1138
+ const listed = git("ls-tree", "-r", "--name-only", "-z", "HEAD");
1139
+ const lockFiles = {};
1140
+ for (const f of failed(listed) ? [] : text(listed).split("\0").filter((x) => /(^|\/)(Cargo\.lock|pnpm-lock\.yaml)$/.test(x))) {
1141
+ const shown = git("show", `HEAD:${f}`); if (!failed(shown)) lockFiles[f] = text(shown);
1142
+ }
1143
+ const binding = bindingOf({
1144
+ tree, lockFiles, gateFile: fileURLToPath(import.meta.url), executor: executor.kind,
1145
+ toolchain: { classA: { "cargo-deny": version("cargo-deny", ["--version"]), "cargo-audit": version("cargo-audit", ["--version"]), actionlint: version("actionlint", ["-version"]), shellcheck: version("shellcheck", ["--version"]) }, node: process.version, rustc: version(executor.kind === "broker" && executor.cargo[0] === "rustright" ? "rustright" : "rustc", executor.kind === "broker" && executor.cargo[0] === "rustright" ? ["rustc", "-V"] : ["-V"]), pnpm: version("pnpm", ["--version"]) },
1146
+ });
1147
+ // Release writes to R2. The write credential lives only in the trusted publisher: a GitHub Actions job in the protected
1148
+ // publication environment, which sets RIGHTKIT_PUBLISHER=github-environment. Hosts and agents never hold it, so the door refuses the
1149
+ // stage anywhere else rather than fail half way through an upload.
1150
+ if (stages.includes("release") && !(o.env.GITHUB_ACTIONS === "true" && o.env.RIGHTKIT_PUBLISHER === "github-environment")) {
1151
+ o.err(`${tag} BLOCKED: --to release runs only in the trusted publisher (the release workflow's protected publication job). Run --to qualify here, then dispatch the release workflow for ${commit.slice(0, 12)}`);
1152
+ return 1;
1153
+ }
1154
+ // --receipt-in <file>: the receipt an earlier job of the same pipeline wrote (another runner: CI's package job, then its qualify and
1155
+ // publish jobs). It is taken only for this commit and the same source binding (tree, lockfiles, gate); it then stands in for the
1156
+ // stages it records, and the sealed bytes on disk must still hash to its manifest digest before anything uses them.
1157
+ const receiptIn = option("--receipt-in");
1158
+ if (receiptIn !== undefined) {
1159
+ let imported = null;
1160
+ try { imported = JSON.parse(o.readFile(path.resolve(o.cwd, receiptIn))); } catch { /* refused below */ }
1161
+ if (!imported || imported.schema !== RECEIPT_SCHEMA || imported.commit !== commit || !sameSource(imported.binding, binding)) {
1162
+ o.err(`${tag} BLOCKED: --receipt-in ${receiptIn} is not a ${RECEIPT_SCHEMA} receipt for ${commit.slice(0, 12)} with this tree, these lockfiles and this gate`);
1163
+ return 1;
1164
+ }
1165
+ (o.writeReceipt ?? writeReceipt)({ commonDir, receipt: imported });
1166
+ }
1167
+ const previous = (o.readReceipt ?? readReceipt)({ commonDir, commit });
1168
+ // Same host and inputs: every recorded stage counts. Same source on another host: only the stages bound to sealed bytes (package and
1169
+ // later) carry over; check and compile run again here.
1170
+ const receipt = previous && sameBinding(previous.binding, binding) ? previous
1171
+ : previous && sameSource(previous.binding, binding) ? { ...previous, binding, stages: Object.fromEntries(Object.entries(previous.stages ?? {}).filter(([k]) => k !== "check" && k !== "compile")) }
1172
+ : { schema: RECEIPT_SCHEMA, commit, binding, stages: {} };
1173
+ const receiptOut = option("--receipt-out");
1174
+ const save = () => {
1175
+ (o.writeReceipt ?? writeReceipt)({ commonDir, receipt });
1176
+ if (receiptOut !== undefined) o.writeFile(path.resolve(o.cwd, receiptOut), `${JSON.stringify(receipt, null, 2)}\n`);
1177
+ };
1178
+
1179
+ const cli = fileURLToPath(new URL("../cli/right-release.mjs", import.meta.url));
1180
+ const rightRelease = (stage, cliArgs) => {
1181
+ const run = startRun({ commonDir, commit, tree, stage });
1182
+ try {
1183
+ o.out(`${tag} [${stage}] right-release ${cliArgs.join(" ")}`);
1184
+ const r = (o.spawnInherit ?? ((cmd, a, opts) => spawnInherit(cmd, a, { ...opts, stdio: "inherit" })))(process.execPath, [cli, ...cliArgs], { cwd: root, env: { ...o.env, ...run.env } });
1185
+ return r.status === 0;
1186
+ } finally { endRun(run); }
1187
+ };
1188
+
1189
+ o.out(`${tag} --to ${to} for ${commit.slice(0, 12)}: ${stages.join(" -> ")}; executor ${executor.kind}`);
1190
+ // A later stage on the sealed bytes: check, compile and package already passed for them (in this receipt) and the bytes on disk are
1191
+ // the ones they sealed, so they are not run again.
1192
+ const sealedNow = receipt.stages.package?.ok ? (o.sealedFor ?? sealedFor)({ root, commit, platform }) : null;
1193
+ const sealedIntact = Boolean(sealedNow && sealedNow.id === receipt.stages.package.sealed && sealedNow.digest === receipt.stages.package.manifestSha256);
1194
+ for (const stage of stages) {
1195
+ if ((stage === "check" || stage === "compile") && receipt.stages[stage]?.ok) { o.out(`${tag} [${stage}] already passed for this exact input (commit, tree, lockfiles, gate, toolchain)`); continue; }
1196
+ if ((stage === "check" || stage === "compile" || stage === "package") && sealedIntact) {
1197
+ o.out(`${tag} [${stage}] already passed for the sealed set ${sealedNow.id} (manifest ${sealedNow.digest.slice(0, 12)})`); continue;
1198
+ }
1199
+ // The publisher (another runner, often another OS) cannot repeat installed QA; it takes the qualify result recorded for exactly
1200
+ // these sealed bytes. --to qualify itself always runs it.
1201
+ if (stage === "qualify" && to !== "qualify" && sealedIntact && receipt.stages.qualify?.ok && receipt.stages.qualify.manifestSha256 === sealedNow.digest) {
1202
+ o.out(`${tag} [qualify] already passed for the sealed set ${sealedNow.id} (manifest ${sealedNow.digest.slice(0, 12)})`); continue;
1203
+ }
1204
+ // The publisher only uploads. It never checks out a build environment, signs or runs installed QA, so every earlier stage must
1205
+ // already stand in its receipt for exactly the sealed bytes on disk; anything else is refused, never rebuilt or requalified here.
1206
+ if (to === "release" && stage !== "release") {
1207
+ o.err(`${tag} BLOCKED: [${stage}] --to release only uploads: the receipt must already record ${stage} for the sealed bytes on disk (${sealedNow ? `manifest ${sealedNow.digest.slice(0, 12)}` : "none found"}); run --to qualify on the host and stage again`);
1208
+ return 1;
1209
+ }
1210
+ let ok = false; const at = new Date().toISOString(); let extra = {};
1211
+ if (stage === "check") ok = (await (o.runCheck ?? (() => main(["check", "--commit", "HEAD"], { ...io, cwd: root })))()) === 0;
1212
+ else if (stage === "compile") {
1213
+ // A Tauri crate with externalBin needs its sidecars in src-tauri/binaries before it compiles (its build script refuses
1214
+ // otherwise), so every release config that declares `sidecars` is staged first, through right-release under this door run.
1215
+ const listedConfigs = git("ls-tree", "-r", "--name-only", "-z", "HEAD");
1216
+ const sidecarConfigs = (failed(listedConfigs) ? [] : text(listedConfigs).split("\0").filter((f) => /(^|\/)right-release\.config\.(json|mjs)$/.test(f)))
1217
+ .filter((f) => { try { return /\bsidecars\b/.test(o.readFile(path.join(root, f))); } catch { return false; } });
1218
+ ok = true;
1219
+ for (const config of sidecarConfigs) if (!rightRelease(stage, ["stage-sidecar", "--config", config])) { ok = false; break; }
1220
+ if (ok) ok = (await (o.runCompile ?? (() => main(["--all"], { ...io, cwd: root, readStdin: () => "" })))()) === 0;
1221
+ }
1222
+ else if (stage === "package") {
1223
+ ok = rightRelease(stage, ["qa", "--stage", "source", "--platform", platform]) && rightRelease(stage, ["build", "--platform", platform]);
1224
+ const sealed = ok ? (o.sealedFor ?? sealedFor)({ root, commit, platform }) : null;
1225
+ if (ok && !sealed) { o.err(`${tag} [package] the build reported success but sealed nothing for ${commit.slice(0, 8)} on ${platform}`); ok = false; }
1226
+ if (sealed) extra = { platform, sealed: sealed.id, manifestSha256: sealed.digest };
1227
+ } else {
1228
+ const sealed = receipt.stages.package?.ok ? receipt.stages.package : null;
1229
+ const current = sealed ? (o.sealedFor ?? sealedFor)({ root, commit, platform }) : null;
1230
+ if (!sealed || !current || current.id !== sealed.sealed || current.digest !== sealed.manifestSha256) {
1231
+ o.err(`${tag} BLOCKED: [${stage}] needs the sealed set the package stage produced for this commit, unchanged (sealed manifest digest)`);
1232
+ ok = false;
1233
+ } else if (stage === "qualify") {
1234
+ ok = rightRelease(stage, ["qa", "--stage", "installed", "--platform", platform, "--release", sealed.sealed]);
1235
+ extra = { sealed: sealed.sealed, manifestSha256: sealed.manifestSha256 };
1236
+ } else {
1237
+ if (!receipt.stages.qualify?.ok || receipt.stages.qualify.manifestSha256 !== sealed.manifestSha256) { o.err(`${tag} BLOCKED: [release] the sealed set was not qualified (installed QA) in this door run's receipt`); ok = false; }
1238
+ else { ok = rightRelease(stage, ["upload", "--release", sealed.sealed, "--platform", platform, "--tier", tier]); extra = { sealed: sealed.sealed, manifestSha256: sealed.manifestSha256, tier }; }
1239
+ }
1240
+ }
1241
+ receipt.stages[stage] = { ok, at, ...extra };
1242
+ save();
1243
+ if (!ok) { o.err(`${tag} --to ${to} stopped at ${stage}: nothing after it ran`); return 1; }
1244
+ }
1245
+ o.out(`${tag} --to ${to} passed: ${stages.join(", ")}`);
1246
+ return 0;
1247
+ }
1248
+
1249
+ /**
1250
+ * rightkit-gate stage --platform mac|win [--tier patch|update]: hands a qualified sealed set to the trusted publisher. The host signed,
1251
+ * sealed and qualified it (--to qualify); only the publisher holds the R2 write credential. Requires this host's receipt for HEAD with
1252
+ * package and qualify passed on the sealed bytes as they are now. Packs the sealed directory and the receipt into the draft GitHub
1253
+ * release `rightkit-staging-<commit>` (replacing that platform's asset), then, with --tier, dispatches RightKit's publish-app workflow
1254
+ * (the one trusted publisher for every app) for HEAD.
1255
+ */
1256
+ const PUBLISHER_REPO = "bogusyogi/rightkit";
1257
+ function stageForPublisher(o, args, root, git) {
1258
+ const option = (name) => { const i = args.indexOf(name); return i < 0 ? undefined : args[i + 1]; };
1259
+ const tag = "rightkit-gate:";
1260
+ const platform = option("--platform");
1261
+ const tier = option("--tier");
1262
+ if (!["mac", "win"].includes(platform)) { o.err(`${tag} stage needs --platform mac|win`); return 2; }
1263
+ if (tier !== undefined && !["patch", "update"].includes(tier)) { o.err(`${tag} stage --tier must be patch or update`); return 2; }
1264
+ const commit = text(git("rev-parse", "HEAD")).trim();
1265
+ const common = text(git("rev-parse", "--git-common-dir")).trim();
1266
+ if (!/^[0-9a-f]{40}$/.test(commit) || !common) { o.err(`${tag} cannot read HEAD and the git directory`); return 2; }
1267
+ const commonDir = path.resolve(root, common);
1268
+ const receipt = (o.readReceipt ?? readReceipt)({ commonDir, commit });
1269
+ const pack = receipt?.stages?.package; const qual = receipt?.stages?.qualify;
1270
+ const sealed = pack?.ok ? (o.sealedFor ?? sealedFor)({ root, commit, platform }) : null;
1271
+ if (!pack?.ok || !qual?.ok || pack.platform !== platform || qual.manifestSha256 !== pack.manifestSha256 || !sealed || sealed.id !== pack.sealed || sealed.digest !== pack.manifestSha256) {
1272
+ o.err(`${tag} BLOCKED: stage needs this host's receipt for ${commit.slice(0, 12)} with package and qualify passed on ${platform} and the sealed set unchanged; run rightkit-gate --to qualify --platform ${platform} first`);
1273
+ return 1;
1274
+ }
1275
+ const releaseTag = `rightkit-staging-${commit}`;
1276
+ const work = fs.mkdtempSync(path.join(os.tmpdir(), "rightkit-stage-"));
1277
+ try {
1278
+ const receiptFile = path.join(work, `receipt-${platform}.json`);
1279
+ fs.writeFileSync(receiptFile, `${JSON.stringify(receipt, null, 2)}\n`);
1280
+ const archive = path.join(work, `sealed-${platform}.tar.gz`);
1281
+ const tar = o.spawn("tar", ["-czf", archive, "-C", root, path.join(".right-release", "sealed", sealed.id, platform)]);
1282
+ if (failed(tar)) { o.err(`${tag} could not pack ${sealed.id}/${platform}`); return 1; }
1283
+ const gh = (...a) => o.spawn("gh", a, { cwd: root });
1284
+ if (failed(gh("release", "view", releaseTag))) {
1285
+ const made = gh("release", "create", releaseTag, "--draft", "--target", commit, "--title", `staging ${commit.slice(0, 12)}`, "--notes", "Sealed and qualified sets for the trusted publisher; not a release.");
1286
+ if (failed(made)) { o.err(`${tag} could not create the draft ${releaseTag}: ${text(made).trim()}`); return 1; }
1287
+ }
1288
+ const up = gh("release", "upload", releaseTag, archive, receiptFile, "--clobber");
1289
+ if (failed(up)) { o.err(`${tag} could not upload to ${releaseTag}: ${text(up).trim()}`); return 1; }
1290
+ o.out(`${tag} staged ${sealed.id}/${platform} (manifest ${sealed.digest.slice(0, 12)}) in draft ${releaseTag}`);
1291
+ if (tier) {
1292
+ // The trusted publisher is RightKit's publish-app workflow: app repositories are private and run no Actions.
1293
+ const slug = text(gh("repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner")).trim();
1294
+ const appDir = path.relative(root, o.cwd).replaceAll("\\", "/") || ".";
1295
+ const run = gh("workflow", "run", "publish-app.yml", "-R", PUBLISHER_REPO, "-f", `app=${slug}`, "-f", `app_dir=${appDir}`, "-f", `source_revision=${commit}`, "-f", `tier=${tier}`, "-f", `platforms=${platform}`);
1296
+ if (!slug || failed(run)) { o.err(`${tag} staged, but could not dispatch ${PUBLISHER_REPO} publish-app.yml: ${text(run).trim()}`); return 1; }
1297
+ o.out(`${tag} dispatched ${PUBLISHER_REPO} publish-app.yml for ${slug}@${commit.slice(0, 12)} (${platform}, ${tier}); the publisher re-runs class A, verifies the receipt and the bytes, then uploads`);
1298
+ }
1299
+ return 0;
1300
+ } finally { fs.rmSync(work, { recursive: true, force: true }); }
1301
+ }
1302
+
1303
+ /**
1304
+ * rightkit-gate --to dev|test [-- <right-release dev|test arguments>]: the door for the local compiles that work on the uncommitted tree.
1305
+ * Class A runs over the working tree first (nothing compiles while it is red), then `right-release dev|test` runs under a live door
1306
+ * run, the only way it runs at all. The executor is the same as every other stage: the broker where there is one (its own admission
1307
+ * checks again), plain cargo on an unmanaged host, never around a managed host's broker.
1308
+ */
1309
+ async function runWorktreeCompile(o, args, root, git, io, to) {
1310
+ const tag = "rightkit-gate:";
1311
+ const passthrough = args.includes("--") ? args.slice(args.indexOf("--") + 1) : [];
1312
+ const commit = text(git("rev-parse", "HEAD")).trim();
1313
+ const tree = text(git("rev-parse", "HEAD^{tree}")).trim();
1314
+ const common = text(git("rev-parse", "--git-common-dir")).trim();
1315
+ if (!/^[0-9a-f]{40}$/.test(commit) || !common) { o.err(`${tag} cannot read HEAD and the git directory`); return 2; }
1316
+ const which = (cmd) => { const r = o.spawn(o.platform === "win32" ? "where" : "which", [cmd]); return failed(r) ? [] : text(r).split(/\r?\n/).map((l) => l.trim()).filter(Boolean); };
1317
+ const executor = (o.resolveExecutor ?? resolveExecutor)({ env: o.env, platform: o.platform, exists: o.exists, which, readFile: o.readFile });
1318
+ if (executor.kind === "refuse") { o.err(`${tag} BLOCKED: ${executor.problem}`); return 1; }
1319
+ o.out(`${tag} --to ${to} on the working tree of ${commit.slice(0, 12)}: check (class A) -> right-release ${to}; executor ${executor.kind}`);
1320
+ const checked = await (o.runCheck ?? (() => main(["check", "--worktree"], { ...io, cwd: root })))();
1321
+ if (checked !== 0) { o.err(`${tag} --to ${to} stopped at check: nothing compiled`); return 1; }
1322
+ const commonDir = path.resolve(root, common);
1323
+ const run = startRun({ commonDir, commit, tree, stage: to });
1324
+ try {
1325
+ const cli = fileURLToPath(new URL("../cli/right-release.mjs", import.meta.url));
1326
+ const r = (o.spawnInherit ?? ((cmd, a, opts) => spawnInherit(cmd, a, { ...opts, stdio: "inherit" })))(process.execPath, [cli, to, ...passthrough], { cwd: o.cwd, env: { ...o.env, ...run.env } });
1327
+ return r.status === 0 ? 0 : 1;
1328
+ } finally { endRun(run); }
1329
+ }
1330
+
1215
1331
  // ---- main ---------------------------------------------------------------------------------------------------
1216
1332
 
1217
1333
  /**
@@ -1224,7 +1340,9 @@ export async function main(argv, io = {}) {
1224
1340
  const o = {
1225
1341
  spawn: defaultSpawn, exists: fs.existsSync, readFile: (f) => fs.readFileSync(f, "utf8"), writeFile: fs.writeFileSync, mkdir: fs.mkdirSync, chmod: fs.chmodSync, readdir: fs.readdirSync,
1226
1342
  readStdin: () => { try { return process.stdin.isTTY ? "" : fs.readFileSync(0, "utf8"); } catch { return ""; } },
1227
- out: (m) => console.log(m), err: (m) => console.error(m), cwd: process.cwd(), env: process.env, platform: process.platform, ...io,
1343
+ out: (m) => console.log(m), err: (m) => console.error(m), cwd: process.cwd(), env: process.env, platform: process.platform,
1344
+ // Where this gate runs from (install-hook writes a path to it only when it is a plain path inside the repository: hookGatePlan).
1345
+ gateFile: fileURLToPath(import.meta.url), ...io,
1228
1346
  };
1229
1347
  const args = [...argv];
1230
1348
  const option = (name) => { const i = args.indexOf(name); return i < 0 ? undefined : args[i + 1]; };
@@ -1234,9 +1352,82 @@ export async function main(argv, io = {}) {
1234
1352
  }
1235
1353
  if (args.includes("--help") || args.includes("-h")) { o.out(USAGE); return 0; }
1236
1354
  const sub = args[0] && !args[0].startsWith("-") ? args[0] : null;
1237
- if (sub && sub !== "install-hook" && sub !== "check") { o.err(`rightkit-gate: unknown command ${JSON.stringify(sub)}\n${USAGE}`); return 2; }
1238
- // The app check has its own export and reads no gate state: it needs neither the base ref nor the pushed refs on stdin.
1239
- if (sub === "check") return (o.appCheck ?? (await import("../app-check.mjs")).main)(args.slice(1), { out: o.out, err: o.err, label: "check", cwd: o.cwd });
1355
+ if (sub && sub !== "install-hook" && sub !== "check" && sub !== "stage") { o.err(`rightkit-gate: unknown command ${JSON.stringify(sub)}\n${USAGE}`); return 2; }
1356
+ // `check` is the deterministic stage (Adrian, 2026-10-25: deterministic always first, one door): the app-check rules, then every
1357
+ // class A scan (lockfiles, cargo-deny, cargo-audit, secrets, licences, manifests, workflow lint, syntax...) over the whole tree of the
1358
+ // commit, never a diff. Nothing compiles. The RustRight broker runs exactly this before it admits any compile. `--list`,
1359
+ // `--print-baseline` and `--only` stay app-check-only views.
1360
+ if (sub === "check") {
1361
+ const appCheck = await (o.appCheck ?? (await import("../app-check.mjs")).main)(args.slice(1), { out: o.out, err: o.err, label: "check", cwd: o.cwd });
1362
+ if (args.includes("--list") || args.includes("--print-baseline") || args.includes("--only") || o.classA === false) {
1363
+ // A view of the app rules, never a class A verdict: say so, so no caller can take its exit 0 for a full check.
1364
+ // A green partial view exits PARTIAL_EXIT (3), never 0, so a caller that admits only exit 0 rejects it by status alone.
1365
+ if (args.includes("--list") || args.includes("--print-baseline")) return appCheck;
1366
+ o.err("rightkit-gate: PARTIAL: app rules only (--only); no class A scan ran, so this is not a class A result");
1367
+ return appCheck === 0 ? PARTIAL_EXIT : appCheck;
1368
+ }
1369
+ const repoArg = option("--repo");
1370
+ const checkCwd = repoArg ? path.resolve(o.cwd, repoArg) : o.cwd;
1371
+ const classA = await main(["--class-a", ...(args.includes("--worktree") ? ["--worktree"] : ["--commit", option("--commit") ?? "HEAD"])], { ...io, cwd: checkCwd });
1372
+ // RightKit checking itself: its own rules (line endings, consumer recipes, npm surface, version copies and the version feed, dispatcher
1373
+ // pins, consumer locks, generated docs, temp paths: scripts/preflight.mjs) and its exports' import safety are class A for the RightKit repository. They run only when this gate is that
1374
+ // repository's own in-tree copy, so a gate checking any other repository (the broker's pinned one included) never runs
1375
+ // that repository's code.
1376
+ let selfRules = 0;
1377
+ const top = o.spawn("git", ["rev-parse", "--show-toplevel"], { cwd: checkCwd });
1378
+ const root = failed(top) ? null : path.resolve(text(top).trim());
1379
+ const self = o.selfPath ?? fileURLToPath(import.meta.url);
1380
+ // RightKit's own scripts are the checked repository's code: they run only after the app rules and every class A scan passed,
1381
+ // never after a failure, so a red tree executes nothing of its own.
1382
+ // Compared as real, long paths (and case-blind on Windows): git prints the long form of an 8.3 short path such as RUNNER~1.
1383
+ const canon = (p) => { let c; try { c = fs.realpathSync.native(p); } catch { c = path.resolve(p); } return process.platform === "win32" ? c.toLowerCase() : c; };
1384
+ if (root && canon(self).startsWith(canon(root) + path.sep) && o.exists(path.join(root, "scripts", "preflight.mjs")) && appCheck === 0 && classA === 0) {
1385
+ const launch = o.spawnInherit ?? ((cmd, a, opts) => spawnInherit(cmd, a, { ...opts, stdio: "inherit" }));
1386
+ const selector = args.includes("--worktree") ? ["--worktree"] : ["--commit", option("--commit") ?? "HEAD"];
1387
+ const r = launch(process.execPath, [path.join(root, "scripts", "preflight.mjs"), "--repo", root, ...selector], { cwd: root, env: o.env });
1388
+ selfRules = r.status === 0 ? 0 : 1;
1389
+ // And its exports import cleanly (scripts/ci/exports-import-effects.mjs: every exported module imported in a fresh, permission-
1390
+ // limited process; an import that prints, exits or reads its argv fails), which publish.yml's scans job ran alone before. That
1391
+ // check probes the checkout's HEAD, so it runs only when HEAD is the requested commit (or the working tree was asked for);
1392
+ // any other commit is refused rather than proven by other bytes.
1393
+ const effects = path.join(root, "scripts", "ci", "exports-import-effects.mjs");
1394
+ if (selfRules === 0 && o.exists(effects)) {
1395
+ const requested = args.includes("--worktree") ? null : option("--commit") ?? "HEAD";
1396
+ const at = (rev) => { const v = o.spawn("git", ["rev-parse", "--verify", "--quiet", `${rev}^{commit}`], { cwd: root }); return failed(v) ? null : text(v).trim(); };
1397
+ // The probe exports HEAD, so a working tree whose tracked or untracked (unignored) files differ from HEAD is refused too.
1398
+ const dirty = () => { const st = o.spawn("git", ["status", "--porcelain", "--untracked-files=all"], { cwd: root }); return failed(st) || text(st).trim() !== ""; };
1399
+ if (requested !== null && at(requested) !== at("HEAD")) {
1400
+ o.err(`rightkit-gate: BLOCKED: the exports import check probes the checkout's HEAD (${String(at("HEAD")).slice(0, 12)}), not the requested ${requested}; check out that commit to check it`);
1401
+ selfRules = 1;
1402
+ } else if (requested === null && dirty()) {
1403
+ o.err("rightkit-gate: BLOCKED: the exports import check probes the checkout's HEAD, and the working tree differs from it; commit or stash the changes, or check --commit HEAD");
1404
+ selfRules = 1;
1405
+ } else {
1406
+ // Like CI's scans job: exports that resolve to build output are INCOMPLETE here and handed, through --report, to the
1407
+ // sealed-byte check that publication runs; without --report the probe exits 3 and a green tree could never pass.
1408
+ const common = o.spawn("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], { cwd: root });
1409
+ const report = path.join(failed(common) ? path.join(root, ".git") : text(common).trim(), "rightkit-gate", "import-effects", "report.json");
1410
+ const e = launch(process.execPath, [effects, "--report", report], { cwd: root, env: o.env });
1411
+ if (e.status !== 0) selfRules = 1;
1412
+ }
1413
+ }
1414
+ }
1415
+ // A RustRight broker's check names the tree it captured for the compile: the declared inputs there (ignored files the
1416
+ // commit does not hold) are judged too, as the bytes the compile will read.
1417
+ let inputs = 0;
1418
+ const captured = o.env.RIGHTKIT_COMPILE_INPUTS;
1419
+ if (captured) {
1420
+ const { judgeCompileInputs } = await import("../compile-inputs.mjs");
1421
+ const judged = judgeCompileInputs(path.resolve(captured));
1422
+ for (const i of judged.inputs) o.out(`rightkit-gate: compile input ${i.path}: ${i.files} file(s), sha256 ${i.sha256}`);
1423
+ for (const f of judged.findings) o.err(`rightkit-gate: compile input FAIL ${f}`);
1424
+ if (!judged.inputs.length && !judged.findings.length) o.out("rightkit-gate: no declared compile inputs in the captured tree");
1425
+ inputs = judged.findings.length ? 1 : 0;
1426
+ }
1427
+ return Math.max(appCheck, classA, selfRules, inputs);
1428
+ }
1429
+ const classAOnly = args.includes("--class-a");
1430
+ const classACommit = classAOnly && !args.includes("--worktree") ? (option("--commit") ?? "HEAD") : null;
1240
1431
  if (args.includes("--base") && (!option("--base") || option("--base").startsWith("--"))) { o.err("rightkit-gate: --base needs a ref"); return 2; }
1241
1432
 
1242
1433
  const top = o.spawn("git", ["rev-parse", "--show-toplevel"], { cwd: o.cwd });
@@ -1244,20 +1435,19 @@ export async function main(argv, io = {}) {
1244
1435
  const root = path.resolve(text(top).trim());
1245
1436
  const git = (...a) => o.spawn("git", a, { cwd: root });
1246
1437
  if (sub === "install-hook") return await installHook(o, root, args.includes("--force"));
1438
+ if (option("--to") !== undefined) return await runStages(o, args, root, git, io);
1439
+ if (sub === "stage") return stageForPublisher(o, args, root, git);
1247
1440
 
1248
1441
  const planOnly = args.includes("--plan");
1249
1442
  let base = option("--base") ?? "origin/main";
1250
1443
  const tag = "rightkit-gate:";
1251
1444
 
1252
1445
  // git pre-push: "<local ref> <local sha> <remote ref> <remote sha>" per pushed ref. A plan reads no stdin.
1253
- // `pushedRefs` is every remote ref named, deletions included: it decides whether the Rust gate may defer (rustDeferral). The fields are
1254
- // split on single ASCII spaces only (parsePushLines): a ref name may hold Unicode whitespace. A line that does not read as four fields
1446
+ // git's push lines are split on single ASCII spaces only (parsePushLines): a ref name may hold Unicode whitespace. A line that does not read as four fields
1255
1447
  // (git hands the local field over verbatim, so a rev expression such as `HEAD@{1 minute ago}` or `HEAD^{/one thing}` holds spaces)
1256
1448
  // refuses the push on every host and in every repository, never a line that disappears: its pushed commit could not be compared with
1257
1449
  // HEAD and its remote ref could not be judged. `pushedRemoteShas` are the remote commits being replaced.
1258
- const pushedRefs = []; const pushedRemoteShas = [];
1259
- const askedBase = base;
1260
- if (!planOnly) {
1450
+ if (!planOnly && !classAOnly) {
1261
1451
  const head = text(git("rev-parse", "HEAD")).trim();
1262
1452
  const parsed = parsePushLines(o.readStdin());
1263
1453
  if (parsed.malformed.length) {
@@ -1265,8 +1455,6 @@ export async function main(argv, io = {}) {
1265
1455
  return 1;
1266
1456
  }
1267
1457
  for (const { localSha, remoteRef, remoteSha } of parsed.lines) {
1268
- pushedRefs.push(remoteRef);
1269
- if (!ZERO.test(remoteSha)) pushedRemoteShas.push(remoteSha);
1270
1458
  if (ZERO.test(localSha)) continue; // a delete pushes no content
1271
1459
  if (localSha !== head) {
1272
1460
  o.err(`${tag} BLOCKED: pushing ${localSha.slice(0, 10)} to ${remoteRef}, but HEAD is ${head.slice(0, 10)}; broker scans read the checkout, so check the pushed commit out first (git checkout --detach ${localSha.slice(0, 10)}), push HEAD:<branch>, then return`);
@@ -1276,70 +1464,16 @@ export async function main(argv, io = {}) {
1276
1464
  }
1277
1465
  }
1278
1466
 
1279
- const tracked = (name) => {
1280
- const r = git("ls-files", "-z", "--", `:(glob)**/${name}`);
1281
- if (failed(r)) throw new Error(`git ls-files failed: ${why(r)}`);
1282
- return text(r).split("\0").filter(Boolean).map(posix);
1283
- };
1284
- const readLive = (f) => o.readFile(f);
1285
-
1286
- // Units come from the live checkout (cargo reads it); a dirty checkout then blocks below.
1287
- let rust; let npm; let npmRoots = []; let cargoCommand = ["rustright", "cargo"]; let deferral = null; let manifests = []; const unplaced = [];
1288
- try {
1289
- manifests = tracked("Cargo.toml");
1290
- if (manifests.length) {
1291
- cargoCommand = resolveCargoCommand(o);
1292
- if (!cargoCommand) {
1293
- // No broker and no shim. A pre-push of non-protected branches outside CI defers the Rust gate to host qualification; anything
1294
- // else refuses exactly as before. The cargo command is then only the text of the argv the deferred scans would have had.
1295
- // The default branch is named by the checkout, the base, the remote commits being replaced and origin/HEAD, never by the pushed commit alone.
1296
- const configured = defaultBranchNames({ git, readCheckout: () => o.readFile(path.join(root, ".rightgit.json")), revisions: [askedBase, ...pushedRemoteShas] });
1297
- const decision = rustDeferral({ refs: pushedRefs, env: o.env, protectedBranches: configured });
1298
- if (!decision.defer) {
1299
- o.err(`${tag} BLOCKED: neither \`rustright\` nor the managed cargo shim (cargo-guard.mjs) is first on PATH; plain cargo is never used`);
1300
- o.err(`${tag} not deferred to host qualification: ${decision.why}`);
1301
- return 1;
1302
- }
1303
- deferral = decision;
1304
- cargoCommand = ["rustright", "cargo"];
1305
- } else if (!o.exists(path.join(root, GRANT_FILE))) { o.err(`${tag} BLOCKED: no local compile grant (${GRANT_FILE}); the broker refuses cargo, so the Rust units cannot be discovered or scanned`); return 1; }
1306
- }
1307
- // Untracked (not ignored) manifests only: git does not descend into ignored directories, so this stays cheap. A member of a
1308
- // workspace that is untracked or ignored is dropped by discoverRustWorkspaces itself.
1309
- const untracked = () => {
1310
- const r = git("ls-files", "-z", "--others", "--exclude-standard", "--", ":(glob)**/Cargo.toml");
1311
- return failed(r) ? [] : text(r).split("\0").filter(Boolean).map(posix);
1312
- };
1313
- // ci/gate.config.json `fixtures` (optional): directories whose manifests are consumer or sample crates, gated through the
1314
- // consumer rows of the crates they exercise (CI treats them the same: scripts/ci/gate.mjs consumerRowInvocations), never as units.
1315
- let fixtures = [];
1316
- try { const f = JSON.parse(o.readFile(path.join(root, GATE_CONFIG))).fixtures; if (Array.isArray(f)) fixtures = f.filter((d) => typeof d === "string"); } catch { /* no config: no fixtures */ }
1317
- rust = discoverRustWorkspaces({
1318
- root, spawn: (c, a, opts) => o.spawn(c, a, opts), cargoCommand, tracked, untracked, fixtures, readFile: readLive,
1319
- ...(deferral ? { metadata: (dir) => { const read = manifestWorkspaceMetadata({ root, dir, tracked, readFile: readLive }); unplaced.push(...(read.unplaced ?? [])); return read; } } : {}),
1320
- });
1321
- npm = discoverNpmUnits({ root, tracked, readFile: readLive, exists: o.exists });
1322
- npmRoots = discoverPnpmRoots({ root, tracked, exists: o.exists });
1323
- } catch (e) { o.err(`${tag} BLOCKED: cannot discover the repository's units: ${e.message}`); return 1; }
1324
- for (const k of rust.skipped) o.out(`${tag} note: ${k.manifest} is skipped, not scanned: ${k.why}`);
1325
-
1326
- // Broker scans (cargo, pnpm) read the live checkout, so it must equal HEAD where they read it: an uncommitted fix could
1327
- // otherwise hide a committed failure. Any change or untracked file under those paths blocks the gate.
1328
- if (!planOnly) {
1329
- // The install graph and linter configuration of a pnpm root: the repository root's, and those of every other root (its units' directories
1330
- // hold only the units, and a workspace member's lock sits above it).
1331
- const npmRootFiles = ["package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml", ".npmrc", ...ESLINT_CONFIG_FILES, ...BIOME_CONFIG_FILES];
1332
- const inputs = [...new Set([...rust.units.map((u) => u.dir), ...npm.map((u) => u.dir), ...rust.workspaces.map((w) => w.cargoDir), "ci", "scripts", "vendor", "Cargo.toml", "Cargo.lock", ".cargo", "rust-toolchain.toml", "rust-toolchain",
1333
- "deny.toml", "clippy.toml", "rustfmt.toml", ...npmRootFiles, ...npmRoots.filter((r) => r !== ".").flatMap((r) => npmRootFiles.map((f) => `${r}/${f}`))])];
1334
- const dirty = git("status", "--porcelain", "--untracked-files=all", "--", ...inputs);
1335
- const dirtyFiles = failed(dirty) ? [`git status failed: ${why(dirty)}`]
1336
- : text(dirty).split(/\r?\n/).filter(Boolean).filter((l) => !l.includes("node_modules") && !l.includes(GRANT_FILE));
1337
- if (dirtyFiles.length) {
1338
- o.err(`${tag} BLOCKED: the checkout differs from HEAD where broker scans read it; commit or stash first:`);
1339
- for (const l of dirtyFiles.slice(0, 20)) o.err(` ${l}`);
1340
- return 1;
1341
- }
1342
- }
1467
+ // ---- one door: the executor, then the exact snapshot every scan plans from and reads -----------------------------------
1468
+ // The executor is chosen here, never by the caller (Adrian, 2026-10-25): the RustRight broker where this machine has it, a refusal
1469
+ // on a broker-managed machine whose broker cannot be reached, direct cargo on any other host (a cloud container, a CI runner).
1470
+ const which = (cmd) => { const r = o.spawn(o.platform === "win32" ? "where" : "which", [cmd]); return failed(r) ? [] : text(r).split(/\r?\n/).map((l) => l.trim()).filter(Boolean); };
1471
+ const executor = (o.resolveExecutor ?? resolveExecutor)({ env: o.env, platform: o.platform, exists: o.exists, which, readFile: o.readFile });
1472
+ if (executor.kind === "refuse") { o.err(`${tag} BLOCKED: ${executor.problem}`); return 1; }
1473
+ const cargoCommand = executor.cargo;
1474
+ // The snapshot: the commit or tree being checked (check --commit), HEAD for the gate, or the working tree (check --worktree, --plan).
1475
+ const allFilesMode = classAOnly || args.includes("--all");
1476
+ const snapshot = planOnly ? null : classAOnly ? classACommit : "HEAD";
1343
1477
 
1344
1478
  const ac = o.abort ?? new AbortController();
1345
1479
  let exportRoot = null;
@@ -1347,16 +1481,76 @@ export async function main(argv, io = {}) {
1347
1481
  const signals = [];
1348
1482
  try {
1349
1483
  let repo = root;
1350
- if (!planOnly) {
1351
- exportRoot = fs.mkdtempSync(path.join(o.env.RK_GATE_TMPDIR || os.tmpdir(), "rk-gate-"));
1484
+ if (snapshot) {
1485
+ exportRoot = fs.mkdtempSync(path.join(exportTmpRoot({ env: o.env, platform: o.platform, exists: o.exists }), "rk-gate-"));
1352
1486
  repo = path.join(exportRoot, "repo");
1353
- const made = o.spawn("git", ["archive", "--format=tar", "--prefix=repo/", "-o", path.join(exportRoot, "head.tar"), "HEAD"], { cwd: root });
1354
- const x = failed(made) ? made : o.spawn("tar", ["-xf", "head.tar"], { cwd: exportRoot });
1355
- if (failed(made) || failed(x)) { o.err(`${tag} cannot export HEAD: ${why(made) || why(x)}`); return 2; }
1487
+ // Every file of the commit or tree: read-tree into a private index, then checkout-index. `git archive` would leave out files
1488
+ // marked export-ignore in .gitattributes, so they would never be scanned; this export has no such exclusion.
1489
+ const indexEnv = { ...o.env, GIT_INDEX_FILE: path.join(exportRoot, "index") };
1490
+ const made = o.spawn("git", ["read-tree", snapshot], { cwd: root, env: indexEnv });
1491
+ // git builds each path as <prefix><index path> with "/" separators and creates only the directories below the prefix, so the
1492
+ // export root must exist and the prefix use "/": a "C:\\...\\repo\\" prefix made checkout-index fail on Windows
1493
+ // (publish run 40282215043: "cannot create directory at ...\\repo\\.github").
1494
+ fs.mkdirSync(repo, { recursive: true });
1495
+ const x = failed(made) ? made : o.spawn("git", ["checkout-index", "--all", "--force", `--prefix=${repo.split(path.sep).join("/")}/`], { cwd: root, env: indexEnv });
1496
+ if (failed(made) || failed(x)) { o.err(`${tag} cannot export ${snapshot}: ${why(made) || why(x)}`); return 2; }
1497
+ // rustright binds the tracked .rightkit-local-build.json grant to the origin URL in .git/config (rustright-policy local_build.rs),
1498
+ // and the export has no .git, so cargo metadata in it was refused on every broker host and no push passed the hook (rightkit#1215).
1499
+ // A config-only .git carries exactly the source checkout's origin and is not a repository to git (no HEAD, no objects), so nothing
1500
+ // can run git against the export. No origin (a checkout without a remote): nothing is written and rustright decides as before.
1501
+ const origin = o.spawn("git", ["config", "--get", "remote.origin.url"], { cwd: root });
1502
+ const originUrl = failed(origin) ? "" : text(origin).trim();
1503
+ if (originUrl) {
1504
+ o.mkdir(path.join(repo, ".git"), { recursive: true });
1505
+ o.writeFile(path.join(repo, ".git", "config"), `[remote "origin"]\n\turl = ${originUrl}\n`);
1506
+ }
1507
+ }
1508
+ // Every file of the snapshot, once: units, manifests, lockfiles and the changed set are all read from what is scanned, never from
1509
+ // the live checkout (a live edit cannot change what gets planned).
1510
+ const listed = snapshot ? git("ls-tree", "-r", "--name-only", "-z", snapshot) : git("ls-files", "-z", "--cached", ...(classAOnly ? ["--others", "--exclude-standard"] : []));
1511
+ if (failed(listed)) { o.err(`${tag} cannot list the files of ${snapshot ?? "the working tree"}: ${why(listed)}`); return 2; }
1512
+ const files = text(listed).split("\0").filter(Boolean).map(posix);
1513
+ const tracked = (name) => files.filter((f) => f === name || f.endsWith(`/${name}`));
1514
+ const readSnap = (f) => o.readFile(f);
1515
+
1516
+ let rust; let npm; let npmRoots = []; let manifests = [];
1517
+ try {
1518
+ manifests = tracked("Cargo.toml");
1519
+ // ci/gate.config.json `fixtures` (optional): directories whose manifests are consumer or sample crates, gated through the
1520
+ // consumer rows of the crates they exercise (CI treats them the same), never as units.
1521
+ let fixtures = [];
1522
+ try { const f = JSON.parse(o.readFile(path.join(repo, GATE_CONFIG))).fixtures; if (Array.isArray(f)) fixtures = f.filter((d) => typeof d === "string"); } catch { /* no config: no fixtures */ }
1523
+ // A snapshot has nothing untracked; the working tree (--plan, check --worktree) names its untracked manifests as skipped.
1524
+ const untracked = () => {
1525
+ if (snapshot) return [];
1526
+ const r = git("ls-files", "-z", "--others", "--exclude-standard", "--", ":(glob)**/Cargo.toml");
1527
+ return failed(r) ? [] : text(r).split("\0").filter(Boolean).map(posix);
1528
+ };
1529
+ rust = discoverRustWorkspaces({ root: repo, spawn: (c, a, opts) => o.spawn(c, a, opts), cargoCommand, tracked, untracked, fixtures, readFile: readSnap });
1530
+ npm = discoverNpmUnits({ root: repo, tracked, readFile: readSnap, exists: o.exists });
1531
+ npmRoots = discoverPnpmRoots({ root: repo, tracked, exists: o.exists });
1532
+ } catch (e) { o.err(`${tag} BLOCKED: cannot discover the repository's units: ${e.message}`); return 1; }
1533
+ for (const k of rust.skipped) o.out(`${tag} note: ${k.manifest} is skipped, not scanned: ${k.why}`);
1534
+
1535
+ // Class B scans (compile, lint, tests) run in the live checkout, so it must equal HEAD where they read it: an uncommitted fix could
1536
+ // otherwise hide a committed failure. Class A reads only the snapshot.
1537
+ if (!planOnly && !classAOnly) {
1538
+ const npmRootFiles = ["package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml", ".npmrc", ...ESLINT_CONFIG_FILES, ...BIOME_CONFIG_FILES];
1539
+ const inputs = [...new Set([...rust.units.map((u) => u.dir), ...npm.map((u) => u.dir), ...rust.workspaces.map((w) => w.cargoDir), "ci", "scripts", "vendor", "Cargo.toml", "Cargo.lock", ".cargo", "rust-toolchain.toml", "rust-toolchain",
1540
+ "deny.toml", "clippy.toml", "rustfmt.toml", ...npmRootFiles, ...npmRoots.filter((r) => r !== ".").flatMap((r) => npmRootFiles.map((f) => `${r}/${f}`))])];
1541
+ const dirty = git("status", "--porcelain", "--untracked-files=all", "--", ...inputs);
1542
+ const dirtyFiles = failed(dirty) ? [`git status failed: ${why(dirty)}`] : text(dirty).split(/\r?\n/).filter(Boolean).filter((l) => !l.includes("node_modules"));
1543
+ if (dirtyFiles.length) {
1544
+ o.err(`${tag} BLOCKED: the checkout differs from HEAD where compile scans read it; commit or stash first:`);
1545
+ for (const l of dirtyFiles.slice(0, 20)) o.err(` ${l}`);
1546
+ return 1;
1547
+ }
1356
1548
  }
1357
- const diff = git("diff", "--name-only", "--diff-filter=ACMR", `${base}...HEAD`);
1358
- if (failed(diff)) { o.err(`${tag} cannot diff against ${base}: ${why(diff)}; pass --base <ref>`); return 2; }
1359
- const changed = [...new Set(text(diff).split(/\r?\n/).map((f) => f.trim()).filter(Boolean))].filter((f) => o.exists(path.join(repo, f)));
1549
+
1550
+ const diff = allFilesMode ? null : git("diff", "--name-only", "--diff-filter=ACMR", `${base}...HEAD`);
1551
+ if (diff && failed(diff)) { o.err(`${tag} cannot diff against ${base}: ${why(diff)}; pass --base <ref> or --all`); return 2; }
1552
+ const changed = allFilesMode ? files : [...new Set(text(diff).split(/\r?\n/).map((f) => f.trim()).filter(Boolean))].filter((f) => o.exists(path.join(repo, f)));
1553
+ if (allFilesMode) base = snapshot ?? "the working tree";
1360
1554
 
1361
1555
  let crateFeatures = {}; let packageFeatures = {};
1362
1556
  try { const gateConfig = JSON.parse(o.readFile(path.join(repo, "ci/gate.config.json"))); crateFeatures = gateConfig.rust ?? {}; packageFeatures = gateConfig.npm ?? {}; } catch { /* no config: default feature sets */ }
@@ -1380,31 +1574,18 @@ export async function main(argv, io = {}) {
1380
1574
  const rustScope = restricted ? scopeNames : null;
1381
1575
 
1382
1576
  const fullPlan = planScans({
1383
- changed, contentRoot: repo, liveRoot: root, includeTests: true, rustScope, cargoCommand,
1577
+ // Class A of a commit or tree reads only that snapshot: every scan, cargo's included, runs in the export, never in the live
1578
+ // checkout (which may hold other bytes). Compiles (class B) use the live checkout, whose tree the dirty check pinned to HEAD.
1579
+ changed, contentRoot: repo, liveRoot: classAOnly && snapshot ? repo : root, includeTests: true, rustScope, cargoCommand,
1384
1580
  // The quiet tests (port, pipe, desktop and wall-clock bound) are proven by the CI shards, one crate per runner, unless
1385
1581
  // RK_GATE_QUIET_TESTS=local asks for the serial quiet lane here (no CI, or a CI outage).
1386
1582
  quietTests: o.env.RK_GATE_QUIET_TESTS === "local" ? "local" : "ci", packageFeatures,
1387
1583
  rustUnits: rust.units, npmUnits: npm, npmRoots, cargoDir: ".", rustLocks, crateFeatures,
1388
- localCompile: deferral ? true : o.exists(path.join(root, GRANT_FILE)),
1584
+ // The broker (or, on a direct host, the gate itself) admits compiles; no separate grant file decides it here.
1585
+ localCompile: true,
1389
1586
  exists: o.exists, readFile: o.readFile, platform: o.platform,
1390
1587
  });
1391
1588
  let plan = args.includes("--pre-push") ? prePushPlan(fullPlan) : fullPlan;
1392
- // Deferred Rust gate: every scan that goes through the broker is taken out of the plan and named; rustfmt too when this host has
1393
- // none (it is a Rust-toolchain scan, not a content scan, on such a host). Every other scan stays exactly as planned.
1394
- let notRun = []; let deferNote = null; let npmCalls = [];
1395
- if (deferral) {
1396
- const rustfmt = (o.findRustfmt ?? usableRustfmt)(o);
1397
- const dropped = plan.scans.filter((s) => s.tool === "rustright" || (s.tool === "rustfmt" && !rustfmt));
1398
- // An npm test scan whose script runs `rustright` or `cargo` through its package script chain (planScans marks it `brokerCall`: ViewRight's
1399
- // test:e2e is `rustright storage run ... -- right-release test`) cannot run on this host either. It goes the way of the Rust scans, named
1400
- // with its reason and never passed. On a host with the broker, in CI, on a protected ref or with the opt-out there is no deferral and it runs.
1401
- const npmDropped = plan.scans.filter((s) => s.brokerCall && s.tool === "pnpm" && !dropped.includes(s));
1402
- npmCalls = npmDropped.map((s) => ({ id: s.id, ...s.brokerCall }));
1403
- notRun = [...dropped, ...npmDropped].map((s) => s.id);
1404
- plan = withoutScans(plan, [...dropped, ...npmDropped]);
1405
- deferNote = `no RustRight broker on this host: ${dropped.length} Rust scan(s) are deferred to host qualification, not run and not passed${rustfmt ? "" : " (rustfmt is not usable on this host either)"}`
1406
- + (npmDropped.length ? `; ${npmDropped.length} npm test scan(s) whose script runs the broker or cargo are deferred with them, not run and not passed: ${npmDropped.map((s) => s.id).join(", ")}` : "");
1407
- }
1408
1589
  const jobs = Math.max(1, Number(o.env.RK_GATE_JOBS ?? 4) || 4);
1409
1590
  const testJobs = Math.max(1, Number(o.env.RK_GATE_TEST_JOBS ?? jobs) || jobs);
1410
1591
 
@@ -1419,7 +1600,7 @@ export async function main(argv, io = {}) {
1419
1600
  }
1420
1601
 
1421
1602
  for (const n of plan.notes) o.out(`${tag} note: ${n}`);
1422
- if (deferNote) o.out(`${tag} note: ${deferNote}`);
1603
+ o.out(`${tag} executor: ${executor.kind}${executor.kind === "broker" ? ` (${cargoCommand.join(" ")})` : " (no broker on this host: cargo runs directly, through the same gates)"}`);
1423
1604
  const quietCount = plan.scans.filter((s) => s.quiet === true).length;
1424
1605
  o.out(`${tag} ${plan.scans.length} scan(s) for ${changed.length} changed file(s) vs ${base.slice(0, 12)}; compile scans ${jobs} at a time, tests ${testJobs} at a time${quietCount ? `, ${quietCount} quiet test scan(s) last under the broker's quiet lease` : ""}`);
1425
1606
  let signalCode = 0;
@@ -1434,7 +1615,7 @@ export async function main(argv, io = {}) {
1434
1615
  }
1435
1616
  // The result cache keys on the gated commit's object ids; the pre-push check above made HEAD the pushed commit.
1436
1617
  let cache = null;
1437
- if (!args.includes("--no-cache") && o.env.RK_GATE_CACHE !== "0") {
1618
+ if (!classAOnly && !args.includes("--no-cache") && o.env.RK_GATE_CACHE !== "0") {
1438
1619
  try {
1439
1620
  const headSha = text(git("rev-parse", "HEAD")).trim();
1440
1621
  const treeId = text(git("rev-parse", `${headSha}^{tree}`)).trim();
@@ -1462,7 +1643,7 @@ export async function main(argv, io = {}) {
1462
1643
  } catch { cache = null; }
1463
1644
  }
1464
1645
  const scanResult = await (o.runScans ?? runScans)(plan, {
1465
- classes: ["A", "B"], jobs, testJobs, signal: ac.signal, ...(cache ? { cache } : {}),
1646
+ classes: classAOnly ? ["A"] : ["A", "B"], jobs, testJobs, signal: ac.signal, ...(cache ? { cache } : {}),
1466
1647
  quietLane: quietLeaseLane(o, { tag, reason: `rightkit-gate-quiet-tests@${text(git("rev-parse", "--short", "HEAD")).trim()}` }),
1467
1648
  onResult: (row, p) => o.out(`${tag} [${p.done}/${p.total}] ${(row.cached ? "cached" : row.status).padEnd(7)} ${row.id} (${p.seconds}s)${row.status === "fail" ? ` :: ${String(row.detail ?? "").slice(0, 300)}` : ""}`),
1468
1649
  });
@@ -1480,13 +1661,11 @@ export async function main(argv, io = {}) {
1480
1661
  if (failures.length) {
1481
1662
  o.err(`${tag} BLOCKED (${failures.length})`);
1482
1663
  for (const f of failures) o.err(` - ${f}`);
1483
- o.err("Fix and push again. Never bypass with --no-verify.");
1664
+ o.err(classAOnly ? "Class A is red: nothing compiles until it is green." : "Fix and push again. Never bypass with --no-verify.");
1484
1665
  return 1;
1485
1666
  }
1486
- if (deferral) {
1487
- o.out(`${tag} the scans that could run here passed (${changed.length} changed file(s) vs ${base}${scanResult.cacheHits.length ? `; ${scanResult.cacheHits.length} reused from the result cache` : ""})`);
1488
- const unmatched = [...new Set([...unplaced, ...unmatchedManifests({ root, manifests, rust, readFile: readLive })])].sort();
1489
- for (const line of deferredBlock({ units: rust.units.map((u) => u.name), notRun, refs: pushedRefs, manifests: manifests.length, unmatched, npmCalls, tag })) o.err(line);
1667
+ if (classAOnly) {
1668
+ o.out(`${tag} class A pass: ${scanResult.scans.length} deterministic scan(s) over ${changed.length} file(s) of ${base}`);
1490
1669
  return 0;
1491
1670
  }
1492
1671
  o.out(`${tag} pass (${changed.length} changed file(s) vs ${base}); ${deferrals.length} scan(s) deferred, not passed${scanResult.cacheHits.length ? `; ${scanResult.cacheHits.length} reused from the result cache` : ""}`);