@rightkit/release 0.6.10 → 0.6.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +7 -5
  2. package/admission-files.mjs +3 -0
  3. package/admission.mjs +2 -2
  4. package/advisory-exceptions.mjs +41 -5
  5. package/android-play-upload.mjs +16 -15
  6. package/app-check.mjs +13 -9
  7. package/bin/rightkit-gate.mjs +772 -355
  8. package/broker-pins.mjs +152 -0
  9. package/build-release.mjs +80 -27
  10. package/cargo-ledger.mjs +62 -0
  11. package/cargo-target-shim.mjs +98 -26
  12. package/cli/right-release.mjs +28 -4
  13. package/clippy-equivalence.mjs +202 -0
  14. package/clippy-route.mjs +543 -0
  15. package/committed-source.mjs +39 -5
  16. package/crate-workspaces.mjs +87 -0
  17. package/create-mac-updater.mjs +14 -2
  18. package/deterministic-scans.mjs +196 -21
  19. package/development.mjs +13 -4
  20. package/direct-receipt.mjs +135 -0
  21. package/door-policy.mjs +10 -0
  22. package/door.mjs +6 -6
  23. package/freshness-census.mjs +257 -0
  24. package/gate-stages.mjs +76 -10
  25. package/hook-retirement.mjs +65 -0
  26. package/init-command.mjs +1 -1
  27. package/inventory-check.mjs +11 -8
  28. package/ios-upload.mjs +20 -15
  29. package/local-publish.mjs +77 -41
  30. package/local-qualify.mjs +5 -3
  31. package/local-runtime.mjs +5 -4
  32. package/mac-package-recipe.mjs +4 -1
  33. package/mirror-root-artifact.mjs +18 -5
  34. package/ownership-class-rules.mjs +1 -1
  35. package/ownership-evidence.mjs +37 -13
  36. package/ownership-hooks.mjs +20 -59
  37. package/ownership-lockfiles.mjs +14 -1
  38. package/ownership-policy-bundle.mjs +3 -2
  39. package/ownership-staged.mjs +2 -1
  40. package/ownership.mjs +24 -0
  41. package/package-recipe-targets.mjs +35 -6
  42. package/package.json +1 -1
  43. package/pipeline-admission.mjs +4 -4
  44. package/preflight.mjs +27 -4
  45. package/publish-cargo.mjs +4 -3
  46. package/recipe-build.mjs +38 -14
  47. package/release-artifact-path.mjs +341 -0
  48. package/release-invocation.mjs +8 -1
  49. package/release-qa-command.mjs +25 -9
  50. package/release-source-brand.mjs +17 -0
  51. package/release-source-identity.mjs +171 -0
  52. package/release.mjs +44 -17
  53. package/rightkit-versions.json +63 -42
  54. package/runtime-budgets.mjs +6 -2
  55. package/scan-digest.mjs +71 -5
  56. package/sign-updater.mjs +6 -3
  57. package/snapshot-broker.mjs +172 -0
  58. package/snapshot-check.mjs +12 -6
  59. package/snapshot-request.mjs +207 -5
  60. package/snapshot.mjs +123 -10
  61. package/source-split.mjs +73 -0
  62. package/stage-commands.mjs +2 -2
  63. package/stage-ledger.mjs +204 -0
  64. package/stage-sidecar-command.mjs +5 -1
  65. package/stage-sidecar.mjs +100 -20
  66. package/swap-helper-bundle.mjs +5 -2
  67. package/unit-gate.mjs +45 -3
  68. package/web-build.mjs +6 -4
  69. package/win-package-recipe.mjs +20 -7
@@ -1,8 +1,7 @@
1
1
  #!/usr/bin/env node
2
- // rightkit-gate: the mandatory local deterministic gate for any repository that uses RightKit (Adrian 2026-10-22:
3
- // "local deterministic checks ... mandatory for all builds"). Runs from the pre-push hook that `rightkit-gate install-hook`
4
- // writes, so it never depends on an agent remembering. Every applicable scan for the pushed change runs through the one
5
- // scan module (../deterministic-scans.mjs); Rust units come from `cargo metadata` (one run per Cargo workspace), npm units from
2
+ // rightkit-gate: the local deterministic gate for any repository that uses RightKit. The gate is for building: commit and push
3
+ // run no gate and no local hook check (docs/GATE-CONTRACT.md), and `rightkit-gate install-hook` writes no git hook. Every
4
+ // applicable scan for the gated change runs through the one scan module (../deterministic-scans.mjs); Rust units come from `cargo metadata` (one run per Cargo workspace), npm units from
6
5
  // the pnpm roots' workspace files, so the layout is the repository's own. CI still compiles, lints and tests everything.
7
6
  //
8
7
  // Layout it covers (nothing is partly covered silently):
@@ -10,7 +9,9 @@
10
9
  // workspace (`cargo metadata --no-deps --locked` there, through the broker). A unit of a workspace below the repository root is
11
10
  // named `<cargoDir>:<crate>`; its scans run with that directory as cwd, its reverse dependents and cache keys come from that
12
11
  // workspace's own Cargo.lock, and ci/gate.config.json `rust` may key its feature sets by `<cargoDir>:<crate>` or by the plain crate
13
- // name. A tracked Cargo.toml that is not a member and cannot stand alone, a member that is untracked or ignored, and an untracked
12
+ // name. One workspace and lock per crate (RightKit itself, rightkit#1285): a crate whose workspace is its own crates/<crate> directory keeps
13
+ // its plain name and unsuffixed scan ids; a selected crate also selects every crate that path-depends on it, across workspaces, read from
14
+ // the manifests (packages/release/crate-workspaces.mjs); a vendored source a crate patches in selects that crate. A tracked Cargo.toml that is not a member and cannot stand alone, a member that is untracked or ignored, and an untracked
14
15
  // Cargo.toml are skipped with a note on the first lines of the run, never silently.
15
16
  // - npm: every pnpm root (discoverPnpmRoots: the repository root, and any tracked directory with a pnpm-lock.yaml or pnpm-workspace.yaml
16
17
  // beside a package.json, outside node_modules) has its own units: the pnpm-workspace.yaml globs over tracked package.json files; when that
@@ -34,23 +35,20 @@
34
35
  // plain cargo for metadata/deny/audit only. The RustRight broker runs this before it admits
35
36
  // any compile. --list/--print-baseline/--only show the app check alone. Exit 0 pass,
36
37
  // 1 findings, 2 could not run
37
- // rightkit-gate install-hook [--force] write .githooks/pre-push and set core.hooksPath. core.hooksPath hides .git/hooks, so the
38
- // pre-push hook runs the repository's own .git/hooks/pre-push first, then `rightkit-gate
39
- // check --commit HEAD`, then the gate; every other hook present in .git/hooks gets a
40
- // .githooks/<name> trampoline that runs it; the hook finds the gate binary in the root
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
44
- // installed it also writes the advisory ownership pre-commit step (`right-release hooks install`
45
- // is an alias of this command)
38
+ // rightkit-gate install-hook retire the git hooks an earlier release wrote and add the agent hook: it removes
39
+ // .githooks/pre-push, the .githooks/<name> forwarders and the advisory ownership pre-commit
40
+ // hook, each only when its own header or marker says RightKit wrote it (a foreign hook is never
41
+ // touched; pre-commit.rightkit-foreign goes back to pre-commit), unsets a local core.hooksPath
42
+ // of `.githooks` once nothing foreign remains there, and registers the one-door agent hook in
43
+ // .claude/settings.json. It writes no pre-push, pre-commit or other git hook
44
+ // (`right-release hooks install` is an alias of this command; --force is ignored)
46
45
  //
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
48
- // read the live checkout), and the remote's current commit is the diff base. To push an earlier commit of the local stack,
49
- // check it out first (a detached HEAD is fine), push HEAD:<branch>, return.
46
+ // Git ref lines on stdin (the form git hands a pre-push hook; nothing here installs one) are still read when given: the commit
47
+ // pushed must be the checked-out HEAD (broker scans read the live checkout), and the remote's current commit is the diff base.
50
48
  // The executor is chosen by the gate, never the caller (gate-executor.mjs resolveExecutor): the RustRight broker where this machine
51
49
  // has it (`rustright` on PATH, or its managed cargo shim), a refusal on a broker-managed machine whose broker is unreachable, and
52
50
  // 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.
51
+ // the compile scans); an unmanaged host is never told to find a broker.
54
52
  // ci/gate.config.json `rust` (optional) sets per-crate feature sets, exactly as CI's gate reads it; `npm.<unit>` sets `quietTests` and
55
53
  // `requiresBroker: { "reason": "..." }` (a test the package scripts do not show reaching the broker: node -e, node scripts, sh -c, pnpm --dir).
56
54
  // Result cache: a passing class B scan is reused only when its scan id, command, tool version, environment and input digest
@@ -59,7 +57,8 @@
59
57
  // rust.<crate>.testInputs lists the paths they read, for npm, node and docs scans, and whenever a reference cannot be resolved
60
58
  // statically) are all identical. Stored in <git common dir>/rightkit-gate-cache; a hit prints
61
59
  // `cached` and is listed in the result's cacheHits. Failures, deferrals and class A scans are never cached.
62
- // RK_GATE_JOBS (default 4) sets class A scan concurrency; class B runs one scan at a time per checkout.
60
+ // RK_GATE_JOBS (default 4) sets class A scan concurrency. RK_GATE_CLASS_B_JOBS (default 3, the Mac broker's build capacity; the
61
+ // broker queues beyond its own) sets how many components' class B scans run at once; one component's scans stay in order (rightkit#1285).
63
62
  // A crate whose ci/gate.config.json entry says `quietTests: true` has its test scans run last,
64
63
  // one at a time, under the RustRight quiet lease (`rustright quiet acquire`, RUSTRIGHT_QUIET_LEASE in their environment).
65
64
  // SIGINT/SIGTERM stop scheduling, kill every running scan's process tree and exit non-zero listing the scans not run.
@@ -68,14 +67,17 @@ import { createHash, randomUUID } from "node:crypto";
68
67
  import { resolveManagedRoots } from "../managed-roots.mjs";
69
68
  import os from "node:os";
70
69
  import path from "node:path";
70
+ import { FOREIGN_PRE_COMMIT, retireHooksIn } from "../hook-retirement.mjs";
71
71
  import { fileURLToPath } from "node:url";
72
- 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";
72
+ import { BIOME_CONFIG_FILES, ESLINT_CONFIG_FILES, WORKSPACE_RUST_FILE, ancestorConfigChanged, ancestorDirsOf, canonicalJson, defaultSpawn, defaultSpawnAsync, digestScanSet, fileScanCache, findRustfmt, metadataArgs, planScans, runScans, scanDeferrals, scanFailures } from "../deterministic-scans.mjs";
73
73
  import { changedLockNames, parseLock, reach } from "../lock-graph.mjs";
74
74
  import { resolveExecutor } from "../gate-executor.mjs";
75
75
  import { directPlatform } from "../managed-cargo.mjs";
76
- import { bindingOf, endRun, readReceipt, RECEIPT_SCHEMA, receiptKey, sameBinding, sameSource, sealedFor, stagesTo, startRun, STAGES, writeReceipt } from "../gate-stages.mjs";
76
+ import { bindingOf, endRun, readReceipt, RECEIPT_SCHEMA, receiptKey, sameBinding, sameSource, sealedFor, stagesTo, startRun, STAGES, STAGE_SOURCES, writeReceipt } from "../gate-stages.mjs";
77
77
  import { spawnSync as spawnInherit } from "../background-process.mjs";
78
78
  import { readDiskPolicy, scratchRoots } from "../disk-policy.mjs";
79
+ import { buildDirectReceipt, directVerdict, resolveTargetDirectory, verifyDirectReceipt, writeDirectDiagnostics } from "../direct-receipt.mjs";
80
+ import { createFreshnessCensus } from "../freshness-census.mjs";
79
81
 
80
82
  const ZERO = /^0+$/;
81
83
  const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
@@ -89,22 +91,10 @@ export const PARTIAL_EXIT = 3;
89
91
  /** The platforms a door run can package: desktop (signed, sealed, installed QA, publisher upload) and mobile (signed, sealed, store test track). */
90
92
  export const PLATFORMS = Object.freeze(["mac", "win", "ios", "android"]);
91
93
  export const MOBILE_PLATFORMS = Object.freeze(new Set(["ios", "android"]));
92
- const USAGE = `usage: rightkit-gate --to check|compile|package|qualify|release [--platform mac|win|ios|android] [--tier patch|update] [--receipt-in <file>] [--receipt-out <file>] | rightkit-gate --to check|compile|package|qualify|release --unit npm:@rightkit/<name>|cargo:rightkit-<name> [--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 env-digest | rightkit-gate snapshot [--commit <rev>] [--verify] [--json] | rightkit-gate live-scans --repo <root> | rightkit-gate env-digest [--snapshot <dir> --commit <sha>] | rightkit-gate check [--repo <dir>] [--commit <rev> | --worktree] [--rule-tier compile|release] [--only <rule,...>] [--list] [--print-baseline] [--baseline <file>] | rightkit-gate check --commit <sha> --full-a --snapshot <dir> | --version`;
94
+ const USAGE = `usage: rightkit-gate --to check|compile|package|qualify|release [--platform mac|win|ios|android] [--tier patch|update] [--receipt-in <file>] [--receipt-out <file>] | rightkit-gate --to check|compile|package|qualify|release --unit npm:@rightkit/<name>|cargo:rightkit-<name> [--receipt-out <file>] | rightkit-gate --to dev|test [--commit <rev>] [-- <right-release args>] | rightkit-gate stage --platform mac|win [--tier patch|update] | rightkit-gate [--base <ref>] [--plan] [--no-cache] | rightkit-gate install-hook | rightkit-gate env-digest | rightkit-gate snapshot [--commit <rev>] [--verify [--env <sha256>]] [--json] | rightkit-gate live-scans --repo <root> | rightkit-gate clippy [<cargo clippy args>] | rightkit-gate env-digest [--snapshot <dir> --commit <sha>] | rightkit-gate check [--repo <dir>] [--commit <rev> | --worktree] [--rule-tier compile|release] [--only <rule,...>] [--list] [--print-baseline] [--baseline <file>] | rightkit-gate check --commit <sha> --full-a --snapshot <dir> | --version`;
93
95
 
94
96
  // ---- pure helpers (exported for tests) ----------------------------------------------------------------------
95
97
 
96
- /** Repository pre-push policy only. Full gates & publication qualification retain native checks. */
97
- export function prePushPlan(plan) {
98
- const scans = plan.scans.filter((s) => s.class === "A" || (s.class === "B" && ["test:scripts-ci", "generated-docs"].includes(s.id)));
99
- const ids = new Set(scans.map((s) => s.id));
100
- return {
101
- ...plan, scans, deferred: plan.deferred.filter((id) => ids.has(id)),
102
- scanSetDigest: digestScanSet(scans),
103
- classDigests: { A: digestScanSet(scans.filter((s) => s.class === "A")), B: digestScanSet(scans.filter((s) => s.class === "B")) },
104
- notes: [...plan.notes, "pre-push mode: class A, focused scripts/ci tests & generated docs only; native checks remain required for publication qualification"],
105
- };
106
- }
107
-
108
98
  /** The `packages:` globs of a pnpm-workspace.yaml (block or flow list; the subset pnpm repositories use). */
109
99
  export function parseWorkspaceGlobs(source) {
110
100
  const out = []; let inPackages = false;
@@ -466,12 +456,13 @@ export function installedDependencyDigest({ root, files, maxFiles = 400000, incl
466
456
  * (node_modules/.modules.yaml) and the installed dependency bytes (installedDependencyDigest). The broker binds an admission ticket to it and recomputes it when the compile starts
467
457
  * (`rightkit-gate env-digest`), so a dependency or tool change while a compile waits invalidates the ticket.
468
458
  */
469
- export function classAEnvironmentDigest({ root, files, spawn = defaultSpawn, includeCaches = true }) {
459
+ export function classAEnvironmentDigest({ root, files, spawn = defaultSpawn, includeCaches = true, gatePackage = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..") }) {
470
460
  // Only a file that does not exist is "absent"; any other read failure throws: an input whose bytes cannot be read is never
471
461
  // identified by a placeholder that would match the same failure later.
472
462
  // `optional`: only an install-state file may legitimately not exist; a gate file found by the walk must be readable.
473
463
  const fileDigest = (f, { optional = false } = {}) => { try { return createHash("sha256").update(fs.readFileSync(f)).digest("hex"); } catch (error) { if (optional && (error?.code === "ENOENT" || error?.code === "ENOTDIR")) return "absent"; throw new Error(`cannot read ${f} for the class A environment (${error?.code ?? error?.message})`); } };
474
- const gatePkg = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
464
+ // `gatePackage` defaults to this very package; only a test that must not be disturbed by other writers of the live package injects another.
465
+ const gatePkg = gatePackage;
475
466
  const walkGate = (dir) => fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => (e.name === "node_modules" || e.name.startsWith(".") ? [] : e.isDirectory() ? walkGate(path.join(dir, e.name)) : [path.join(dir, e.name)]));
476
467
  const gateFiles = walkGate(gatePkg).sort().map((f) => `${path.relative(gatePkg, f)}:${fileDigest(f)}`);
477
468
  // Bounded: a pnpm that switches itself to the repository's packageManager version on first use, or waits on the network,
@@ -513,8 +504,30 @@ export function discoverRustUnits({ root, spawn, cargoCommand, tracked, readFile
513
504
  return { present: true, units, cargoDir, uncovered };
514
505
  }
515
506
 
516
- /** The unit name in scan ids and ci/gate.config.json: the plain crate in a root workspace, `<cargoDir>:<crate>` below it. */
517
- export const rustUnitName = (cargoDir, crate) => (cargoDir === "." ? crate : `${cargoDir}:${crate}`);
507
+ /**
508
+ * The unit name in scan ids and ci/gate.config.json: the plain crate in a root workspace and in a workspace that is exactly that crate's
509
+ * own directory (`crates/rightkit-fs` holding rightkit-fs: one workspace and lock per crate, rightkit#1285), `<cargoDir>:<crate>` below
510
+ * any other workspace.
511
+ */
512
+ export const rustUnitName = (cargoDir, crate) => (cargoDir === "." || cargoDir === `crates/${crate}` ? crate : `${cargoDir}:${crate}`);
513
+
514
+ /**
515
+ * The vendored sources a crate compiles: `{ "vendor/<name>": [unit names] }`, from every quoted `../../vendor/<name>` path in the crate
516
+ * manifests (a [patch.crates-io] or dependency `path`, rightkit-typeset's vendor/citationberg). A changed file under the directory
517
+ * selects those units. `readManifest(unit)` returns the manifest text, or null.
518
+ */
519
+ export function vendorOwners(units, readManifest) {
520
+ const owners = {};
521
+ for (const u of units) {
522
+ let manifest = null;
523
+ try { manifest = readManifest(u); } catch { manifest = null; }
524
+ for (const m of String(manifest ?? "").matchAll(/["'](?:\.\.\/)+vendor\/([A-Za-z0-9_.@+-]+)\/?["']/g)) {
525
+ const list = (owners[`vendor/${m[1]}`] ??= []);
526
+ if (!list.includes(u.name)) list.push(u.name);
527
+ }
528
+ }
529
+ return owners;
530
+ }
518
531
 
519
532
  /**
520
533
  * The top-level TOML tables of a Cargo manifest: name -> body text (the lines under `[name]` up to the next header, trimmed).
@@ -624,8 +637,7 @@ export function manifestChangeIsLockReflected(before, after) {
624
637
  * A member whose Cargo.toml is not tracked (untracked or ignored) is dropped into `skipped`, as are `untracked()` manifests.
625
638
  * Returns { present, workspaces: [{ cargoDir, units }], units, cargoDir, skipped: [{ manifest, why }] }; `units` is every workspace's
626
639
  * [{ name, crate, dir, publish, cargoDir }] and `cargoDir` the first workspace's (the repository root's when it has one).
627
- * `metadata(dir)` (optional) replaces the cargo call with a reader of the same { cargoDir, members } shape (extra fields are ignored): the
628
- * deferred pre-push gate passes manifestWorkspaceMetadata where no broker can run cargo.
640
+ * `metadata(dir)` (optional) replaces the cargo call with a reader of the same { cargoDir, members } shape (extra fields are ignored).
629
641
  */
630
642
  export function discoverRustWorkspaces({ root, spawn, cargoCommand, tracked, untracked = () => [], realpath = fs.realpathSync, fixtures = [], readFile = (f) => fs.readFileSync(f, "utf8"), metadata = null }) {
631
643
  const manifests = tracked("Cargo.toml");
@@ -736,84 +748,6 @@ export function lockScope({ changed, cargoDir = ".", unitNames, showBase, readLo
736
748
  return scope;
737
749
  }
738
750
 
739
- /**
740
- * The pre-push hook. git's ref lines arrive on stdin and the gate consumes them, so stdin is buffered once and fed to both the
741
- * repository's own .git/hooks/pre-push (which core.hooksPath=.githooks would otherwise hide; it runs first and a failure stops the
742
- * push) and the gate. Between them runs the app check, `rightkit-gate check --commit HEAD`: seconds, committed files only, so a
743
- * red tree stops the push before the gate's scans start; a push that only deletes refs has no content to check and skips it. The
744
- * gate binary is the root node_modules/.bin/rightkit-gate, else any workspace package's (up to three levels down: tauri-app-next,
745
- * apps/*, packages/*), else `pnpm exec rightkit-gate`, else node with a package-relative path when pnpm is absent; the line it
746
- * chose is printed once. No absolute path is written: everything is resolved from git at run time.
747
- *
748
- * `packages` (default none) is the multi-root form: the repository-relative directories of the packages that declare @rightkit/release when the
749
- * 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
750
- * 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`;
751
- * 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
752
- * (fixtures/published-gate-hooks.json holds its digest, and a repository whose root installs the package must keep getting it, byte for byte,
753
- * or the committed hook of every app copied from the template stops being "already current"). `nodePath` and every package are written into
754
- * a shell script: installHook passes only plain relative paths (hookGatePlan).
755
- */
756
- export function hookText(nodePath, packages = [], legacy = false) {
757
- const bins = packages.map((dir) => ` ${dir}/node_modules/.bin/rightkit-gate`).join("");
758
- const pnpmExec = packages.length ? `pnpm --dir ${packages[0]} exec rightkit-gate` : "pnpm exec rightkit-gate";
759
- return `#!/bin/sh
760
- # Mandatory local check and gate before every push (rightkit-gate install-hook). Never bypass with --no-verify.
761
- cd "$(git rev-parse --show-toplevel)" || exit 1
762
- stdin=$(mktemp "\${TMPDIR:-/tmp}/rightkit-gate.XXXXXX") || exit 1
763
- trap 'rm -f "$stdin"' EXIT
764
- cat > "$stdin"
765
- # core.hooksPath hides .git/hooks: run the repository's own pre-push hook first.
766
- own="$(git rev-parse --git-dir)/hooks/pre-push"
767
- if [ -x "$own" ]; then
768
- echo "rightkit-gate: running $own" >&2
769
- "$own" "$@" < "$stdin" || exit $?
770
- fi
771
- # 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"}.
772
- found=""
773
- 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
774
- if [ -x "$bin" ]; then found="$bin"; break; fi
775
- done
776
- ${legacy ? "" : `if command -v rightkit-host-gate >/dev/null 2>&1; then
777
- echo "rightkit-gate: using host trusted gate" >&2
778
- rightkit_gate() { rightkit-host-gate "$@"; }
779
- elif [ -f "$HOME/Library/Application Support/RightKit/build-control/config.json" ] || [ -f /d/.rightkit-managed/config.json ]; then
780
- echo "rightkit-gate: managed host is missing rightkit-host-gate" >&2
781
- exit 1
782
- el`}if [ -n "$found" ]; then
783
- echo "rightkit-gate: using $found" >&2
784
- rightkit_gate() { "$found" "$@"; }
785
- elif command -v pnpm >/dev/null 2>&1; then
786
- echo "rightkit-gate: using ${pnpmExec}" >&2
787
- rightkit_gate() { ${pnpmExec} "$@"; }
788
- else
789
- echo "rightkit-gate: using node ${nodePath}" >&2
790
- rightkit_gate() { node "${nodePath}" "$@"; }
791
- fi
792
- # The check first, over the committed files of HEAD; a push that only deletes refs has nothing to check.
793
- if [ -s "$stdin" ] && ! awk '$2 !~ /^0+$/ { found = 1 } END { exit !found }' "$stdin"; then
794
- echo "rightkit-gate: only deletions are pushed; nothing to check" >&2
795
- else
796
- rightkit_gate check --commit HEAD < /dev/null >&2 || exit $?
797
- fi
798
- rightkit_gate --base origin/main < "$stdin"; exit $?
799
- `;
800
- }
801
-
802
- /** A trampoline for a hook other than pre-push: core.hooksPath hides .git/hooks/<name>, so run that one when it exists. */
803
- export function trampolineText(name) {
804
- return `#!/bin/sh
805
- # rightkit-gate install-hook: core.hooksPath=.githooks hides .git/hooks, so run this repository's own ${name} hook from there.
806
- hook="$(git rev-parse --git-dir)/hooks/${name}"
807
- if [ -x "$hook" ]; then exec "$hook" "$@"; fi
808
- exit 0
809
- `;
810
- }
811
-
812
- // EVERY file present in .git/hooks is chained, whatever git version or hook family it belongs to (pre-receive, update and
813
- // post-receive included), except git's own `.sample` templates. The name goes into a committed shell script, so only plain file
814
- // names are chained; anything else is reported and skipped, never interpolated.
815
- const HOOK_FILE_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
816
- const chainableHook = (name) => !name.endsWith(".sample") && HOOK_FILE_NAME.test(name);
817
751
  // ---- result cache inputs ------------------------------------------------------------------------------------
818
752
 
819
753
  // The build inputs that change how every crate compiles, relative to the Cargo workspace root, plus the gate's own config.
@@ -1046,6 +980,7 @@ export function buildCacheDigest({ objectId, headTree, rustUnits = [], npmUnits
1046
980
  const byName = new Map(rustUnits.map((u) => [u.name, u]));
1047
981
  const known = new Set(byName.keys());
1048
982
  const byCrate = new Map(rustUnits.map((u) => [`${wsOf(u)}\0${crateOf(u)}`, u]));
983
+ const siblingByCrate = new Map(rustUnits.filter((u) => wsOf(u) === `crates/${crateOf(u)}`).map((u) => [crateOf(u), u]));
1049
984
  const whole = () => canonicalJson({ kind: "tree", tree: headTree });
1050
985
  const externalOf = (member) => {
1051
986
  if (!externalMemo.has(member.dir)) {
@@ -1087,6 +1022,12 @@ export function buildCacheDigest({ objectId, headTree, rustUnits = [], npmUnits
1087
1022
  }
1088
1023
  if (scan.class !== "B") return null;
1089
1024
  try {
1025
+ // A batched Clippy scan (clippy:batch:*, planScans `clippyBatching`) is keyed on the inputs of every member it names: each member's
1026
+ // own compile key, in member order. Any member that falls back to the whole tree makes the batch fall back too.
1027
+ if (/^clippy:batch:/.test(String(scan.id)) && Array.isArray(scan.batch?.units) && scan.batch.units.length && scan.batch.units.every((n) => known.has(n))) {
1028
+ const keys = scan.batch.units.map((n) => rustUnitKey(n, scan));
1029
+ return keys.includes(undefined) ? whole() : canonicalJson({ kind: "rust-batch", id: scan.id, units: scan.batch.units.map((n, i) => [n, keys[i]]) });
1030
+ }
1090
1031
  const unit = rustUnitOfScanId(scan.id, known);
1091
1032
  if (!unit) {
1092
1033
  if (typeof readBlob !== "function") return whole();
@@ -1100,27 +1041,35 @@ export function buildCacheDigest({ objectId, headTree, rustUnits = [], npmUnits
1100
1041
  if (scan.id === "test:scripts-ci") return canonicalJson({ kind: "dirs", id: scan.id, inputs: SCRIPTS_CI_INPUTS.map((p) => [p, idOf(p)]) });
1101
1042
  return whole();
1102
1043
  }
1044
+ const keyed = rustUnitKey(unit, scan);
1045
+ return keyed === undefined ? whole() : keyed;
1046
+ } catch { return null; }
1047
+ };
1048
+ // The cache key of one Rust unit's compile or test scan, or undefined when the scan must fall back to the whole tree.
1049
+ function rustUnitKey(unit, scan) {
1103
1050
  const head = byName.get(unit);
1104
1051
  const ws = wsOf(head); const lock = lockFor(ws);
1105
- if (!lock || typeof listFiles !== "function" || typeof readBlob !== "function") return whole();
1052
+ if (!lock || typeof listFiles !== "function" || typeof readBlob !== "function") return undefined;
1106
1053
  const testScan = /^test:rust(?:-serial)?:/.test(scan.id);
1107
1054
  const ownConfig = crateFeatures?.[unit] ?? crateFeatures?.[crateOf(head)] ?? null;
1108
1055
  let testInputs = []; let declaredInputs = false;
1109
1056
  if (testScan) {
1110
1057
  const declared = ownConfig?.testInputs;
1111
1058
  if (declared !== undefined) {
1112
- if (!Array.isArray(declared) || declared.length === 0 || !declared.every(plainRelative)) return whole();
1059
+ if (!Array.isArray(declared) || declared.length === 0 || !declared.every(plainRelative)) return undefined;
1113
1060
  testInputs = [...new Set(declared)].sort(); declaredInputs = true;
1114
1061
  }
1115
- } else if (!COMPILE_SCAN_PREFIXES.has(String(scan.id).split(":")[0])) return whole();
1062
+ } else if (!COMPILE_SCAN_PREFIXES.has(String(scan.id).split(":")[0])) return undefined;
1116
1063
  if (!reachMemos.has(ws)) reachMemos.set(ws, new Map());
1117
1064
  const reached = [...reach(lock, crateOf(head), reachMemos.get(ws))];
1118
- const closure = [head, ...reached.map((n) => byCrate.get(`${ws}\0${n}`)).filter(Boolean)].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
1065
+ // A crate that is its own workspace (one workspace and lock per crate) holds its sibling crates in its lock as path packages: the
1066
+ // sibling's source is part of the closure whichever workspace it lives in, or a change to it would leave a stale key.
1067
+ const closure = [...new Set([head, ...reached.map((n) => byCrate.get(`${ws}\0${n}`) ?? siblingByCrate.get(n)).filter(Boolean)])].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
1119
1068
  const extra = new Set(testInputs);
1120
1069
  for (const member of closure) {
1121
1070
  const found = externalOf(member);
1122
- if (found.unresolved) return whole();
1123
- if (testScan && !declaredInputs && found.leavesCrate) return whole();
1071
+ if (found.unresolved) return undefined;
1072
+ if (testScan && !declaredInputs && found.leavesCrate) return undefined;
1124
1073
  for (const p of found.paths) extra.add(p);
1125
1074
  }
1126
1075
  // The shared inputs, projected to what this crate consumes.
@@ -1146,8 +1095,7 @@ export function buildCacheDigest({ objectId, headTree, rustUnits = [], npmUnits
1146
1095
  shared: [...new Set(toolFiles)].sort().map((p) => [p, idOf(p)]),
1147
1096
  external: [...extra].sort().map((p) => [p, idOf(p)]),
1148
1097
  });
1149
- } catch { return null; }
1150
- };
1098
+ }
1151
1099
  }
1152
1100
 
1153
1101
  // ---- quiet lease ------------------------------------------------------------------------------------------------
@@ -1199,137 +1147,60 @@ export function quietLeaseLane(o, { tag = "rightkit-gate:", reason = "rightkit-g
1199
1147
  // ---- install-hook -------------------------------------------------------------------------------------------
1200
1148
 
1201
1149
  /**
1202
- * The tracked package.json files (the root one when git cannot list) that matter to the hook: `provider` is the first that declares
1203
- * @rightkit/release or is that package itself, `declaring` the directories of every one that declares it ("." for the root; the package itself is
1204
- * not one: it installs nothing of itself).
1205
- */
1206
- function releasePackages(o, root, git) {
1207
- let files = ["package.json"];
1208
- const listed = git("ls-files", "-z", "--", ":(glob)**/package.json");
1209
- if (!failed(listed)) files = text(listed).split("\0").filter(Boolean).map(posix);
1210
- let provider = null; const declaring = [];
1211
- for (const file of files) {
1212
- const abs = path.join(root, file);
1213
- if (!o.exists(abs)) continue;
1214
- let manifest;
1215
- try { manifest = JSON.parse(o.readFile(abs).replace(/^\uFEFF/, "")); } catch { continue; }
1216
- if (manifest?.name === "@rightkit/release") { provider ??= file; continue; }
1217
- if (["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"].some((section) => manifest?.[section] && Object.hasOwn(manifest[section], "@rightkit/release"))) {
1218
- provider ??= file;
1219
- declaring.push(path.posix.dirname(file));
1220
- }
1221
- }
1222
- return { provider, declaring: [...new Set(declaring)].sort() };
1223
- }
1224
-
1225
- // 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
1226
- // 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).
1227
- const GATE_IN_PACKAGE = "node_modules/@rightkit/release/bin/rightkit-gate.mjs";
1228
- // What may be written into the committed shell script: a plain relative path of letters, digits and _ - . @ + /, no `..`, no `.` segment, no
1229
- // leading dash (it would read as an option of `pnpm --dir`).
1230
- const HOOK_PATH_SEGMENT = /^(?!\.{1,2}$)[A-Za-z0-9_@.+-]+$/;
1231
- export const hookPathIsPlain = (value) => typeof value === "string" && value !== "" && !value.startsWith("-") && value.split("/").every((segment) => HOOK_PATH_SEGMENT.test(segment));
1232
-
1233
- /**
1234
- * What the pre-push hook is told about where the gate lives: { nodePath, packages, unsafe }. `nodePath` is the last resort the hook runs with
1235
- * node, `packages` the extra directories hookText searches (multi-root form), `unsafe` the declaring packages whose directory cannot be written
1236
- * into a shell script. `declaring` is releasePackages(...).declaring; `gateFile` is where this gate runs from.
1237
- *
1238
- * 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
1239
- * clone, an exports checkout, after `pnpm install` and after a version bump, so `install-hook` (a `prepare` script) never meets a hook it
1240
- * wrote earlier as one that "already exists and differs". It takes no `exists`, so that cannot creep back in. Always a stable,
1241
- * version-independent path relative to the repository, never one outside it, absolute, or inside the pnpm store:
1242
- * 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
1243
- * it at the root and in hub/; RightKit itself at the root).
1244
- * 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
1245
- * installed, and every one of them is searched.
1246
- * 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
1247
- * 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
1248
- * outside the repository. This is the one case that reads where the gate runs from, because the repository says nothing.
1150
+ * `rightkit-gate install-hook` installs no git hook: commit and push run no gate and no local hook check (docs/GATE-CONTRACT.md).
1151
+ * It retires what an earlier release wrote, each file only by its own header or marker (../hook-retirement.mjs), never by name or place:
1152
+ * - .githooks/pre-push, the gate hook, and the .githooks/<name> forwarders that chained .git/hooks beside it;
1153
+ * - the advisory ownership pre-commit hook (in .githooks, in .git/hooks or in the directory core.hooksPath names), with the foreign
1154
+ * pre-commit it displaced (pre-commit.rightkit-foreign) put back as pre-commit;
1155
+ * - the repository-local core.hooksPath, only when it is exactly `.githooks` and nothing foreign remains in that directory.
1156
+ * A foreign hook (any other file in .githooks, .git/hooks or the configured directory) is never read for change, moved or deleted, and
1157
+ * a foreign .githooks directory keeps its core.hooksPath. The global and system git configuration is never touched. It still registers
1158
+ * the one-door agent hook in .claude/settings.json (the agent side, not a git hook).
1249
1159
  */
1250
- export function hookGatePlan({ root, gateFile, declaring }) {
1251
- const plain = declaring.filter((d) => d !== "." && hookPathIsPlain(d));
1252
- const unsafe = declaring.filter((d) => d !== "." && !hookPathIsPlain(d));
1253
- if (declaring.includes(".")) return { nodePath: GATE_IN_PACKAGE, packages: [], unsafe };
1254
- if (plain.length) return { nodePath: `${plain[0]}/${GATE_IN_PACKAGE}`, packages: plain, unsafe };
1255
- const relative = posix(path.relative(root, gateFile));
1256
- const inside = relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative) && !relative.split("/").includes("node_modules") && hookPathIsPlain(relative);
1257
- return { nodePath: inside ? relative : GATE_IN_PACKAGE, packages: [], unsafe };
1258
- }
1259
-
1260
- // The advisory ownership pre-commit step (`right-release hooks install`, ownership-hooks.mjs) runs the pinned @rightkit/release, so
1261
- // it is written only where that is installed. Its own hook and the repository's .git/hooks/pre-commit both keep running.
1262
- const PINNED_CLI = "node_modules/@rightkit/release/cli/right-release.mjs";
1263
- const FOREIGN_PRE_COMMIT = "pre-commit.rightkit-foreign";
1264
-
1265
- async function installHook(o, root, force) {
1160
+ async function installHook(o, root) {
1266
1161
  const git = (...a) => o.spawn("git", a, { cwd: root });
1267
- const hooksDir = path.join(root, ".githooks");
1268
- const hookPath = path.join(hooksDir, "pre-push");
1269
- const { provider, declaring } = releasePackages(o, root, git);
1270
- const { nodePath, packages, unsafe } = hookGatePlan({ root, gateFile: o.gateFile, declaring });
1271
- 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.`);
1272
- const wanted = hookText(nodePath, packages);
1273
- const hasHook = o.exists(hookPath);
1274
- const current = hasHook ? o.readFile(hookPath).replace(/\r\n/g, "\n") : null;
1275
- if (hasHook && current !== wanted && current !== hookText(nodePath, packages, true) && !force) {
1276
- o.err(`rightkit-gate: ${path.join(".githooks", "pre-push")} already exists and differs; nothing changed. Review it, or run \`rightkit-gate install-hook --force\` to replace it.`);
1277
- return 1;
1278
- }
1279
- // core.hooksPath=.githooks makes git ignore .git/hooks: chain the hooks that are there now (pre-push at run time inside the gate hook).
1280
- const gitDirResult = git("rev-parse", "--git-dir");
1281
- let present = [];
1282
- if (!failed(gitDirResult) && text(gitDirResult).trim()) { try { present = o.readdir(path.join(path.resolve(root, text(gitDirResult).trim()), "hooks")); } catch { present = []; } }
1283
- const gitHooksDir = !failed(gitDirResult) && text(gitDirResult).trim() ? path.join(path.resolve(root, text(gitDirResult).trim()), "hooks") : null;
1284
- const own = present.filter(chainableHook).sort();
1285
- // The ownership step takes .githooks/pre-commit. The repository's own pre-commit hook keeps running first, through a trampoline
1286
- // beside it, unless it is the ownership hook a plain `right-release hooks install` wrote (this one replaces that), whose own
1287
- // chained hook, pre-commit.rightkit-foreign, then runs instead. A name that is no plain hook name is reported, never chained.
1288
- const ownership = o.exists(path.join(root, PINNED_CLI)) ? await import("../ownership-hooks.mjs") : null;
1289
- const readHook = (name) => { try { return o.readFile(path.join(gitHooksDir, name)); } catch { return ""; } };
1290
- const chainName = !ownership ? null : own.includes("pre-commit") && !readHook("pre-commit").includes(ownership.HOOK_MARKER) ? "pre-commit" : present.includes(FOREIGN_PRE_COMMIT) ? FOREIGN_PRE_COMMIT : null;
1291
- const skipped = present.filter((name) => !name.endsWith(".sample") && !chainableHook(name) && !(ownership && name === FOREIGN_PRE_COMMIT)).sort();
1292
- if (skipped.length) o.err(`rightkit-gate: WARNING: not chaining .git/hooks/${skipped.join(", .git/hooks/")}: not a plain hook file name (letters, digits, - and _ only); run it from a hook of its own.`);
1293
- const trampolines = own.filter((name) => name !== "pre-push" && !(ownership && name === "pre-commit")).map((name) => ({ name, file: path.join(hooksDir, name), wanted: trampolineText(name), note: `runs .git/hooks/${name}` }));
1294
- if (ownership) {
1295
- trampolines.push({ name: "pre-commit", file: path.join(hooksDir, "pre-commit"), wanted: ownership.renderPreCommitHook(), ownership: true, replaces: [trampolineText("pre-commit")], marker: ownership.HOOK_MARKER, note: "the advisory ownership gate: right-release ownership --staged" });
1296
- if (chainName) trampolines.push({ name: FOREIGN_PRE_COMMIT, file: path.join(hooksDir, FOREIGN_PRE_COMMIT), wanted: trampolineText(chainName), note: `runs .git/hooks/${chainName}, before the ownership gate` });
1297
- }
1298
- for (const t of trampolines) {
1299
- t.exists = o.exists(t.file);
1300
- const found = t.exists ? o.readFile(t.file).replace(/\r\n/g, "\n") : null;
1301
- t.current = found === t.wanted;
1302
- // Our own earlier artifacts are updated in place: a plain trampoline where the ownership hook now goes, and an older ownership hook.
1303
- const ours = found !== null && (t.current || t.replaces?.includes(found) || (t.marker && found.includes(t.marker)));
1304
- if (t.exists && !ours && !force) {
1305
- o.err(`rightkit-gate: .githooks/${t.name} already exists and differs; nothing changed. Review it, or run \`rightkit-gate install-hook --force\` to replace it with ${t.ownership ? "the ownership pre-commit hook" : `a trampoline to .git/hooks/${chainName ?? t.name}`}.`);
1306
- return 1;
1307
- }
1308
- }
1309
- const currentPath = git("config", "--get", "core.hooksPath");
1310
- const existingPath = failed(currentPath) ? "" : text(currentPath).trim();
1311
- if (existingPath && existingPath !== ".githooks" && !force) {
1312
- o.err(`rightkit-gate: core.hooksPath is already ${JSON.stringify(existingPath)}; nothing changed. Run \`rightkit-gate install-hook --force\` to point it at .githooks.`);
1313
- return 1;
1314
- }
1162
+ const ops = { readdir: o.readdir, readFile: o.readFile, lstat: o.lstat ?? fs.lstatSync, unlink: o.unlink ?? fs.unlinkSync, rename: o.rename ?? fs.renameSync, rmdir: o.rmdir ?? fs.rmdirSync };
1315
1163
  const done = [];
1316
- const put = (file, body, label, note) => {
1317
- o.mkdir(path.dirname(file), { recursive: true }); o.writeFile(file, body);
1318
- try { o.chmod(file, 0o755); } catch { /* not supported here */ }
1319
- done.push(`${label}${note}`);
1164
+ const isRealDirectory = (dir) => { try { const stat = ops.lstat(dir); return stat.isDirectory() && !stat.isSymbolicLink(); } catch { return false; } };
1165
+ const label = (dir) => posix(path.relative(root, dir)) || ".";
1166
+ const retire = (dir, options) => {
1167
+ if (!isRealDirectory(dir)) return null;
1168
+ const result = retireHooksIn(dir, ops, options);
1169
+ for (const name of result.removed) done.push(`retired ${label(dir)}/${name}`);
1170
+ for (const name of result.restored) done.push(`restored ${label(dir)}/${name} from ${FOREIGN_PRE_COMMIT}`);
1171
+ if (result.removedDir) done.push(`removed the empty ${label(dir)}`);
1172
+ return result;
1320
1173
  };
1321
- if (current === wanted) done.push(".githooks/pre-push already current");
1322
- else put(hookPath, wanted, `${hasHook ? "replaced" : "wrote"} .githooks/pre-push`, ` (${nodePath} without pnpm)`);
1323
- for (const t of trampolines) {
1324
- if (t.current) done.push(`.githooks/${t.name} already current`);
1325
- else put(t.file, t.wanted, `${t.exists ? "replaced" : "wrote"} .githooks/${t.name}`, ` (${t.note})`);
1326
- }
1327
- if (existingPath === ".githooks") done.push("core.hooksPath already .githooks");
1328
- else {
1329
- const set = git("config", "core.hooksPath", ".githooks");
1330
- if (failed(set)) { o.err(`rightkit-gate: git config core.hooksPath failed: ${why(set)}`); return 2; }
1331
- done.push(`set core.hooksPath to .githooks${existingPath ? ` (was ${existingPath})` : ""}`);
1174
+
1175
+ // The gate hook, its forwarders and the ownership step in .githooks, then the ownership step wherever else git ran hooks from.
1176
+ const hooksDir = path.join(root, ".githooks");
1177
+ retire(hooksDir, { scripts: true });
1178
+ const gitHooksResult = git("rev-parse", "--git-path", "hooks");
1179
+ const gitHooksDir = !failed(gitHooksResult) && text(gitHooksResult).trim() ? path.resolve(root, text(gitHooksResult).trim()) : null;
1180
+ const configured = git("config", "--local", "--get", "core.hooksPath");
1181
+ const hooksPath = failed(configured) ? "" : text(configured).trim();
1182
+ const seen = new Set([path.resolve(hooksDir)]);
1183
+ for (const dir of [gitHooksDir, hooksPath && hooksPath !== ".githooks" ? path.resolve(root, hooksPath) : null]) {
1184
+ if (!dir || seen.has(path.resolve(dir))) continue;
1185
+ seen.add(path.resolve(dir));
1186
+ retire(dir, { scripts: false });
1332
1187
  }
1188
+
1189
+ // core.hooksPath: unset only when it is `.githooks` and that directory holds nothing foreign (it is gone, or empty).
1190
+ let remaining = [];
1191
+ if (hooksPath === ".githooks") {
1192
+ if (isRealDirectory(hooksDir)) { try { remaining = [...o.readdir(hooksDir)].map(String).sort(); } catch { remaining = ["(unreadable)"]; } }
1193
+ else if (o.exists(hooksDir)) remaining = ["(not a plain directory)"];
1194
+ if (!remaining.length) {
1195
+ const unset = git("config", "--local", "--unset", "core.hooksPath");
1196
+ if (failed(unset)) { o.err(`rightkit-gate: git config --unset core.hooksPath failed: ${why(unset)}`); return 2; }
1197
+ done.push("unset core.hooksPath (was .githooks)");
1198
+ } else {
1199
+ done.push(`core.hooksPath stays .githooks: foreign file(s) remain there (${remaining.join(", ")}); the hooks in .git/hooks do not run while it is set`);
1200
+ }
1201
+ } else if (hooksPath) done.push(`core.hooksPath is ${JSON.stringify(hooksPath)}: left as it is`);
1202
+ if (!done.length) done.push("no git hook of RightKit's to retire");
1203
+
1333
1204
  // 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
1334
1205
  // shell command, on every host (the broker-less cloud included), and denies any build that does not go through rightkit-gate.
1335
1206
  const agentHookFile = "node_modules/@rightkit/release/bin/rightkit-agent-hook.mjs";
@@ -1350,13 +1221,7 @@ async function installHook(o, root, force) {
1350
1221
  }
1351
1222
  }
1352
1223
  for (const line of done) o.out(`rightkit-gate: ${line}`);
1353
- const chained = own.length ? own.map((name) => (name === "pre-push" ? "pre-push (run first, then the gate)" : name)) : [];
1354
- 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"}`);
1355
- 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`);
1356
- const files = [".githooks/pre-push", ...trampolines.map((t) => `.githooks/${t.name}`)];
1357
- 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\`"}.`);
1358
- 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.");
1359
- else o.out(`rightkit-gate: @rightkit/release is declared in ${provider}`);
1224
+ o.out("rightkit-gate: no git hook is written: commit and push run no gate and no local hook check (the gate is for building)");
1360
1225
  return 0;
1361
1226
  }
1362
1227
 
@@ -1388,9 +1253,14 @@ async function runStages(o, args, root, git, io) {
1388
1253
  const tag = "rightkit-gate:";
1389
1254
  const { appRoot, appDir } = appRootOf({ root, cwd: o.cwd, exists: o.exists });
1390
1255
  const to = option("--to");
1391
- if (to === "dev" || to === "test") return await runWorktreeCompile(o, args, root, git, io, to, appRoot);
1392
- let stages = stagesTo(to);
1393
- if (!stages) { o.err(`${tag} --to must be one of ${STAGES.join(", ")}`); return 2; }
1256
+ // dev and test are door stages like the rest: the same commit-only route (select a commit, sync its snapshot under ownership, class A
1257
+ // of the snapshot), then right-release dev|test with the snapshot's app root as its cwd. They have no stage list of their own.
1258
+ const devTest = to === "dev" || to === "test";
1259
+ let stages = devTest ? [] : stagesTo(to);
1260
+ if (!stages) { o.err(`${tag} --to must be one of ${[...STAGES, "dev", "test"].join(", ")}`); return 2; }
1261
+ // Every stage the door runs has a row naming where it reads its source from (gate-stages.mjs STAGE_SOURCES, drift-tested in
1262
+ // door-stages.test.mjs); a stage added without one is refused rather than run with an unstated source.
1263
+ if (!STAGE_SOURCES[to]) { o.err(`${tag} BLOCKED: stage ${to} has no STAGE_SOURCES row naming its source; nothing ran`); return 2; }
1394
1264
  const platform = option("--platform");
1395
1265
  const tier = option("--tier");
1396
1266
  if (stages.includes("package") && !PLATFORMS.includes(platform)) { o.err(`${tag} --to ${to} needs --platform ${PLATFORMS.join("|")}`); return 2; }
@@ -1402,11 +1272,20 @@ async function runStages(o, args, root, git, io) {
1402
1272
  if (mobile && to === "qualify") { o.err(`${tag} --to qualify has no stage for ${platform}: there is no installed-QA lane for a mobile build; qualification is the store's test track after --to release. Run --to package or --to release.`); return 2; }
1403
1273
  if (mobile) stages = stages.filter((s) => s !== "qualify");
1404
1274
  if (stages.includes("release") && !mobile && !["patch", "update"].includes(tier)) { o.err(`${tag} --to release needs --tier patch|update (chosen explicitly, never defaulted)`); return 2; }
1405
- // Commit-only build (GATE-CONTRACT §1, joint plan step 4c): on a direct host (Linux; not hosted CI, whose runner checkout is the
1406
- // commit) the door selects a commit (--commit <rev>, default HEAD), takes the host's build ownership, syncs the commit's
1407
- // gate-owned snapshot once, and every stage below reads that snapshot; the checkout may hold anything. Broker hosts keep the
1408
- // checkout route until the RustRight snapshot carrier lands (step 4d).
1409
- if (!o.doorSnapshot && directPlatform(o.platform) && o.env.GITHUB_ACTIONS !== "true") {
1275
+ // Commit-only build (GATE-CONTRACT §1): on every host (not hosted CI, whose runner checkout is the commit) the door selects a commit
1276
+ // (--commit <rev>, default HEAD), takes the host's build ownership, syncs the commit's gate-owned snapshot once, and every stage
1277
+ // below reads that snapshot; the checkout may hold anything and is never a build source. Mac and Dell compile it through the
1278
+ // RustRight snapshot carrier (`deps.snapshotBroker`, item 7 rows 7.20 to 7.24). Interim, until the one cutover commit (decision 2,
1279
+ // "Interim"): Mac and Dell keep the checkout route exactly as it was (clean HEAD, today's package, qualify and `runWorktreeCompile`),
1280
+ // and take the snapshot route ONLY when the carrier is wired (`deps.snapshotBroker`, today only in tests); no flag or environment
1281
+ // variable selects it. Once it is selected any failure refuses; there is no retry on the checkout.
1282
+ if (!o.doorSnapshot && o.env.GITHUB_ACTIONS !== "true" && snapshotRouteSelected(o)) {
1283
+ // The door never sets TAURI_CONFIG or any externalBin override, and refuses to inherit one: Tauri merges it over the commit's own
1284
+ // configuration (tauri-build lib.rs), which would build from a configuration that is not the commit's.
1285
+ if (o.env.TAURI_CONFIG !== undefined) { o.err(`${tag} BLOCKED: RK_TAURI_CONFIG_OVERRIDE: TAURI_CONFIG is set in the environment; it would replace the commit's Tauri configuration (externalBin included) for every build below, so a commit-only build refuses it; nothing ran`); return 1; }
1286
+ // `--to check` compiles nothing (class A of the snapshot: its Cargo scans go through the broker on their own), so it needs no carrier.
1287
+ const refused = to === "check" ? null : snapshotRouteRefusal(o, { rightRelease: devTest });
1288
+ if (refused) { o.err(`${tag} BLOCKED: ${refused.code}: ${refused.message}; nothing ran and the checkout is never built`); return 1; }
1410
1289
  const snap = await import("../snapshot.mjs");
1411
1290
  const { acquireBuildOwnership, ownershipFile } = await import("../build-ownership.mjs");
1412
1291
  let selected;
@@ -1423,12 +1302,18 @@ async function runStages(o, args, root, git, io) {
1423
1302
  try { synced = (o.syncSnapshot ?? snap.syncSnapshot)({ checkout: root, rev: selected.commit, root: snapshotsAt }); }
1424
1303
  catch (e) { o.err(`${tag} ${e.code === "error" ? "RK_CHECK_ERROR" : "BLOCKED"}: snapshot ${e.message}; nothing ran`); return e.code === "error" ? 2 : 1; }
1425
1304
  o.out(`${tag} snapshot ${synced.path} is commit ${synced.commit} tree ${synced.tree} (written=${synced.written} removed=${synced.removed} unchanged=${synced.unchanged})`);
1426
- return await runStages({ ...o, buildOwnership: own, doorSnapshot: { own, path: synced.path, commit: synced.commit, tree: synced.tree, root: snapshotsAt } }, args, root, git, { ...io, buildOwnership: own });
1305
+ return await runStages({ ...o, buildOwnership: own, doorSnapshot: { own, path: synced.path, commit: synced.commit, tree: synced.tree, root: snapshotsAt, ledger: { entries: [] } } }, args, root, git, { ...io, buildOwnership: own });
1427
1306
  } finally {
1428
1307
  const r = own.release();
1429
1308
  if (r?.released === false && !own.joined) o.err(`${tag} build ownership ${own.handle} kept: ${r.why}`);
1430
1309
  }
1431
1310
  }
1311
+ if (devTest) {
1312
+ // Mac and Dell, until the cutover: the checkout route as it was (class A of the working tree, then right-release on it). Hosted CI's
1313
+ // runner checkout is the commit, so it takes the commit route below without a snapshot.
1314
+ if (!o.doorSnapshot && o.env.GITHUB_ACTIONS !== "true") return await runWorktreeCompile(o, args, root, git, io, to, appRoot);
1315
+ return await runDevTest(o, args, root, git, io, to, appRoot, appDir);
1316
+ }
1432
1317
  const ds = o.doorSnapshot ?? null;
1433
1318
  const commit = ds ? ds.commit : text(git("rev-parse", "HEAD")).trim();
1434
1319
  const tree = ds ? ds.tree : text(git("rev-parse", "HEAD^{tree}")).trim();
@@ -1485,11 +1370,15 @@ async function runStages(o, args, root, git, io) {
1485
1370
  };
1486
1371
 
1487
1372
  const cli = fileURLToPath(new URL("../cli/right-release.mjs", import.meta.url));
1488
- const rightRelease = (stage, cliArgs) => {
1489
- const run = startRun({ commonDir, commit, tree, stage });
1373
+ // On the snapshot route right-release runs INSIDE the snapshot (its sources, config and every command it spawns resolve there);
1374
+ // the door run records the split, so the child can prove which snapshot it may read and which checkout holds its outputs
1375
+ // (source-split.mjs). Anywhere else it runs in the app root of the checkout, which hosted CI's runner holds at the commit.
1376
+ const sourceOf = ds ? sourceSplitOf({ root, ds, appDir }) : null;
1377
+ const rightRelease = (stage, cliArgs, extraEnv = {}) => {
1378
+ const run = startRun({ commonDir, commit, tree, stage, ...(sourceOf ? { source: sourceOf } : {}) });
1490
1379
  try {
1491
- o.out(`${tag} [${stage}] right-release ${cliArgs.join(" ")}`);
1492
- const r = (o.spawnInherit ?? ((cmd, a, opts) => spawnInherit(cmd, a, { ...opts, stdio: "inherit" })))(process.execPath, [cli, ...cliArgs], { cwd: appRoot, env: { ...o.env, ...run.env } });
1380
+ o.out(`${tag} [${stage}] right-release ${cliArgs.join(" ")}${sourceOf ? ` (from the snapshot ${ds.path})` : ""}`);
1381
+ const r = (o.spawnInherit ?? ((cmd, a, opts) => spawnInherit(cmd, a, { ...opts, stdio: "inherit" })))(process.execPath, [cli, ...cliArgs], { cwd: sourceOf ? snapshotAppRootOf(ds.path, appDir) : appRoot, env: { ...o.env, ...run.env, ...extraEnv } });
1493
1382
  return r.status === 0;
1494
1383
  } finally { endRun(run); }
1495
1384
  };
@@ -1497,10 +1386,19 @@ async function runStages(o, args, root, git, io) {
1497
1386
  o.out(`${tag} --to ${to} for ${commit.slice(0, 12)}${appDir === "." ? "" : ` (app ${appDir})`}: ${stages.join(" -> ")}; executor ${executor.kind}`);
1498
1387
  // A later stage on the sealed bytes: check, compile and package already passed for them (in this receipt) and the bytes on disk are
1499
1388
  // the ones they sealed, so they are not run again.
1389
+ // The release configs of the commit (under this app) that declare `sidecars`: the commit's config decides on the snapshot route, never the checkout's.
1390
+ const sidecarConfigsOf = () => {
1391
+ const listedConfigs = git("ls-tree", "-r", "--name-only", "-z", commit);
1392
+ return (failed(listedConfigs) ? [] : text(listedConfigs).split("\0").filter((f) => /(^|\/)right-release\.config\.(json|mjs)$/.test(f)).filter((f) => appDir === "." || f.startsWith(`${appDir}/`)))
1393
+ .filter((f) => { try { return /\bsidecars\b/.test(o.readFile(path.join(ds ? ds.path : root, f))); } catch { return false; } });
1394
+ };
1500
1395
  const sealedNow = receipt.stages.package?.ok ? (o.sealedFor ?? sealedFor)({ root: appRoot, commit, platform }) : null;
1501
1396
  const sealedIntact = Boolean(sealedNow && sealedNow.id === receipt.stages.package.sealed && sealedNow.digest === receipt.stages.package.manifestSha256);
1502
- // Every deterministic rule and scan must run before compilation, for every door stage.
1503
- const earlyTier = "release";
1397
+ // Every deterministic scan (full class A) runs before any compile, for every door stage. The app rules run at the tier the stage
1398
+ // owes: dev, test and package take the compile-tier rules; qualify and release add the release-only rules (approvals, ratified
1399
+ // rulings, notice digests), which are conditions for publishing, not for compiling (Adrian, 2026-10-30, rightkit#1296; the
1400
+ // 2026-10-28 change to release tier everywhere was never his decision and blocked HeardRight's dev and test builds).
1401
+ const earlyTier = ["qualify", "release"].includes(to) ? "release" : "compile";
1504
1402
  const tierAtLeast = (have, need) => have === "release" || need === "compile";
1505
1403
  for (const stage of stages) {
1506
1404
  if (stage === "check" && receipt.stages.check?.ok && tierAtLeast(receipt.stages.check.ruleTier ?? "compile", earlyTier)) { o.out(`${tag} [check] already passed (${receipt.stages.check.ruleTier ?? "compile"} tier) for this exact input (commit, tree, lockfiles, gate, toolchain)`); continue; }
@@ -1519,6 +1417,8 @@ async function runStages(o, args, root, git, io) {
1519
1417
  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`);
1520
1418
  return 1;
1521
1419
  }
1420
+ // The start of every stage: the snapshot is exactly tree + dependencies + the ledger of the stages before it (7.08, decision 2 Q1).
1421
+ if (ds && !(await openSnapshotStage({ o, ds, root, stage, tag }))) { receipt.stages[stage] = { ok: false, at: new Date().toISOString(), snapshot: { path: ds.path, tree: ds.tree, intactAfter: false }, ownership: ds.own.handle }; save(); o.err(`${tag} --to ${to} stopped before ${stage}: the snapshot is not the commit's bytes plus the ledger; nothing ran`); return 2; }
1522
1422
  let ok = false; const at = new Date().toISOString(); let extra = {}; let directReceipt = null;
1523
1423
  if (stage === "check") {
1524
1424
  ok = (await (o.runCheck ?? (() => main(["check", "--commit", ds ? commit : "HEAD", "--full-a", ...(ds ? ["--snapshot", ds.path] : []), "--rule-tier", earlyTier], { ...io, cwd: root, ...(ds ? { buildOwnership: ds.own } : {}) })))(earlyTier)) === 0;
@@ -1527,29 +1427,65 @@ async function runStages(o, args, root, git, io) {
1527
1427
  else if (stage === "compile") {
1528
1428
  // A Tauri crate with externalBin needs its sidecars in src-tauri/binaries before it compiles (its build script refuses
1529
1429
  // otherwise), so every release config that declares `sidecars` is staged first, through right-release under this door run.
1530
- const listedConfigs = git("ls-tree", "-r", "--name-only", "-z", commit);
1531
- const sidecarConfigs = (failed(listedConfigs) ? [] : text(listedConfigs).split("\0").filter((f) => /(^|\/)right-release\.config\.(json|mjs)$/.test(f)).filter((f) => appDir === "." || f.startsWith(`${appDir}/`)))
1532
- // On the snapshot route the commit's config decides, never the checkout's (which may add, edit or hide one).
1533
- .filter((f) => { try { return /\bsidecars\b/.test(o.readFile(path.join(ds ? ds.path : root, f))); } catch { return false; } });
1430
+ const sidecarConfigs = sidecarConfigsOf();
1534
1431
  ok = true;
1535
- // Sidecar staging is right-release's, which still reads the checkout: from a snapshot it waits for the right-release source
1536
- // split (joint plan step 4c, second part), so a commit with sidecars is refused here rather than built from the checkout.
1537
- if (ds && sidecarConfigs.length) { o.err(`${tag} BLOCKED: [compile] ${sidecarConfigs.join(", ")} stage sidecars through right-release, which does not build from a commit snapshot yet; nothing compiled`); ok = false; }
1432
+ // On the snapshot route the sidecars are staged from the snapshot's own config and sources through right-release's source split
1433
+ // (the staged files and receipt land beside the sources the compile reads; the caches and build trees stay the checkout's), before
1434
+ // `main --all` compiles. A broker host reaches Cargo here only through the snapshot carrier, which right-release's children do not
1435
+ // have yet (item 7 row 7.24): it is refused with a code, never staged from the checkout.
1436
+ if (ds && sidecarConfigs.length && executor.kind === "broker") { o.err(`${tag} BLOCKED: RK_SNAPSHOT_CARRIER_NOT_WIRED: [compile] ${sidecarConfigs.join(", ")} stage sidecars through right-release, whose Cargo calls reach the RustRight broker only through the snapshot carrier (item 7 row 7.24), which is not wired; nothing compiled`); ok = false; }
1538
1437
  // right-release runs in appRoot (nested apps), so the repository-relative config is handed over absolute (Astra, second pass).
1539
- for (const config of ok && !ds ? sidecarConfigs : []) if (!rightRelease(stage, ["stage-sidecar", "--config", path.join(root, config)])) { ok = false; break; }
1540
- if (ok) ok = (await (o.runCompile ?? (() => main(ds ? ["--all", "--commit", commit, "--snapshot", ds.path] : ["--all"], { ...io, cwd: root, readStdin: () => "", ...(ds ? { buildOwnership: ds.own, onDirectReceipt: (r) => { directReceipt = r; } } : {}) })))()) === 0;
1438
+ // On the snapshot route sidecars are staged ONCE per door run by the ledger bookkeeping (attribution diff, immutable staged entries).
1439
+ if (ok && ds) ok = await stageSidecarsOnce({ o, ds, root, configs: sidecarConfigs, appDir, cli, commonDir, commit, tree, stage });
1440
+ else for (const config of ok ? sidecarConfigs : []) if (!rightRelease(stage, ["stage-sidecar", "--config", path.join(root, config)])) { ok = false; break; }
1441
+ if (ok) ok = (await (o.runCompile ?? (() => main(ds ? ["--all", "--commit", commit, "--snapshot", ds.path] : ["--all"], { ...io, cwd: root, readStdin: () => "", ...(ds ? { buildOwnership: ds.own, snapshotLedger: ds.ledger?.entries ?? [], onCompileLedger: (entries) => { ds.compileAdmitted = entries; }, onDirectReceipt: (r) => { directReceipt = r; } } : {}) })))()) === 0;
1541
1442
  if (directReceipt) extra = { ...extra, direct: directReceipt };
1542
- }
1543
- else if (stage === "package" && ds) {
1544
- o.err(`${tag} BLOCKED: [package] right-release builds and seals from the checkout; building from a commit snapshot waits for the right-release source split (joint plan step 4c, second part); nothing was built`);
1545
- ok = false;
1443
+ // The cross-check with `.rightkit/compile-inputs.json`: a declared input that covers ledger entries must carry the digest of those
1444
+ // very bytes in the compile receipt (the broker digests declared inputs in place in the snapshot, BC:39).
1445
+ if (ok && ds && directReceipt?.declaredInputDigests) {
1446
+ const { declaredInputProblems } = await import("../stage-ledger.mjs");
1447
+ const { judgeCompileInputs } = await import("../compile-inputs.mjs");
1448
+ const bad = declaredInputProblems({ snapshot: ds.path, entries: ds.ledger?.entries ?? [], receiptDigests: directReceipt.declaredInputDigests, judge: (dir) => judgeCompileInputs(dir) });
1449
+ if (bad.length) { for (const p of bad.slice(0, 10)) o.err(`${tag} RK_CHECK_ERROR: [compile] ${p}`); ok = false; }
1450
+ }
1546
1451
  }
1547
1452
  else if (stage === "package") {
1453
+ // On the snapshot route the sidecars are staged ONCE per door run, before the first stage that builds the Tauri crate: here when
1454
+ // compile was already passed (a separately invoked `--to package` re-stages after the sync, so it never meets a missing externalBin).
1455
+ // right-release then takes its identity from the selected commit and the verified snapshot (release-source-identity.mjs, row 7.04),
1456
+ // never the checkout's HEAD, status or branch; its outputs, state and the sealed directory stay in the checkout's `.right-release`.
1457
+ ok = ds ? await stageSidecarsOnce({ o, ds, root, configs: sidecarConfigsOf(), appDir, cli, commonDir, commit, tree, stage, executor }) : true;
1548
1458
  // Source QA (right-release qa --stage source) exists for mac and win only; a mobile package is the signed build alone.
1549
- ok = (mobile || rightRelease(stage, ["qa", "--stage", "source", "--platform", platform])) && rightRelease(stage, ["build", "--platform", platform]);
1459
+ // Row 7.04c (R-T1, 7.09): the snapshot holds no Cargo target. On a direct host the checkout's output directory is resolved here, in the primary
1460
+ // checkout, and handed to right-release absolute: it binds every manifest's target for this stage, and the sealed manifest must record it back
1461
+ // (the door never trusts the child's value alone). One workspace is pinned as a whole; several are refused, naming them. A broker host's target
1462
+ // is the carrier's (rows 7.20 to 7.24), which this gate does not call yet.
1463
+ let packagePin = { dir: null };
1464
+ if (ok && ds && executor.kind !== "broker") {
1465
+ packagePin = cargoOutputPin({ o, git, root, commit, executor });
1466
+ if (packagePin.refusal) { o.err(`${tag} BLOCKED: RK_SNAPSHOT_OUTPUT_UNPINNED: [package] ${packagePin.refusal}; nothing was built`); ok = false; }
1467
+ else if (packagePin.dir) o.out(`${tag} [package] output directory for the Cargo workspace: ${packagePin.dir} (the checkout's)`);
1468
+ }
1469
+ const packageEnv = packagePin.dir ? { CARGO_TARGET_DIR: packagePin.dir } : {};
1470
+ ok = ok && (mobile || rightRelease(stage, ["qa", "--stage", "source", "--platform", platform], packageEnv)) && rightRelease(stage, ["build", "--platform", platform], packageEnv);
1550
1471
  const sealed = ok ? (o.sealedFor ?? sealedFor)({ root: appRoot, commit, platform }) : null;
1551
1472
  if (ok && !sealed) { o.err(`${tag} [package] the build reported success but sealed nothing for ${commit.slice(0, 8)} on ${platform}`); ok = false; }
1552
1473
  if (sealed) extra = { platform, sealed: sealed.id, manifestSha256: sealed.digest };
1474
+ // The cross-check: the Cargo targets the sealed manifest records are the directory this door resolved itself. A different one voids the stage.
1475
+ if (ok && sealed && ds && packagePin.dir) {
1476
+ let recorded = null;
1477
+ try { recorded = JSON.parse(String(o.readFile(sealed.manifest))).boundTargets ?? null; } catch { recorded = null; }
1478
+ const { crossCheckBoundTargets, SPLIT_TARGET_DRIFT_CODE } = await import("../release-artifact-path.mjs");
1479
+ const drift = crossCheckBoundTargets({ recorded, expected: [packagePin.dir] });
1480
+ if (drift.length) {
1481
+ for (const problem of drift.slice(0, 10)) o.err(`${tag} ${SPLIT_TARGET_DRIFT_CODE}: [package] ${problem}`);
1482
+ const closedDrift = await closeSnapshotStage({ o, ds, root, stage, step: stage, tag });
1483
+ receipt.stages[stage] = { ok: false, at, voided: SPLIT_TARGET_DRIFT_CODE, ...extra, snapshot: { path: ds.path, tree: ds.tree, intactAfter: closedDrift.ok }, ownership: ds.own.handle };
1484
+ save();
1485
+ o.err(`${tag} --to ${to} stopped at ${stage}: ${SPLIT_TARGET_DRIFT_CODE}; the sealed set is void and nothing after it ran`);
1486
+ return 2;
1487
+ }
1488
+ }
1553
1489
  } else {
1554
1490
  const sealed = receipt.stages.package?.ok ? receipt.stages.package : null;
1555
1491
  const current = sealed ? (o.sealedFor ?? sealedFor)({ root: appRoot, commit, platform }) : null;
@@ -1579,12 +1515,13 @@ async function runStages(o, args, root, git, io) {
1579
1515
  }
1580
1516
  }
1581
1517
  if (ds) {
1582
- // The stage covers the commit only if the snapshot is still its tree and the host is still this door's (E6).
1583
- let intact = ds.own.held();
1584
- if (intact) { try { (o.verifySnapshot ?? (await import("../snapshot.mjs")).verifySnapshot)({ checkout: root, rev: commit, root: ds.root, dir: ds.path }); } catch (e) { intact = false; o.err(`${tag} RK_CHECK_ERROR: [${stage}] ${e.message}`); } }
1585
- else o.err(`${tag} RK_CHECK_ERROR: [${stage}] the build ownership ${ds.own.handle} is no longer held`);
1586
- extra = { ...extra, snapshot: { path: ds.path, tree: ds.tree, intactAfter: intact }, ownership: ds.own.handle };
1587
- if (!intact) { receipt.stages[stage] = { ok: false, at, ...extra }; save(); o.err(`${tag} --to ${to} stopped at ${stage}: the result covers no known bytes`); return 2; }
1518
+ // The stage covers the commit only if the snapshot is still its tree plus the ledger and the host is still this door's (E6, 7.08):
1519
+ // what the stage's children wrote may remain only as ledger entries, recorded by this gate's own walk and kept in the receipt (never
1520
+ // in the snapshot); a staged entry changed since staging voids the stage.
1521
+ const closed = await closeSnapshotStage({ o, ds, root, stage, step: stage, tag });
1522
+ const { receiptLedger } = await import("../stage-ledger.mjs");
1523
+ extra = { ...extra, snapshot: { path: ds.path, tree: ds.tree, intactAfter: closed.ok, ledger: receiptLedger(closed.entries) }, ownership: ds.own.handle };
1524
+ if (!closed.ok) { receipt.stages[stage] = { ok: false, at, ...extra }; save(); o.err(`${tag} --to ${to} stopped at ${stage}: the result covers no known bytes`); return 2; }
1588
1525
  }
1589
1526
  receipt.stages[stage] = { ok, at, ...extra };
1590
1527
  save();
@@ -1649,11 +1586,263 @@ function stageForPublisher(o, args, root, git) {
1649
1586
  } finally { fs.rmSync(work, { recursive: true, force: true }); }
1650
1587
  }
1651
1588
 
1589
+ // ---- the stage-output ledger (stage-ledger.mjs; decision 2 Q1, rows 7.05/7.05b/7.07/7.08) ----------------------------------------
1590
+
1591
+ /** What the snapshot holds beyond the commit right now: { entries, problems } by this gate's own walk. */
1592
+ async function walkDoorSnapshot(o, ds, root) {
1593
+ const snap = await import("../snapshot.mjs");
1594
+ const tracked = new Set((o.treeEntries ?? snap.treeEntries)(root, ds.commit).map((e) => e.path));
1595
+ const dependencyRoots = (o.dependencyDirs ?? snap.dependencyDirs)((o.treeEntries ?? snap.treeEntries)(root, ds.commit), o.platform);
1596
+ const { walkSnapshot } = await import("../stage-ledger.mjs");
1597
+ return (o.walkSnapshot ?? walkSnapshot)({ dir: ds.path, tracked, dependencyRoots, env: o.env, platform: o.platform });
1598
+ }
1599
+
1600
+ /** `verifySnapshot` with the ledger (item 5 row 5.09d: `ledger` admits exactly these entries, `admitNew` only at a stage's end). */
1601
+ async function verifyDoorSnapshot(o, ds, root, { ledger, admitNew = false }) {
1602
+ const verify = o.verifySnapshot ?? (await import("../snapshot.mjs")).verifySnapshot;
1603
+ return verify({ checkout: root, rev: ds.commit, root: ds.root, dir: ds.path, ledger, admitNew });
1604
+ }
1605
+
1652
1606
  /**
1653
- * rightkit-gate --to dev|test [-- <right-release dev|test arguments>]: the door for the local compiles that work on the uncommitted tree.
1654
- * Class A runs over the working tree first (nothing compiles while it is red), then `right-release dev|test` runs under a live door
1655
- * 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
1656
- * checks again), plain cargo on an unmanaged host, never around a managed host's broker.
1607
+ * The start of a stage: the snapshot is exactly tree + dependencies + the ledger of the stages before it, no entry new, changed or
1608
+ * missing, and the host's ownership is still held (7.08, decision 2 Q1). An ignored file planted before the stage is a new entry here.
1609
+ */
1610
+ export async function openSnapshotStage({ o, ds, root, stage, tag = "rightkit-gate:" }) {
1611
+ if (!ds.own.held()) { o.err(`${tag} RK_CHECK_ERROR: [${stage}] the build ownership ${ds.own.handle} is no longer held`); return false; }
1612
+ const { diffEntries, sameEntries } = await import("../stage-ledger.mjs");
1613
+ let walked;
1614
+ try { walked = await walkDoorSnapshot(o, ds, root); } catch (e) { o.err(`${tag} RK_CHECK_ERROR: [${stage}] ${e.message}`); return false; }
1615
+ const prior = ds.ledger?.entries ?? [];
1616
+ if (walked.problems.length) { for (const p of walked.problems.slice(0, 10)) o.err(`${tag} RK_CHECK_ERROR: [${stage}] before the stage: ${p}`); return false; }
1617
+ if (!sameEntries(prior, walked.entries)) {
1618
+ const d = diffEntries(prior, walked.entries);
1619
+ o.err(`${tag} RK_CHECK_ERROR: [${stage}] before the stage the snapshot differs from the ledger of the stages before it (new: ${d.added.join(", ") || "none"}; changed: ${d.changed.join(", ") || "none"}; missing: ${d.removed.join(", ") || "none"})`);
1620
+ return false;
1621
+ }
1622
+ try { await verifyDoorSnapshot(o, ds, root, { ledger: prior }); } catch (e) { o.err(`${tag} RK_CHECK_ERROR: [${stage}] before the stage: ${e.message}`); return false; }
1623
+ return true;
1624
+ }
1625
+
1626
+ /**
1627
+ * The end of a stage: what its children wrote may remain only as ledger entries. A staged entry is immutable for the rest of the run
1628
+ * (the bytes the compile read are the bytes sealed); a generated entry may be rewritten or removed and is re-recorded here. Returns
1629
+ * `{ ok, entries }` and replaces the door's ledger with the entries just recorded.
1630
+ */
1631
+ export async function closeSnapshotStage({ o, ds, root, stage, step = "stage", tag = "rightkit-gate:" }) {
1632
+ if (!ds.own.held()) { o.err(`${tag} RK_CHECK_ERROR: [${stage}] the build ownership ${ds.own.handle} is no longer held`); return { ok: false, entries: ds.ledger?.entries ?? [] }; }
1633
+ const { entryKey } = await import("../stage-ledger.mjs");
1634
+ let walked;
1635
+ try { walked = await walkDoorSnapshot(o, ds, root); } catch (e) { o.err(`${tag} RK_CHECK_ERROR: [${stage}] ${e.message}`); return { ok: false, entries: ds.ledger?.entries ?? [] }; }
1636
+ const prior = new Map((ds.ledger?.entries ?? []).map((e) => [e.path, e]));
1637
+ const now = new Map(walked.entries.map((e) => [e.path, e]));
1638
+ const problems = [...walked.problems];
1639
+ for (const [p, e] of prior) if (e.kind === "staged" && (!now.has(p) || entryKey(now.get(p)) !== entryKey(e))) problems.push(`${p} is a staged sidecar output and ${now.has(p) ? "changed" : "was removed"} after it was staged (a staged entry is immutable for the door run)`);
1640
+ if (problems.length) { for (const p of problems.slice(0, 10)) o.err(`${tag} RK_CHECK_ERROR: [${stage}] ${p}`); return { ok: false, entries: ds.ledger?.entries ?? [] }; }
1641
+ // The compile's own verify (snapshot-check.mjs, 5.09d) returned the entries it admitted: they must be the entries this walk found.
1642
+ if (Array.isArray(ds.compileAdmitted) && stage === "compile") {
1643
+ const { admittedMismatch } = await import("../stage-ledger.mjs");
1644
+ const bad = admittedMismatch(ds.compileAdmitted, walked.entries);
1645
+ if (bad) { o.err(`${tag} RK_CHECK_ERROR: [${stage}] the compile's snapshot verify admitted other ledger entries than the door's own walk recorded: ${bad}`); return { ok: false, entries: ds.ledger?.entries ?? [] }; }
1646
+ }
1647
+ ds.compileAdmitted = null;
1648
+ const entries = walked.entries.map((e) => {
1649
+ const was = prior.get(e.path);
1650
+ if (was?.kind === "staged") return { ...e, kind: "staged", producer: was.producer };
1651
+ return { ...e, kind: "generated", producer: was && entryKey(was) === entryKey(e) ? was.producer : { stage, step } };
1652
+ });
1653
+ try {
1654
+ const verified = await verifyDoorSnapshot(o, ds, root, { ledger: entries, admitNew: true });
1655
+ // The verify side (item 5 row 5.09d) returns the entries it admitted; if it does, they must be the ones this walk recorded.
1656
+ if (Array.isArray(verified?.ledger)) {
1657
+ const { admittedMismatch } = await import("../stage-ledger.mjs");
1658
+ const bad = admittedMismatch(verified.ledger, entries);
1659
+ if (bad) throw new Error(`the snapshot verify admitted other ledger entries than the door's own walk recorded: ${bad}`);
1660
+ }
1661
+ } catch (e) { o.err(`${tag} RK_CHECK_ERROR: [${stage}] ${e.message}`); return { ok: false, entries: ds.ledger?.entries ?? [] }; }
1662
+ ds.ledger = { entries };
1663
+ return { ok: true, entries };
1664
+ }
1665
+
1666
+ /**
1667
+ * Sidecars are staged ONCE per door run, after the sync and before the first stage that builds the Tauri crate. The step is
1668
+ * right-release's own `stage-sidecar --json` run inside the snapshot (the split); this gate then walks the snapshot itself and the
1669
+ * step's additions must equal the files it reported plus its receipt (anything else is an unattributed write, RK_CHECK_ERROR). The
1670
+ * staged files become immutable `staged` ledger entries. Returns true when nothing is owed or staging passed.
1671
+ */
1672
+ export async function stageSidecarsOnce({ o, ds, root, configs, appDir, cli, commonDir, commit, tree, stage, executor = null, tag = "rightkit-gate:" }) {
1673
+ if (ds.sidecarsStaged) return true;
1674
+ ds.sidecarsStaged = true;
1675
+ if (!configs.length) return true;
1676
+ if (executor?.kind === "broker") { o.err(`${tag} BLOCKED: RK_SNAPSHOT_CARRIER_NOT_WIRED: [${stage}] ${configs.join(", ")} stage sidecars through right-release, whose Cargo calls reach the RustRight broker only through the snapshot carrier (item 7 row 7.24); nothing was staged`); return false; }
1677
+ const { attributionProblems, reportedPaths } = await import("../stage-ledger.mjs");
1678
+ const source = sourceSplitOf({ root, ds, appDir });
1679
+ const { TAURI_CONFIG: inherited, ...cleanEnv } = o.env; void inherited; // never set or pass one (refused at the door's start)
1680
+ for (const config of configs) {
1681
+ const before = ds.ledger?.entries ?? [];
1682
+ const run = startRun({ commonDir, commit, tree, stage, source });
1683
+ let r;
1684
+ try {
1685
+ o.out(`${tag} [${stage}] right-release stage-sidecar --config ${path.join(ds.path, config)} (from the snapshot ${ds.path})`);
1686
+ r = (o.spawnCapture ?? ((cmd, a, opts) => spawnInherit(cmd, a, { ...opts, encoding: "utf8", maxBuffer: 1 << 26 })))(process.execPath, [cli, "stage-sidecar", "--config", path.join(ds.path, config), "--json"], { cwd: snapshotAppRootOf(ds.path, appDir), env: { ...cleanEnv, ...run.env } });
1687
+ } finally { endRun(run); }
1688
+ if (r?.stderr) o.err(String(r.stderr).trimEnd());
1689
+ if (!r || r.status !== 0) { o.err(`${tag} [${stage}] stage-sidecar for ${config} failed${r?.error ? ` (${r.error.message})` : ""}`); return false; }
1690
+ let result;
1691
+ try { result = JSON.parse(String(r.stdout ?? "").trim()); } catch { o.err(`${tag} RK_CHECK_ERROR: [${stage}] stage-sidecar --json for ${config} did not print its result; its writes cannot be attributed`); return false; }
1692
+ let walked;
1693
+ try { walked = await walkDoorSnapshot(o, ds, root); } catch (e) { o.err(`${tag} RK_CHECK_ERROR: [${stage}] ${e.message}`); return false; }
1694
+ const underApp = (rel) => (appDir === "." ? rel : `${appDir}/${rel}`).replaceAll("\\", "/");
1695
+ const receiptAbs = typeof result.receiptPath === "string" ? path.relative(ds.path, result.receiptPath).split(path.sep).join("/") : null;
1696
+ const problems = [...walked.problems, ...attributionProblems({ before, after: walked.entries, reported: [...reportedPaths(result)].map(underApp), receipt: receiptAbs })];
1697
+ if (problems.length) { for (const p of problems.slice(0, 10)) o.err(`${tag} RK_CHECK_ERROR: [${stage}] stage-sidecar ${config}: ${p}`); return false; }
1698
+ const priorPaths = new Set(before.map((e) => e.path));
1699
+ const priorByPath = new Map(before.map((e) => [e.path, e]));
1700
+ ds.ledger = { entries: walked.entries.map((e) => (priorPaths.has(e.path) && priorByPath.get(e.path).sha256 === e.sha256 ? priorByPath.get(e.path) : { ...e, kind: "staged", producer: { stage: "sidecar", step: config } })) };
1701
+ }
1702
+ return true;
1703
+ }
1704
+
1705
+ /**
1706
+ * The Cargo binding of a unit stage built from a snapshot (row 7.06a; decision 2 Q3, as for any npm unit): a unit's test or pack that
1707
+ * reaches Cargo must never write `target/` into the snapshot. Every tracked Cargo.toml of the commit is asked, in the CHECKOUT, for its
1708
+ * workspace and `target_directory`. Linux and any POSIX host with a direct executor put the runtime wrapper (cargo-target-shim.mjs)
1709
+ * first on PATH and name it in CARGO, so each cargo call binds its own workspace's checkout target or is refused. A broker host
1710
+ * (`o.snapshotBroker`) uses the same wrapper in broker mode, one carried request per call with its own receipt. A direct Windows host has
1711
+ * no wrapper: one workspace is pinned by CARGO_TARGET_DIR, several are refused. Returns `{ env, note, cleanup }` or `{ refusal }`.
1712
+ */
1713
+ async function bindUnitCargo({ o, git, root, ds, executor, which }) {
1714
+ const listed = git("ls-tree", "-r", "--name-only", "-z", ds.commit);
1715
+ if (failed(listed)) return { refusal: `cannot list the files of ${ds.commit.slice(0, 12)}: ${why(listed)}` };
1716
+ const manifests = text(listed).split("\0").filter((f) => f === "Cargo.toml" || f.endsWith("/Cargo.toml"));
1717
+ if (!manifests.length) return { env: {}, note: null, cleanup() {} };
1718
+ let checkoutReal = root; try { checkoutReal = fs.realpathSync.native(root); } catch { /* as given */ }
1719
+ const cargo = executor.cargo ?? ["cargo"];
1720
+ const targetDirs = {};
1721
+ for (const m of manifests) {
1722
+ const file = path.join(root, ...m.split("/"));
1723
+ if (!o.exists(file)) return { refusal: `the checkout has no ${m}, so its Cargo output directory cannot be resolved` };
1724
+ const r = o.spawn(cargo[0], [...cargo.slice(1), "metadata", "--offline", "--format-version", "1", "--no-deps", "--manifest-path", file], { cwd: path.dirname(file), env: o.env });
1725
+ let doc = null;
1726
+ try { doc = failed(r) ? null : JSON.parse(text(r)); } catch { doc = null; }
1727
+ if (!doc || typeof doc.target_directory !== "string" || !path.isAbsolute(doc.target_directory) || typeof doc.workspace_root !== "string") return { refusal: `cargo metadata in the checkout reported no absolute target_directory for ${m}${failed(r) ? ` (${why(r)})` : ""}` };
1728
+ const cargoDir = posix(path.relative(checkoutReal, (() => { try { return fs.realpathSync.native(doc.workspace_root); } catch { return doc.workspace_root; } })())) || ".";
1729
+ targetDirs[cargoDir] = path.resolve(doc.target_directory);
1730
+ }
1731
+ const workspaces = Object.keys(targetDirs);
1732
+ if (o.platform === "win32" && executor.kind !== "broker") {
1733
+ if (workspaces.length > 1) return { refusal: `the commit holds ${workspaces.length} Cargo workspaces (${workspaces.join(", ")}) and a direct Windows host has no per-call wrapper to bind each to its own target directory` };
1734
+ return { env: { CARGO_TARGET_DIR: Object.values(targetDirs)[0] }, note: `output directory for the Cargo workspace: ${Object.values(targetDirs)[0]} (the checkout's)`, cleanup() {} };
1735
+ }
1736
+ const shimModule = await import("../cargo-target-shim.mjs");
1737
+ let broker = null; let realCargo = null;
1738
+ if (executor.kind === "broker") {
1739
+ if (!o.snapshotBroker) return { refusal: "a broker host reaches Cargo from a unit stage only through the snapshot carrier (deps.snapshotBroker), which is not wired" };
1740
+ const sb = o.snapshotBroker;
1741
+ const marked = sb.markBrokerSubmit?.();
1742
+ if (marked?.broker?.submitted !== true || marked.handle !== sb.handle) return { refusal: `the build ownership did not record the broker submit${sb.handle ? ` under ${sb.handle}` : ""}` };
1743
+ const { brokerSection } = await import("../snapshot-broker.mjs");
1744
+ broker = { checkoutDirs: Object.fromEntries(workspaces.map((w) => [w, path.join(checkoutReal, ...(w === "." ? [] : w.split("/")))])), section: (o.brokerSection ?? brokerSection)(sb) };
1745
+ } else {
1746
+ realCargo = which("cargo").find((c) => path.isAbsolute(c));
1747
+ if (!realCargo) return { refusal: "no cargo on PATH to wrap for the unit stages" };
1748
+ }
1749
+ let installed; let dir = null;
1750
+ try { installed = (o.installCargoShim ?? shimModule.installCargoShim)({ base: os.tmpdir(), snapshotRoot: ds.path, targetDirs, broker, realCargo, platform: o.platform, onDir: (d) => { dir = d; } }); }
1751
+ catch (error) { if (dir) fs.rmSync(dir, { recursive: true, force: true }); return { refusal: `the runtime cargo wrapper cannot be installed (${error.message})` }; }
1752
+ const scan = shimModule.shimScanEnv(o.env, installed);
1753
+ const delta = Object.fromEntries(Object.entries(scan).filter(([k, v]) => o.env[k] !== v));
1754
+ return {
1755
+ env: delta, note: `runtime cargo wrapper ${installed.shim} (${installed.mode}): each cargo call a unit stage reaches binds its workspace's checkout target directory or is refused`,
1756
+ // Every call the wrapper decided is in the gate-owned ledger: a refused or unverified call fails the stage even when the test ignored cargo's exit (7.10, decision C).
1757
+ check: async () => { const { readLedger, UNSCOPED } = await import("../cargo-ledger.mjs"); return readLedger(installed.ledgerDir, UNSCOPED).problems ?? []; },
1758
+ cleanup: () => { fs.rmSync(installed.dir ?? dir, { recursive: true, force: true }); },
1759
+ };
1760
+ }
1761
+
1762
+ /**
1763
+ * `--to <stage> --unit <selector> [--commit <rev>]` on the snapshot route (row 7.06a): select a commit (HEAD by default), take the
1764
+ * host's build ownership, sync its snapshot, run class A of the snapshot (`check --commit <sha> --full-a --snapshot <dir>`), and build
1765
+ * the unit from the snapshot (`--workspace <snapshot>`). Commit and tree come from the selection; no HEAD or status is read. The unit
1766
+ * manifest and the local-build grant are read from the selected commit, so one present only in the checkout is refused. Each stage is
1767
+ * bracketed by the ledger verify (7.08); Cargo's output is pinned to the checkout's target directory (7.09); `--to release` keeps its
1768
+ * publication path and takes only commit and tree from the selection.
1769
+ */
1770
+ async function runUnitSnapshot(o, args, root, git, io, option) {
1771
+ const tag = "rightkit-gate:";
1772
+ const selector = option("--unit");
1773
+ if (o.env.TAURI_CONFIG !== undefined) { o.err(`${tag} BLOCKED: RK_TAURI_CONFIG_OVERRIDE: TAURI_CONFIG is set in the environment; a commit-only build refuses it; nothing ran`); return 1; }
1774
+ const match = /^(?:npm:@rightkit\/([a-z0-9-]+)|cargo:(rightkit-[a-z0-9-]+))$/.exec(selector);
1775
+ const manifest = match && (match[1] ? `packages/${match[1]}/package.json` : `crates/${match[2]}/Cargo.toml`);
1776
+ if (!manifest) { o.err(`${tag} BLOCKED: unit needs npm:@rightkit/<name> or cargo:rightkit-<name>`); return 1; }
1777
+ const snap = await import("../snapshot.mjs");
1778
+ const { acquireBuildOwnership, ownershipFile } = await import("../build-ownership.mjs");
1779
+ let selected;
1780
+ try { selected = snap.resolveCommit(root, option("--commit") ?? "HEAD"); }
1781
+ catch (e) { o.err(`${tag} cannot resolve ${option("--commit") ?? "HEAD"} to a commit: ${e.message}`); return 2; }
1782
+ const commit = selected.commit;
1783
+ // The canonical manifest and the local-build grant must be tracked in the SELECTED COMMIT (read with the object database, never the checkout).
1784
+ for (const file of [".rightkit-local-build.json", manifest]) if (failed(git("cat-file", "-e", `${commit}:${file}`))) { o.err(`${tag} BLOCKED: unit and local grant must be tracked canonical files of ${commit.slice(0, 12)} (${file} is not)`); return 1; }
1785
+ 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); };
1786
+ const executor = (o.resolveExecutor ?? resolveExecutor)({ env: o.env, platform: o.platform, exists: o.exists, which, readFile: o.readFile }); applyExecutorEnv(o, executor);
1787
+ if (executor.kind === "refuse") { o.err(`${tag} BLOCKED: ${executor.problem}`); return 1; }
1788
+ if (executor.kind === "broker" && !o.snapshotBroker) { o.err(`${tag} BLOCKED: RK_SNAPSHOT_CARRIER_NOT_WIRED: a broker host builds a unit snapshot only through the snapshot carrier; nothing ran`); return 1; }
1789
+ const common = git("rev-parse", "--git-common-dir");
1790
+ if (failed(common) || !text(common).trim()) { o.err(`${tag} cannot read the git directory`); return 2; }
1791
+ const snapshotsAt = o.snapshotRoot ?? snap.snapshotRoot({ platform: o.platform, env: o.env });
1792
+ let own;
1793
+ try { own = (o.acquireOwnership ?? acquireBuildOwnership)({ file: ownershipFile({ platform: o.platform, beside: o.snapshotRoot }), stage: `door:unit:${option("--to")}`, snapshot: path.join(snapshotsAt, snap.repositoryOf(root).key), commit, platform: o.platform, env: o.env }); }
1794
+ catch (e) { if (e.code !== "busy" && e.code !== "refused") throw e; o.err(`${tag} BLOCKED: ${e.message}; nothing ran${e.code === "refused" ? ", nothing created" : ""}`); return 1; }
1795
+ o.out(`${tag} build ownership ${own.joined ? "joined" : "held"} ${own.handle}`);
1796
+ try {
1797
+ let synced;
1798
+ try { synced = (o.syncSnapshot ?? snap.syncSnapshot)({ checkout: root, rev: commit, root: snapshotsAt }); }
1799
+ catch (e) { o.err(`${tag} ${e.code === "error" ? "RK_CHECK_ERROR" : "BLOCKED"}: snapshot ${e.message}; nothing ran`); return e.code === "error" ? 2 : 1; }
1800
+ o.out(`${tag} snapshot ${synced.path} is commit ${synced.commit} tree ${synced.tree} (written=${synced.written} removed=${synced.removed} unchanged=${synced.unchanged})`);
1801
+ const ds = { own, path: synced.path, commit: synced.commit, tree: synced.tree, root: snapshotsAt, ledger: { entries: [] } };
1802
+ // A unit's test or pack that reaches Cargo must never write `target/` into the snapshot (npm units included): see bindUnitCargo.
1803
+ const bound = await bindUnitCargo({ o, git, root, ds, executor, which });
1804
+ if (bound.refusal) { o.err(`${tag} BLOCKED: RK_SNAPSHOT_OUTPUT_UNPINNED: ${bound.refusal}; nothing ran`); return 1; }
1805
+ if (bound.note) o.out(`${tag} ${bound.note}`);
1806
+ const envExtra = bound.env;
1807
+ try {
1808
+ let checkout = root; try { checkout = fs.realpathSync.native(root); } catch { /* as given */ }
1809
+ const source = { checkout, commit: ds.commit, tree: ds.tree };
1810
+ const reportOut = option("--receipt-out");
1811
+ const { runUnitGate, resolveUnit } = await import("../unit-gate.mjs");
1812
+ const { localBuildGrantedAt } = await import("../local-publish.mjs");
1813
+ return await runUnitGate({ selector, to: option("--to"), root: ds.path, commonDir: path.resolve(root, text(common).trim()), commit: ds.commit, tree: ds.tree, executor, env: o.env, platform: o.platform, source, envExtra,
1814
+ resolve: (sel, r) => resolveUnit(sel, r, { granted: () => localBuildGrantedAt(source) }),
1815
+ ...(o.loadUnitOwner ? { loadOwner: o.loadUnitOwner } : {}),
1816
+ stageGuard: { start: (stage) => openSnapshotStage({ o, ds, root, stage: `unit:${stage}`, tag }), end: async (stage) => {
1817
+ if (!(await closeSnapshotStage({ o, ds, root, stage: `unit:${stage}`, step: `unit:${stage}`, tag })).ok) return false;
1818
+ const calls = bound.check ? await bound.check() : [];
1819
+ for (const p of calls.slice(0, 10)) o.err(`${tag} RK_CHECK_ERROR: [unit:${stage}] a cargo call the unit stage made did not complete: ${typeof p === "string" ? p : JSON.stringify(p)}`);
1820
+ return calls.length === 0;
1821
+ } },
1822
+ check: (tier) => (o.runCheck ?? (() => main(["check", "--commit", ds.commit, "--full-a", "--snapshot", ds.path, "--rule-tier", tier], { ...io, cwd: root, buildOwnership: own })))(tier), out: o.out, err: o.err,
1823
+ ...(reportOut ? { writeReport: (report) => o.writeFile(path.resolve(o.cwd, reportOut), `${JSON.stringify(report, null, 2)}\n`) } : {}),
1824
+ });
1825
+ } finally { bound.cleanup(); }
1826
+ } finally {
1827
+ const r = own.release();
1828
+ if (r?.released === false && !own.joined) o.err(`${tag} build ownership ${own.handle} kept: ${r.why}`);
1829
+ }
1830
+ }
1831
+
1832
+ /**
1833
+ * Whether the door takes the snapshot route on this host. Linux always does (direct, no broker); a broker host (Mac, Dell) does only
1834
+ * when the snapshot carrier is wired (`deps.snapshotBroker`). Until the cutover commit wires it in production, that is tests only;
1835
+ * every other Mac or Dell run keeps the checkout route (decision 2 "Interim"). No flag or environment variable selects it.
1836
+ */
1837
+ export function snapshotRouteSelected(o) {
1838
+ return directPlatform(o.platform) || Boolean(o.snapshotBroker);
1839
+ }
1840
+
1841
+ /**
1842
+ * rightkit-gate --to dev|test [-- <right-release dev|test arguments>] on the CHECKOUT route (Mac and Dell until the cutover commit):
1843
+ * class A over the working tree first (nothing compiles while it is red), then `right-release dev|test` runs under a live door run,
1844
+ * 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
1845
+ * checks again), plain cargo on an unmanaged host, never around a managed host's broker. The cutover commit deletes this function.
1657
1846
  */
1658
1847
  async function runWorktreeCompile(o, args, root, git, io, to, appRoot = root) {
1659
1848
  const tag = "rightkit-gate:";
@@ -1689,6 +1878,121 @@ async function runWorktreeCompile(o, args, root, git, io, to, appRoot = root) {
1689
1878
  } finally { endRun(run); }
1690
1879
  }
1691
1880
 
1881
+ /** The app's root inside the snapshot: `<snapshot>/<appDir>`, the snapshot itself for an app at the repository root. */
1882
+ export function snapshotAppRootOf(snapshotDir, appDir = ".") {
1883
+ return appDir === "." ? snapshotDir : path.join(snapshotDir, ...appDir.split("/"));
1884
+ }
1885
+
1886
+ /** What the door run records for a right-release child (source-split.mjs): the checkout it ran in and the snapshot it may read. */
1887
+ function sourceSplitOf({ root, ds, appDir }) {
1888
+ let checkout = root;
1889
+ try { checkout = fs.realpathSync.native(root); } catch { /* the path as given */ }
1890
+ return { checkout, snapshot: ds.path, commit: ds.commit, tree: ds.tree, appDir };
1891
+ }
1892
+
1893
+ /**
1894
+ * Why the snapshot route cannot start on this host, or null. The route is the executor's: `direct` hosts (Linux always, any host
1895
+ * with no broker) take it as they are. A broker host compiles the snapshot only through the RustRight snapshot carrier, which the
1896
+ * gate reaches through `deps.snapshotBroker` (item 7 rows 7.20 to 7.24, wired in production by the carrier lane); without it the
1897
+ * host is refused (RK_SNAPSHOT_CARRIER_NOT_WIRED), and a broker that cannot be reached is refused too (RK_SNAPSHOT_BROKER_UNAVAILABLE).
1898
+ * Neither ever falls back to compiling the checkout.
1899
+ */
1900
+ export function snapshotRouteRefusal(o, { rightRelease = false } = {}) {
1901
+ 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); };
1902
+ const executor = (o.resolveExecutor ?? resolveExecutor)({ env: o.env, platform: o.platform, exists: o.exists, which, readFile: o.readFile });
1903
+ if (executor.kind === "refuse") return { code: "RK_SNAPSHOT_BROKER_UNAVAILABLE", message: executor.problem };
1904
+ // right-release's own children (dev, test) reach Cargo with no carrier of their own on a broker host (row 7.24): refused even when the gate has one.
1905
+ if (executor.kind === "broker" && rightRelease) return { code: "RK_SNAPSHOT_CARRIER_NOT_WIRED", message: `right-release dev and test reach Cargo on a ${o.platform} broker host only through the snapshot carrier's per-call wrapper (item 7 row 7.24), which is not wired` };
1906
+ if (executor.kind === "broker" && !o.snapshotBroker) return { code: "RK_SNAPSHOT_CARRIER_NOT_WIRED", message: `a ${o.platform} host compiles a commit snapshot only through the RustRight snapshot carrier (item 7 rows 7.20 to 7.24), which this gate does not carry yet` };
1907
+ return null;
1908
+ }
1909
+
1910
+ /**
1911
+ * The checkout's Cargo output directory for the snapshot of `commit`: `{ dir }` (null when the commit has no Cargo manifest) or
1912
+ * `{ refusal }`. Each tracked Cargo.toml of the commit is asked, in the CHECKOUT with the stage's environment, for its
1913
+ * `target_directory` (which honours CARGO_TARGET_DIR and `.cargo/config.toml` build.target-dir) and its workspace root; one workspace
1914
+ * yields its directory, several are refused rather than pointed at one shared directory (GATE-CONTRACT: mixed workspaces never share a target).
1915
+ */
1916
+ function cargoOutputPin({ o, git, root, commit, executor }) {
1917
+ const listed = git("ls-tree", "-r", "--name-only", "-z", commit);
1918
+ if (failed(listed)) return { refusal: `cannot list the files of ${commit.slice(0, 12)}: ${why(listed)}` };
1919
+ const manifests = text(listed).split("\0").filter((f) => f === "Cargo.toml" || f.endsWith("/Cargo.toml"));
1920
+ const cargo = executor.cargo ?? ["cargo"];
1921
+ const workspaces = new Map();
1922
+ for (const m of manifests) {
1923
+ const file = path.join(root, ...m.split("/"));
1924
+ if (!o.exists(file)) return { refusal: `the checkout has no ${m}, so its Cargo output directory cannot be resolved` };
1925
+ const r = o.spawn(cargo[0], [...cargo.slice(1), "metadata", "--offline", "--format-version", "1", "--no-deps", "--manifest-path", file], { cwd: path.dirname(file), env: o.env });
1926
+ let doc = null;
1927
+ try { doc = failed(r) ? null : JSON.parse(text(r)); } catch { doc = null; }
1928
+ if (!doc || typeof doc.target_directory !== "string" || !path.isAbsolute(doc.target_directory) || typeof doc.workspace_root !== "string") return { refusal: `cargo metadata in the checkout reported no absolute target_directory for ${m}${failed(r) ? ` (${why(r)})` : ""}` };
1929
+ workspaces.set(path.resolve(doc.workspace_root), path.resolve(doc.target_directory));
1930
+ }
1931
+ if (workspaces.size > 1) return { refusal: `the commit holds ${workspaces.size} Cargo workspaces (${[...workspaces.keys()].map((w) => posix(path.relative(root, w)) || ".").join(", ")}); one directory cannot serve them all, and right-release's own Cargo calls carry no per-workspace wrapper` };
1932
+ return { dir: workspaces.size ? [...workspaces.values()][0] : null };
1933
+ }
1934
+
1935
+ /**
1936
+ * rightkit-gate --to dev|test [--commit <rev>] [-- <right-release dev|test arguments>]: the local compiles, on a commit like every other
1937
+ * stage (docs/GATE-CONTRACT.md "Commit-only build": `--to dev` and `--to test` build a commit; `--worktree` never admits a build). The
1938
+ * door has selected the commit and synced its snapshot under ownership (runStages); this runs full class A of that snapshot
1939
+ * (`check --commit <sha> --full-a --snapshot <dir>`, never `--worktree`), then right-release dev|test with the snapshot's app root as
1940
+ * its cwd under a live door run, which carries the source split. Hosted CI, whose runner checkout is the commit, keeps its own
1941
+ * clean checkout. The snapshot is verified before right-release starts and after it ends (E6): a result that covers no known bytes
1942
+ * is refused.
1943
+ */
1944
+ async function runDevTest(o, args, root, git, io, to, appRoot, appDir) {
1945
+ const tag = "rightkit-gate:";
1946
+ const separator = args.indexOf("--");
1947
+ const passthrough = separator >= 0 ? args.slice(separator + 1) : [];
1948
+ // `--to dev --platform mac` (the documented form) hands the platform to right-release as `--platform mac`; a platform given after
1949
+ // `--` wins (Pulse, 2026-10-27: the flag before `--` was dropped and right-release refused "requires --platform").
1950
+ const gateArgs = separator >= 0 ? args.slice(0, separator) : args;
1951
+ const platformAt = gateArgs.indexOf("--platform");
1952
+ if (platformAt >= 0 && gateArgs[platformAt + 1] && !passthrough.includes("--platform")) passthrough.unshift("--platform", gateArgs[platformAt + 1]);
1953
+ const ds = o.doorSnapshot ?? null;
1954
+ const commit = ds ? ds.commit : text(git("rev-parse", "HEAD")).trim();
1955
+ const tree = ds ? ds.tree : text(git("rev-parse", "HEAD^{tree}")).trim();
1956
+ const common = text(git("rev-parse", "--git-common-dir")).trim();
1957
+ if (!/^[0-9a-f]{40}$/.test(commit) || !common) { o.err(`${tag} cannot read HEAD and the git directory`); return 2; }
1958
+ // Hosted CI's runner checkout is the commit; anywhere else the snapshot is, and the checkout may hold anything.
1959
+ if (!ds) {
1960
+ const dirty = git("status", "--porcelain", "--untracked-files=no");
1961
+ 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; }
1962
+ }
1963
+ 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); };
1964
+ const executor = (o.resolveExecutor ?? resolveExecutor)({ env: o.env, platform: o.platform, exists: o.exists, which, readFile: o.readFile }); applyExecutorEnv(o, executor);
1965
+ if (executor.kind === "refuse") { o.err(`${tag} BLOCKED: ${executor.problem}`); return 1; }
1966
+ if (ds && executor.kind === "broker") { o.err(`${tag} BLOCKED: RK_SNAPSHOT_CARRIER_NOT_WIRED: right-release ${to} reaches Cargo on a broker host only through the snapshot carrier (item 7 row 7.24), which is not wired; nothing ran and the checkout is never built`); return 1; }
1967
+ o.out(`${tag} --to ${to} for ${commit.slice(0, 12)}${appDir === "." ? "" : ` (app ${appDir})`}: check (class A) -> right-release ${to}; executor ${executor.kind}${ds ? `; snapshot ${ds.path}` : ""}`);
1968
+ // Full class A of the commit before right-release compiles anything: of the snapshot, the checks that execute repository code included.
1969
+ const checked = await (o.runCheck ?? (() => main(["check", "--commit", ds ? commit : "HEAD", "--full-a", ...(ds ? ["--snapshot", ds.path] : [])], { ...io, cwd: root, ...(ds ? { buildOwnership: ds.own } : {}) })))();
1970
+ if (checked !== 0) { o.err(`${tag} --to ${to} stopped at check: nothing compiled`); return 1; }
1971
+ // The snapshot is exactly tree + dependencies + the ledger before right-release starts; what it wrote may remain only as ledger
1972
+ // entries (a generated `gen/schemas`, a dist), recorded by this gate's own walk (stage-ledger.mjs, decision 2 Q1).
1973
+ if (ds && !(await openSnapshotStage({ o, ds, root, stage: to, tag }))) { o.err(`${tag} --to ${to} stopped: the snapshot is not the commit's bytes plus the ledger; nothing compiled`); return 2; }
1974
+ // Cargo would resolve `target/` (or a relative build.target-dir) against the snapshot: its output directory is resolved in the checkout
1975
+ // and passed absolute, so the warm outputs stay the checkout's and the snapshot holds no target/ (docs/GATE-CONTRACT.md "Output
1976
+ // directory & cache"). One Cargo workspace is pinned as a whole; several would share one directory (refused, naming them).
1977
+ const pin = ds ? cargoOutputPin({ o, git, root, commit, executor }) : { dir: null };
1978
+ if (pin.refusal) { o.err(`${tag} BLOCKED: RK_SNAPSHOT_OUTPUT_UNPINNED: ${pin.refusal}; nothing compiled`); return 1; }
1979
+ if (pin.dir) o.out(`${tag} output directory for the Cargo workspace: ${pin.dir} (the checkout's)`);
1980
+ const commonDir = path.resolve(root, common);
1981
+ const source = ds ? sourceSplitOf({ root, ds, appDir }) : null;
1982
+ const run = startRun({ commonDir, commit, tree, stage: to, ...(source ? { source } : {}) });
1983
+ try {
1984
+ const cli = fileURLToPath(new URL("../cli/right-release.mjs", import.meta.url));
1985
+ // right-release reads its config from its cwd: the app root (appRootOf), inside the snapshot on the snapshot route, not the
1986
+ // directory the command was typed in (apps/foo/src is apps/foo; Astra, third pass).
1987
+ // The door's own RightKit runs every sub-step: a dev target's `right-release web-build|stage-sidecar` steps resolve `right-release`
1988
+ // on PATH, which found the app's node_modules copy (HeardRight on the Dell, 2026-10-27: the app's 0.6.8 guard refused what the
1989
+ // door's 0.6.9 admitted). An installed gate (node_modules/@rightkit/release/bin) has its package's bin shims two levels up.
1990
+ const r = (o.spawnInherit ?? ((cmd, a, opts) => spawnInherit(cmd, a, { ...opts, stdio: "inherit" })))(process.execPath, [cli, to, ...passthrough], { cwd: source ? snapshotAppRootOf(ds.path, appDir) : appRoot, env: { ...o.env, ...run.env, ...doorPath(o), ...(pin.dir ? { CARGO_TARGET_DIR: pin.dir } : {}) } });
1991
+ if (ds && !(await closeSnapshotStage({ o, ds, root, stage: to, step: to, tag })).ok) { o.err(`${tag} --to ${to} stopped: the result covers no known bytes`); return 2; }
1992
+ return r.status === 0 ? 0 : 1;
1993
+ } finally { endRun(run); }
1994
+ }
1995
+
1692
1996
  /** A direct (Linux) executor's environment (PATH without RustRight shim directories) for every child this run starts. */
1693
1997
  function applyExecutorEnv(o, executor) {
1694
1998
  if (executor?.env && o.env) Object.assign(o.env, executor.env);
@@ -1717,22 +2021,27 @@ export async function main(argv, io = {}) {
1717
2021
  spawn: defaultSpawn, exists: fs.existsSync, readFile: (f) => fs.readFileSync(f, "utf8"), writeFile: fs.writeFileSync, mkdir: fs.mkdirSync, chmod: fs.chmodSync, readdir: fs.readdirSync,
1718
2022
  readStdin: () => { try { return process.stdin.isTTY ? "" : fs.readFileSync(0, "utf8"); } catch { return ""; } },
1719
2023
  out: (m) => console.log(m), err: (m) => console.error(m), cwd: process.cwd(), env: process.env, platform: process.platform,
1720
- // Where this gate runs from (install-hook writes a path to it only when it is a plain path inside the repository: hookGatePlan).
2024
+ // Where this gate runs from (the door puts its own shims first on PATH when it is an installed package: doorPath).
1721
2025
  gateFile: fileURLToPath(import.meta.url), ...io,
1722
2026
  };
1723
2027
  const args = [...argv];
1724
2028
  const option = (name) => { const i = args.indexOf(name); return i < 0 ? undefined : args[i + 1]; };
2029
+ // `clippy`: the Clippy route (../clippy-route.mjs; docs/GATE-CONTRACT.md §1 "Clippy route & batching"). Dispatched before the global
2030
+ // flags below because everything after `clippy` is cargo's own argv, verbatim (`-v`, `-h` and `--version` are cargo's).
2031
+ if (args[0] === "clippy") return (await import("../clippy-route.mjs")).clippyCommand(o, args.slice(1), { main, io, absoluteEnv: SNAPSHOT_ABSOLUTE_ENV });
1725
2032
  if (args.includes("--version") || args.includes("-v")) {
1726
2033
  o.out(JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version);
1727
2034
  return 0;
1728
2035
  }
1729
2036
  if (args.includes("--help") || args.includes("-h")) { o.out(USAGE); return 0; }
2037
+ // `--pre-push` (the plan mode of the retired pre-push hook) no longer exists: commit and push run no gate (docs/GATE-CONTRACT.md).
2038
+ if (args.includes("--pre-push")) { o.err(`rightkit-gate: unknown option "--pre-push"\n${USAGE}`); return 2; }
1730
2039
  // `snapshot`: sync the selected commit into the gate-owned source directory and verify it is exactly the commit's tree
1731
2040
  // (../snapshot.mjs; docs/GATE-CONTRACT.md §1). Reads the checkout's commits only, never its working files.
1732
2041
  // `--verify --env <digest>` also requires the installed dependency environment to still be the digest `env-digest --snapshot` recorded.
1733
2042
  if (args[0] === "snapshot") {
1734
2043
  const sc = await import("../snapshot-check.mjs");
1735
- return (await import("../snapshot.mjs")).snapshotCommand(o, args.slice(1), { environmentDigest: ({ checkout, dir, commit }) => classAEnvironmentDigest({ root: dir, files: sc.commitFiles(checkout, commit, o.spawn).files, spawn: o.spawn }) });
2044
+ return (await import("../snapshot.mjs")).snapshotCommand(o, args.slice(1), { environmentDigest: ({ checkout, dir, commit }) => classAEnvironmentDigest({ root: dir, gatePackage: o.gatePackage, files: sc.commitFiles(checkout, commit, o.spawn).files, spawn: o.spawn }) });
1736
2045
  }
1737
2046
  // `live-scans`: re-run only the live remote scans (pnpm-audit) of a checked tree, answering the broker's verdict-reuse refresh
1738
2047
  // (../snapshot-check.mjs; GATE-CONTRACT §1, E18; rightkit#1257).
@@ -1778,15 +2087,12 @@ export async function main(argv, io = {}) {
1778
2087
  }
1779
2088
  const requestedTier = option("--rule-tier") ?? "compile";
1780
2089
  if (!["compile", "release"].includes(requestedTier)) { o.err(`rightkit-gate: check --rule-tier must be compile or release, not ${JSON.stringify(requestedTier)}`); return 2; }
1781
- // Full admission cannot be downgraded by a caller's tier flag. Partial views retain their requested tier.
2090
+ // Full admission runs every deterministic scan; its app rules run at the tier the stage owes (compile unless named): dev, test
2091
+ // and package compile on the compile-tier rules, qualify and release name the release tier (rightkit#1296).
1782
2092
  const fullAdmission = args.includes("--full-a") && !args.includes("--list") && !args.includes("--print-baseline") && !args.includes("--only");
1783
2093
  const stripSnapshot = (list) => { const i = list.indexOf("--snapshot"); return i < 0 ? list : [...list.slice(0, i), ...list.slice(i + 2)]; };
1784
2094
  const appArgs = stripSnapshot(args.slice(1).filter((a) => a !== "--full-a"));
1785
- if (fullAdmission) {
1786
- const tierIndex = appArgs.indexOf("--rule-tier");
1787
- if (tierIndex >= 0) appArgs.splice(tierIndex, 2);
1788
- appArgs.push("--rule-tier", "release");
1789
- }
2095
+ if (fullAdmission && !appArgs.includes("--rule-tier")) appArgs.push("--rule-tier", requestedTier);
1790
2096
  const appCheck = await (o.appCheck ?? (await import("../app-check.mjs")).main)(appArgs, { out: o.out, err: o.err, label: "check", cwd: o.cwd });
1791
2097
  if (args.includes("--list") || args.includes("--print-baseline") || args.includes("--only") || o.classA === false) {
1792
2098
  // 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.
@@ -1801,8 +2107,8 @@ export async function main(argv, io = {}) {
1801
2107
  }
1802
2108
  const repoArg = option("--repo");
1803
2109
  const checkCwd = repoArg ? path.resolve(o.cwd, repoArg) : o.cwd;
1804
- // Admission uses the full tier; a partial diagnostic check may choose either tier.
1805
- const ruleTier = fullAdmission ? "release" : requestedTier;
2110
+ // The app rules' tier is the stage's (rightkit#1296); class A's deterministic scans are the same at either tier.
2111
+ const ruleTier = requestedTier;
1806
2112
  const classA = await main(["--class-a", "--rule-tier", ruleTier, ...(args.includes("--full-a") ? ["--full-a"] : []), ...(args.includes("--worktree") ? ["--worktree"] : ["--commit", snapshotCommit?.commit ?? option("--commit") ?? "HEAD"]), ...(snapshotDir ? ["--snapshot", snapshotDir] : [])], { ...io, cwd: checkCwd });
1807
2113
  // RightKit checking itself: its own rules (line endings, consumer recipes, npm surface, version copies and the version feed, dispatcher
1808
2114
  // pins, consumer locks, generated docs, temp paths: scripts/preflight.mjs) and its exports' import safety are class A for the RightKit repository.
@@ -1918,7 +2224,7 @@ export async function main(argv, io = {}) {
1918
2224
  if (failed(top) || !text(top).trim()) { o.err("rightkit-gate: not inside a git repository"); return 2; }
1919
2225
  const root = path.resolve(text(top).trim());
1920
2226
  const git = (...a) => o.spawn("git", a, { cwd: root });
1921
- if (sub === "install-hook") return await installHook(o, root, args.includes("--force"));
2227
+ if (sub === "install-hook") return await installHook(o, root);
1922
2228
  // `env-digest`: only the environment identity a full class A verdict depends on (classAEnvironmentDigest), no scans. The broker
1923
2229
  // recomputes it when a queued compile starts and re-runs the check if it changed since the verdict.
1924
2230
  if (sub === "env-digest") {
@@ -1933,13 +2239,13 @@ export async function main(argv, io = {}) {
1933
2239
  const { commit, tree } = sc.commitOf(checkout, rev, o.spawn);
1934
2240
  const real = fs.realpathSync.native(dir);
1935
2241
  sc.verifySnapshotDir({ checkout, dir: real, commit, tree, when: "for env-digest" });
1936
- o.out(`env=${classAEnvironmentDigest({ root: real, files: sc.commitFiles(checkout, commit, o.spawn).files, spawn: o.spawn })}`);
2242
+ o.out(`env=${classAEnvironmentDigest({ root: real, gatePackage: o.gatePackage, files: sc.commitFiles(checkout, commit, o.spawn).files, spawn: o.spawn })}`);
1937
2243
  return 0;
1938
2244
  } catch (e) { o.err(`rightkit-gate: ${e.code === sc.CHECK_ERROR ? `${sc.CHECK_ERROR}: ` : "env-digest: "}${e.message}`); return 2; }
1939
2245
  }
1940
2246
  const listed = git("ls-files", "-z", "--cached", "--others", "--exclude-standard");
1941
2247
  if (failed(listed)) { o.err(`rightkit-gate: env-digest cannot list the working tree: ${why(listed)}`); return 2; }
1942
- try { const files = text(listed).split("\0").filter(Boolean).map(posix); o.out(`env=${classAEnvironmentDigest({ root, files, spawn: o.spawn })}`); }
2248
+ try { const files = text(listed).split("\0").filter(Boolean).map(posix); o.out(`env=${classAEnvironmentDigest({ root, gatePackage: o.gatePackage, files, spawn: o.spawn })}`); }
1943
2249
  catch (error) { o.err(`rightkit-gate: env-digest: ${error.message}`); return 2; }
1944
2250
  return 0;
1945
2251
  }
@@ -1949,10 +2255,13 @@ export async function main(argv, io = {}) {
1949
2255
  const unitFlag = args.includes("--unit") && (separator < 0 || args.indexOf("--unit") < separator);
1950
2256
  if (option("--to") !== undefined && unitFlag) {
1951
2257
  const selector = option("--unit");
1952
- const validArgs = args.length % 2 === 0 && args.every((a, i) => i % 2 ? !a.startsWith("--") : ["--to", "--unit", "--receipt-out"].includes(a)) && ["--to", "--unit", "--receipt-out"].every((flag) => args.filter((a) => a === flag).length <= 1);
2258
+ const validArgs = args.length % 2 === 0 && args.every((a, i) => i % 2 ? !a.startsWith("--") : ["--to", "--unit", "--receipt-out", "--commit"].includes(a)) && ["--to", "--unit", "--receipt-out", "--commit"].every((flag) => args.filter((a) => a === flag).length <= 1);
1953
2259
  if (!validArgs || !selector) {
1954
- o.err("rightkit-gate: unit stages take only --to, --unit and optional --receipt-out"); return 2;
2260
+ o.err("rightkit-gate: unit stages take only --to, --unit and optional --receipt-out and --commit"); return 2;
1955
2261
  }
2262
+ // Row 7.06a: on the snapshot route (Linux now; Mac and Dell at the cutover) a unit stage builds the selected commit's snapshot.
2263
+ if (!o.doorSnapshot && o.env.GITHUB_ACTIONS !== "true" && snapshotRouteSelected(o)) return await runUnitSnapshot(o, args, root, git, io, option);
2264
+ if (option("--commit") !== undefined) { o.err("rightkit-gate: BLOCKED: --commit selects a commit for the snapshot route; on this host a unit stage takes HEAD of a clean checkout until the cutover"); return 2; }
1956
2265
  const head = git("rev-parse", "HEAD"), treeResult = git("rev-parse", "HEAD^{tree}"), common = git("rev-parse", "--git-common-dir"), dirty = git("status", "--porcelain", "--untracked-files=no");
1957
2266
  const commit = text(head).trim(), tree = text(treeResult).trim();
1958
2267
  if (failed(head) || failed(treeResult) || failed(common) || !text(common).trim() || !/^[0-9a-f]{40}$/.test(commit) || !/^[0-9a-f]{40}$/.test(tree) || failed(dirty) || text(dirty).trim()) { o.err("rightkit-gate: BLOCKED: unit stages need clean committed HEAD and tree"); return 1; }
@@ -2014,11 +2323,14 @@ export async function main(argv, io = {}) {
2014
2323
  // A broker-managed host whose broker is unreachable refuses the moment a Cargo manifest is in the snapshot (below): a repository
2015
2324
  // with no Rust owes nothing to the broker (a git hook's minimal PATH, a JS-only app), and the class A scans never compile.
2016
2325
  const cargoCommand = executor.cargo ?? ["cargo"];
2017
- if (snapshotB && executor.kind !== "direct") { o.err(`${tag} BLOCKED: a ${executor.kind} host compiles a commit snapshot only through the RustRight snapshot carrier (joint plan step 4d), which is not wired yet; nothing ran`); return 1; }
2326
+ // A broker host (Mac, Dell) compiles a commit snapshot through the RustRight snapshot carrier: `deps.snapshotBroker`, built in the
2327
+ // snapshot-compile region below (item 7 rows 7.20 to 7.24). A broker-managed host whose broker cannot be reached is refused with
2328
+ // the executor's own problem, never compiled around.
2329
+ if (snapshotB && executor.kind === "refuse") { o.err(`${tag} BLOCKED: ${executor.problem}; nothing ran`); return 1; }
2018
2330
  // The snapshot: the commit or tree being checked (check --commit), HEAD for the gate, or the working tree (check --worktree, --plan).
2019
2331
  const allFilesMode = classAOnly || args.includes("--all");
2020
2332
  const snapshot = planOnly ? null : classAOnly ? classACommit : snapshotB ? option("--commit") : "HEAD";
2021
- // Every effectful gate entry owes the app rules, not only `check` (Astra, 2026-10-26): a bare, --all or pre-push run on HEAD runs them
2333
+ // Every effectful gate entry owes the app rules, not only `check` (Astra, 2026-10-26): a bare or --all run on HEAD runs them
2022
2334
  // first, in the tier asked for (compile by default), and plans nothing while they are red. --plan and --class-a (which runs them in
2023
2335
  // `check`) are untouched.
2024
2336
  if (snapshot && !classAOnly) {
@@ -2110,25 +2422,58 @@ export async function main(argv, io = {}) {
2110
2422
  // The snapshot compile: verified as the commit's tree before anything runs, dependencies installed into it offline from the
2111
2423
  // checkout's store, and every Cargo workspace's output pinned to the target directory cargo resolves for it IN THE CHECKOUT
2112
2424
  // (CARGO_TARGET_DIR or build.target-dir honoured), so the warm outputs are the checkout's and the snapshot holds no target/.
2113
- let targetDirs = null; let cargoShim = null;
2425
+ let targetDirs = null; let cargoShim = null; let snapshotBroker = null;
2426
+ // The direct (Linux) compile's evidence (item 7 rows 7.09, 7.18, 7.19): how each output directory was resolved, the declared inputs
2427
+ // digested in the snapshot, and the stage's own timings.
2428
+ let outputResolution = {}; let directDeclared = []; let directDeclaredNotes = [];
2429
+ const directTiming = { bindStart: 0, bindEnd: 0, cargoStart: 0, cargoEnd: 0, afterStart: 0, afterEnd: 0, diagnostics: 0 };
2114
2430
  if (snapshotB) {
2115
2431
  const sc = await import("../snapshot-check.mjs");
2116
2432
  try {
2117
2433
  const checkout = fs.realpathSync.native(root);
2434
+ directTiming.bindStart = Date.now();
2118
2435
  snapshotBCommit = sc.commitOf(checkout, String(snapshot), o.spawn);
2119
- sc.verifySnapshotDir({ checkout, dir: snapshotB, ...snapshotBCommit, when: "before the compile" });
2436
+ // With a ledger the verify returns an object ({ tree, ledger }), without one the tree id: only the refusal (a throw) matters here.
2437
+ sc.verifySnapshotDir({ checkout, dir: snapshotB, ...snapshotBCommit, when: "before the compile", ...(o.snapshotLedger ? { ledger: o.snapshotLedger } : {}) });
2438
+ directTiming.bindEnd = Date.now();
2439
+ // A direct host digests the declared compile inputs in place (the snapshot's own .rightkit/compile-inputs.json, never the
2440
+ // checkout's); a malformed manifest refuses before anything compiles. The broker digests them for a broker host.
2441
+ if (executor.kind === "direct") {
2442
+ const inputsModule = await import("../compile-inputs.mjs");
2443
+ const judged = inputsModule.judgeCompileInputs(snapshotB);
2444
+ // Only a malformed manifest refuses here (the broker's request does the same); a declared path the snapshot does not hold, or a
2445
+ // finding in one, is recorded with the diagnostics and left to the check stage that judges the captured inputs.
2446
+ const malformed = judged.findings.filter((f) => f.startsWith(`${inputsModule.COMPILE_INPUTS_FILE}:`));
2447
+ if (malformed.length) throw Object.assign(new Error(`the snapshot's declared compile inputs are malformed: ${malformed.join("; ")}`), { code: "RK_COMPILE_INPUTS" });
2448
+ directDeclared = judged.inputs;
2449
+ directDeclaredNotes = judged.findings.map((f) => `declared compile input: ${f}`);
2450
+ }
2120
2451
  o.out(`${tag} snapshot ${snapshotB} is commit ${snapshotBCommit.commit} tree ${snapshotBCommit.tree} (verified before the compile)`);
2121
2452
  for (const r of sc.installSnapshotDependencies({ checkout, snapshot: snapshotB, roots: npmRoots, env: o.env, spawn: o.spawn })) o.out(`${tag} ${sc.describeInstall(r)}`);
2122
2453
  } catch (error) { o.err(`${tag} ${error.code === "RK_CHECK_ERROR" ? "RK_CHECK_ERROR" : "BLOCKED"}: ${error.message}`); return error.code === "RK_CHECK_ERROR" ? 2 : 1; }
2454
+ // A broker host (item 7 rows 7.20 to 7.24): the compile goes through the RustRight snapshot carrier. The broker pins the output
2455
+ // directory (its managed generation target), so no checkout target is resolved here; the gate's broker is built once, with the
2456
+ // host's enrolled pins, the declared inputs read from the snapshot and the build ownership; a missing piece refuses before
2457
+ // anything compiles (an app repository with no enrollment included), never a compile around the broker.
2458
+ if (executor.kind === "broker" && manifests.length) {
2459
+ try {
2460
+ const { createSnapshotBroker } = await import("../snapshot-broker.mjs");
2461
+ const client = which("rustright").find((c) => path.isAbsolute(c));
2462
+ snapshotBroker = createSnapshotBroker({ env: o.env, platform: o.platform, checkout: root, commit: { sha: snapshotBCommit.commit, tree: snapshotBCommit.tree }, snapshot: { path: snapshotB, expectedTree: snapshotBCommit.tree }, ownership: o.buildOwnership, client, gateFile: callingGatePath(), spawn: o.spawn });
2463
+ } catch (error) { o.err(`${tag} BLOCKED: ${error.code ?? "RK_SNAPSHOT_BROKER"}: ${error.message}; nothing compiled`); return 1; }
2464
+ o.out(`${tag} broker snapshot compile: pins ${snapshotBroker.pins.keyId.slice(0, 12)} from ${snapshotBroker.pinsSource}, ${snapshotBroker.declaredInputs.length} declared input(s) read from the snapshot, ownership ${snapshotBroker.handle}`);
2465
+ }
2123
2466
  targetDirs = {};
2124
- for (const ws of rust.workspaces) {
2467
+ for (const ws of snapshotBroker ? [] : rust.workspaces) {
2125
2468
  const manifest = path.join(root, ws.cargoDir, "Cargo.toml");
2126
2469
  if (!o.exists(manifest)) { o.err(`${tag} BLOCKED: the checkout has no ${posix(path.relative(root, manifest))}, so the output directory of workspace ${ws.cargoDir} cannot be resolved; nothing compiled`); return 1; }
2127
- const r = o.spawn(cargoCommand[0], [...cargoCommand.slice(1), "metadata", "--offline", "--format-version", "1", "--no-deps", "--manifest-path", manifest], { cwd: path.dirname(manifest), env: o.env });
2128
- let dir = null;
2129
- try { dir = failed(r) ? null : JSON.parse(text(r)).target_directory; } catch { dir = null; }
2130
- if (typeof dir !== "string" || !path.isAbsolute(dir)) { o.err(`${tag} BLOCKED: cargo metadata in the checkout reported no absolute target_directory for ${ws.cargoDir}${failed(r) ? ` (${why(r)})` : ""}; nothing compiled`); return 1; }
2131
- targetDirs[ws.cargoDir] = path.resolve(dir);
2470
+ // Resolved in the PRIMARY CHECKOUT with the stage's environment, before the source location changes (row 7.09: CARGO_TARGET_DIR,
2471
+ // a relative or absolute build.target-dir, or the default target/ all resolve as for a developer's own build); the cwd and
2472
+ // environment used are recorded. A directory inside the snapshot is refused before anything compiles (a second output).
2473
+ let resolved;
2474
+ try { resolved = resolveTargetDirectory({ spawn: (c, a, opts) => o.spawn(c, a, opts), cargoCommand, manifest, env: o.env, checkoutRoot: root, snapshotRoot: snapshotB, workspace: ws.cargoDir }); }
2475
+ catch (error) { o.err(`${tag} BLOCKED: ${error.message}; nothing compiled`); return 1; }
2476
+ targetDirs[ws.cargoDir] = resolved.dir; outputResolution[ws.cargoDir] = resolved.inputs;
2132
2477
  o.out(`${tag} output directory for workspace ${ws.cargoDir}: ${targetDirs[ws.cargoDir]} (the checkout's)`);
2133
2478
  }
2134
2479
  // CARGO_HOME, sccache & temp are inherited as absolute values, never snapshot-relative (GATE-CONTRACT "Output directory & cache"):
@@ -2140,13 +2485,24 @@ export async function main(argv, io = {}) {
2140
2485
  // broker host never reaches here; a direct Windows host keeps the fail-closed static walk). Removed when the gate ends (cleanup).
2141
2486
  if (o.platform !== "win32" && rust.workspaces.length) {
2142
2487
  const realCargo = which("cargo").find((c) => path.isAbsolute(c));
2143
- if (!realCargo) { o.err(`${tag} BLOCKED: no cargo on PATH to wrap for the snapshot compile; nothing compiled`); return 1; }
2488
+ if (!realCargo && !snapshotBroker) { o.err(`${tag} BLOCKED: no cargo on PATH to wrap for the snapshot compile; nothing compiled`); return 1; }
2144
2489
  try {
2145
2490
  const shimModule = await import("../cargo-target-shim.mjs");
2146
- cargoShim = shimModule.installCargoShim({ base: os.tmpdir(), snapshotRoot: snapshotB, targetDirs, realCargo, platform: o.platform, onDir: (dir) => { cargoShimDir = dir; } });
2491
+ let broker = null;
2492
+ if (snapshotBroker) {
2493
+ // Broker mode (the Mac, decision C): every cargo a test reaches is its own carried request. The gate's own snapshot-to-checkout
2494
+ // map is the only source of a request's primaryCwd, and broker work may exist from here on: the ownership records it first.
2495
+ const checkoutRoot = fs.realpathSync.native(root);
2496
+ const checkoutDirs = Object.fromEntries(rust.workspaces.map((ws) => [ws.cargoDir, path.join(checkoutRoot, ...(ws.cargoDir === "." ? [] : ws.cargoDir.split("/")))]));
2497
+ const marked = snapshotBroker.markBrokerSubmit();
2498
+ if (marked?.broker?.submitted !== true || marked.handle !== snapshotBroker.handle) throw new Error(`the build ownership did not record the broker submit under ${snapshotBroker.handle}`);
2499
+ const { brokerSection } = await import("../snapshot-broker.mjs");
2500
+ broker = { checkoutDirs, section: brokerSection(snapshotBroker) };
2501
+ }
2502
+ cargoShim = shimModule.installCargoShim({ base: os.tmpdir(), snapshotRoot: snapshotB, targetDirs, broker, realCargo: realCargo ?? null, platform: o.platform, onDir: (dir) => { cargoShimDir = dir; } });
2147
2503
  cargoShim.scanEnv = shimModule.shimScanEnv(o.env, cargoShim);
2148
2504
  } catch (error) { o.err(`${tag} BLOCKED: the runtime cargo wrapper cannot be installed (${error.message}); nothing compiled`); return 1; }
2149
- o.out(`${tag} runtime cargo wrapper ${cargoShim.shim} (identity ${cargoShim.identity.slice(0, 16)}) -> ${realCargo}: each cargo invocation binds its workspace's checkout target directory or is refused`);
2505
+ o.out(`${tag} runtime cargo wrapper ${cargoShim.shim} (identity ${cargoShim.identity.slice(0, 16)}, ${cargoShim.mode}) -> ${cargoShim.mode === "broker" ? "the RustRight broker, one carried request per call" : realCargo}: each cargo invocation ${cargoShim.mode === "broker" ? "is carried with its own receipt, verified by the gate," : "binds its workspace's checkout target directory"} or is refused`);
2150
2506
  }
2151
2507
  }
2152
2508
 
@@ -2154,8 +2510,11 @@ export async function main(argv, io = {}) {
2154
2510
  // otherwise hide a committed failure. Class A reads only the snapshot.
2155
2511
  if (!planOnly && !classAOnly && !snapshotB) {
2156
2512
  const npmRootFiles = ["package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml", ".npmrc", ...ESLINT_CONFIG_FILES, ...BIOME_CONFIG_FILES];
2157
- 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",
2158
- "deny.toml", "clippy.toml", "rustfmt.toml", ...npmRootFiles, ...npmRoots.filter((r) => r !== ".").flatMap((r) => npmRootFiles.map((f) => `${r}/${f}`))])];
2513
+ const inputs = [...new Set([...rust.units.map((u) => u.dir), ...npm.map((u) => u.dir), ...rust.workspaces.map((w) => w.cargoDir), "ci", "scripts", "vendor",
2514
+ // The root manifest, lock and deny.toml only where a workspace is rooted there; a crate-per-workspace repository binds each crate's own
2515
+ // (its directory is an input above, which holds its Cargo.toml, Cargo.lock, deny.toml, .cargo and .rightkit).
2516
+ ...(rust.workspaces.some((w) => w.cargoDir === ".") ? ["Cargo.toml", "Cargo.lock", "deny.toml"] : []),
2517
+ ".cargo", "rust-toolchain.toml", "rust-toolchain", "clippy.toml", "rustfmt.toml", ...npmRootFiles, ...npmRoots.filter((r) => r !== ".").flatMap((r) => npmRootFiles.map((f) => `${r}/${f}`))])];
2159
2518
  // Batched like cargoInputsDiffer: one argv with every input path overflows Windows' command line in a large repository.
2160
2519
  const dirtyRuns = pathspecBatches(inputs).map((batch) => git("status", "--porcelain", "--untracked-files=all", "--", ...batch));
2161
2520
  const dirtyFailed = dirtyRuns.find((r) => failed(r));
@@ -2225,6 +2584,25 @@ export async function main(argv, io = {}) {
2225
2584
  }
2226
2585
  cargoLiveRoot = root;
2227
2586
  }
2587
+ // One Cargo workspace and lock per crate (rightkit#1285): the reach between crates is the manifests' path dependencies, read from the
2588
+ // gated snapshot. A crate that is selected selects its transitive dependents (checked, not re-linted); a vendored source it patches
2589
+ // in selects the crate. Neither is derivable from a single lock, so a layout that cannot read them is refused, never planned narrower.
2590
+ let crateDependents = null; let externalInputs = null;
2591
+ const crateUnits = rust.units.filter((u) => u.cargoDir === `crates/${u.crate}` && u.name === u.crate);
2592
+ if (crateUnits.length) {
2593
+ try {
2594
+ const { crateWorkspaces, pathDependencies, reverseDependents } = await import("../crate-workspaces.mjs");
2595
+ const io = { readFile: (f) => o.readFile(f), exists: (f) => o.exists(f), readdir: (d) => o.readdir(d) };
2596
+ const known = new Set(crateUnits.map((u) => u.name));
2597
+ const graph = new Map();
2598
+ for (const [name, set] of pathDependencies(repo, io, crateWorkspaces(repo, io))) if (known.has(name)) graph.set(name, new Set([...set].filter((d) => known.has(d))));
2599
+ crateDependents = (names) => reverseDependents([...names].filter((n) => known.has(n)), graph);
2600
+ externalInputs = vendorOwners(crateUnits, (u) => o.readFile(path.join(repo, u.dir, "Cargo.toml")));
2601
+ } catch (e) { o.err(`${tag} BLOCKED: cannot read the path dependencies between the crate workspaces (${e.message}); their reverse dependents would go unchecked, so nothing is planned`); return 1; }
2602
+ }
2603
+ // A broker snapshot compile: its cargo scans are carried requests that name the commit's snapshot and run from the primary checkout
2604
+ // (rustright refuses cargo anywhere else); the checkout is the request's working directory, never its source.
2605
+ if (snapshotBroker && manifests.length) cargoLiveRoot = root;
2228
2606
  let fullPlan;
2229
2607
  fullPlan = planScans({
2230
2608
  // Class A of a commit or tree reads only that snapshot: every scan, cargo's included, runs in the export, never in the live
@@ -2237,7 +2615,7 @@ export async function main(argv, io = {}) {
2237
2615
  // The door's snapshot compile already executes the repository's own code (its tests) in the verified snapshot, guarded like
2238
2616
  // full class A, so the docs generator runs there too instead of being deferred (step 4c review).
2239
2617
  quietTests: fullA || o.env.RK_GATE_QUIET_TESTS === "local" ? "local" : "ci", packageFeatures, liveExecution: (fullA && (args.includes("--worktree") || Boolean(snapshotA))) || Boolean(snapshotB),
2240
- rustUnits: rust.units, npmUnits: npm, npmRoots, cargoDir: ".", rustLocks, crateFeatures,
2618
+ rustUnits: rust.units, npmUnits: npm, npmRoots, cargoDir: ".", rustLocks, crateFeatures, crateDependents, externalInputs,
2241
2619
  // The broker (or, on a direct host, the gate itself) admits compiles; no separate grant file decides it here.
2242
2620
  localCompile: true,
2243
2621
  exists: o.exists, readFile: o.readFile, platform: o.platform,
@@ -2247,13 +2625,15 @@ export async function main(argv, io = {}) {
2247
2625
  // Class A tests run code too (Astra, second pass: a test script can compile native code its text does not show), so a host whose
2248
2626
  // broker is unreachable runs neither; the gate stops rather than skip them.
2249
2627
  if (executor.kind === "refuse" && fullPlan.scans.some((x) => x.class === "B" || (x.kind === "test" && (!classAOnly || fullA)))) { o.err(`${tag} BLOCKED: ${executor.problem}`); return 1; }
2250
- let plan = args.includes("--pre-push") ? prePushPlan(fullPlan) : fullPlan;
2628
+ let plan = fullPlan;
2251
2629
  const jobs = Math.max(1, Number(o.env.RK_GATE_JOBS ?? 4) || 4);
2252
2630
  const testJobs = Math.max(1, Number(o.env.RK_GATE_TEST_JOBS ?? jobs) || jobs);
2631
+ const classBJobs = Math.max(1, Number(o.env.RK_GATE_CLASS_B_JOBS ?? 3) || 3);
2253
2632
 
2254
2633
  if (planOnly) {
2255
2634
  o.out(`${tag} plan: ${plan.scans.length} scan(s) for ${changed.length} changed file(s) vs ${base.slice(0, 12)} (reads the working tree; nothing runs)`);
2256
- const wsList = rust.workspaces.map((w) => w.cargoDir);
2635
+ // A crate that is its own workspace is named by its unit (rust units below); only the other workspaces are listed.
2636
+ const wsList = rust.workspaces.filter((w) => !(w.units.length === 1 && w.units[0].name === w.units[0].crate && w.cargoDir === `crates/${w.units[0].crate}`)).map((w) => w.cargoDir);
2257
2637
  const wsNote = wsList.length === 1 ? (wsList[0] === "." ? "" : ` (workspace ${wsList[0]})`) : wsList.length > 1 ? ` (workspaces ${wsList.join(", ")})` : "";
2258
2638
  o.out(`${tag} rust units: ${rust.units.map((u) => `${u.name}=${u.dir}`).join(", ") || "none"}${wsNote}; npm units: ${npm.map((u) => `${u.name}=${u.dir}`).join(", ") || "none"}`);
2259
2639
  for (const s of plan.scans) o.out(` [${s.class}] ${s.id} :: ${s.command}${plan.deferred.includes(s.id) ? " [deferred: no local compile grant]" : ""}`);
@@ -2264,7 +2644,7 @@ export async function main(argv, io = {}) {
2264
2644
  for (const n of plan.notes) o.out(`${tag} note: ${n}`);
2265
2645
  o.out(`${tag} executor: ${executor.kind}${executor.kind === "broker" ? ` (${cargoCommand.join(" ")})` : " (no broker on this host: cargo runs directly, through the same gates)"}`);
2266
2646
  const quietCount = plan.scans.filter((s) => s.quiet === true).length;
2267
- o.out(`${tag} ${plan.scans.length} scan(s) for ${changed.length} changed file(s) vs ${base.slice(0, 12)}; class A scans ${jobs} at a time, class B scans 1 at a time${quietCount ? `, ${quietCount} quiet test scan(s) last under the broker's quiet lease` : ""}`);
2647
+ o.out(`${tag} ${plan.scans.length} scan(s) for ${changed.length} changed file(s) vs ${base.slice(0, 12)}; class A scans ${jobs} at a time, class B scans ${classBJobs} component(s) at a time${quietCount ? `, ${quietCount} quiet test scan(s) last under the broker's quiet lease` : ""}`);
2268
2648
  let signalCode = 0;
2269
2649
  for (const sig of ["SIGINT", "SIGTERM"]) {
2270
2650
  const handler = () => {
@@ -2275,7 +2655,7 @@ export async function main(argv, io = {}) {
2275
2655
  };
2276
2656
  process.on(sig, handler); signals.push([sig, handler]);
2277
2657
  }
2278
- // The result cache keys on the gated commit's object ids; the pre-push check above made HEAD the pushed commit.
2658
+ // The result cache keys on the gated commit's object ids; the stdin check above made HEAD the pushed commit.
2279
2659
  let cache = null;
2280
2660
  // Full class A on the working tree (the door, and broker admission of the live checkout) caches on the LIVE bytes: every
2281
2661
  // tracked and untracked (not ignored) file is hashed raw, so the door's run and a repeat admission of the same bytes hit the
@@ -2331,7 +2711,7 @@ export async function main(argv, io = {}) {
2331
2711
  if (boundFullA) {
2332
2712
  const orphans = orphanedPnpmStores({ root: liveBase, files: live.files });
2333
2713
  if (orphans.length) { o.err(`${tag} BLOCKED: orphaned nested pnpm store(s) ${orphans.join(", ")}: left by a standalone install in a workspace member; they can hold stale dependencies and make concurrent vitest runs clear their shared module cache under each other. Move them aside (or reinstall) and run again.`); return 1; }
2334
- try { envAtStart = classAEnvironmentDigest({ root: liveBase, files: live.files, spawn: o.spawn, includeCaches: false }); envStartWithCaches = classAEnvironmentDigest({ root: liveBase, files: live.files, spawn: o.spawn }); cachesAtStart = toolCacheManifest({ root: liveBase, files: live.files }); }
2714
+ try { envAtStart = classAEnvironmentDigest({ root: liveBase, gatePackage: o.gatePackage, files: live.files, spawn: o.spawn, includeCaches: false }); envStartWithCaches = classAEnvironmentDigest({ root: liveBase, gatePackage: o.gatePackage, files: live.files, spawn: o.spawn }); cachesAtStart = toolCacheManifest({ root: liveBase, files: live.files }); }
2335
2715
  catch (error) { o.err(`${tag} BLOCKED: ${error.message}`); return 1; }
2336
2716
  }
2337
2717
  if (boundFullA && !args.includes("--no-cache") && o.env.RK_GATE_CACHE !== "0") {
@@ -2346,7 +2726,7 @@ export async function main(argv, io = {}) {
2346
2726
  const readBlob = (p) => fs.readFileSync(path.join(liveBase, p), "utf8");
2347
2727
  // The verdict also depends on what is not in the tree: the gate's own code and advisory data, the node and pnpm that run
2348
2728
  // the checks, the installed dependencies (pnpm's install state) and the platform. Any change there is a cache miss.
2349
- const environment = classAEnvironmentDigest({ root: liveBase, files, spawn: o.spawn });
2729
+ const environment = classAEnvironmentDigest({ root: liveBase, gatePackage: o.gatePackage, files, spawn: o.spawn });
2350
2730
  cache = { ...store, digest: `${buildCacheDigest({ objectId, headTree: digestOf(files), rustUnits: rust.units, npmUnits: npm, cargoDir: ".", rustLocks, crateFeatures, listFiles, readBlob })}:${environment}` };
2351
2731
  } catch { cache = null; }
2352
2732
  }
@@ -2377,11 +2757,19 @@ export async function main(argv, io = {}) {
2377
2757
  }
2378
2758
  } catch { cache = null; }
2379
2759
  }
2760
+ // The direct compile's freshness census (row 7.19): the fingerprint units before the scans, and the fingerprint log of each cargo
2761
+ // process that compiles (CARGO_LOG on that process only, read from its own standard error). A broker host's census is the broker's.
2762
+ const census = snapshotB && !snapshotBroker ? createFreshnessCensus({ targetDirs }) : null;
2763
+ directTiming.cargoStart = Date.now();
2764
+ census?.start();
2380
2765
  const scanResult = await (o.runScans ?? runScans)(plan, {
2381
- classes: classAOnly ? ["A"] : ["A", "B"], jobs, testJobs, signal: ac.signal, ...(cache ? { cache } : {}),
2766
+ classes: classAOnly ? ["A"] : ["A", "B"], jobs, testJobs, classBJobs, signal: ac.signal, ...(cache ? { cache } : {}),
2767
+ ...(census ? { spawn: census.wrapSpawn(defaultSpawnAsync) } : {}),
2382
2768
  // The runtime cargo wrapper is first on PATH and named in CARGO for every scan of a snapshot compile; CARGO (a path named by the
2383
2769
  // wrapper's identity) is in every scan's cache key, so no result cached without the wrapper or with another map is reused.
2384
- ...(cargoShim ? { env: cargoShim.scanEnv, ...(cargoShim.sentinel ? { cargoSentinel: cargoShim.sentinel } : {}) } : {}),
2770
+ ...(cargoShim ? { env: cargoShim.scanEnv, ...(cargoShim.sentinel ? { cargoSentinel: cargoShim.sentinel } : {}), cargoLedger: { dir: cargoShim.ledgerDir } } : {}),
2771
+ // The broker snapshot compile's seam (item 7 rows 7.20 to 7.24): the commit, snapshot, ownership, enrolled pins and receipt reader.
2772
+ ...(snapshotBroker ? { snapshotBroker } : {}),
2385
2773
  // `check` (and the broker's compile admission, which runs it on a captured copy of the tree without node_modules) judges the
2386
2774
  // content: it never executes the repository's own code. The class A checks that do (tests, typecheck, lint, generated docs:
2387
2775
  // `executes`) run in the full gate (--all, the push hook, the door's compile stage), after the content checks and before any compile.
@@ -2401,6 +2789,9 @@ export async function main(argv, io = {}) {
2401
2789
  })(),
2402
2790
  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)}` : ""}`),
2403
2791
  });
2792
+ directTiming.cargoEnd = Date.now();
2793
+ const freshness = census ? census.finish() : null;
2794
+ directTiming.diagnostics = Date.now() - directTiming.cargoEnd;
2404
2795
  if (ac.signal.aborted) {
2405
2796
  const notRun = scanResult.scans.filter((r) => r.status !== "pass" && r.status !== "fail").map((r) => r.id);
2406
2797
  o.err(`${tag} CANCELLED (${signalCode || "aborted"}); ${notRun.length} scan(s) not run:`);
@@ -2427,7 +2818,7 @@ export async function main(argv, io = {}) {
2427
2818
  }
2428
2819
  if (!live) return { env: null, notes: [] };
2429
2820
  let envDigest; let envNoCaches;
2430
- try { envDigest = classAEnvironmentDigest({ root: liveBase, files: live.files, spawn: o.spawn }); envNoCaches = classAEnvironmentDigest({ root: liveBase, files: live.files, spawn: o.spawn, includeCaches: false }); }
2821
+ try { envDigest = classAEnvironmentDigest({ root: liveBase, gatePackage: o.gatePackage, files: live.files, spawn: o.spawn }); envNoCaches = classAEnvironmentDigest({ root: liveBase, gatePackage: o.gatePackage, files: live.files, spawn: o.spawn, includeCaches: false }); }
2431
2822
  catch (error) { return { prefix: "BLOCKED", exit: 1, problem: error.message }; }
2432
2823
  const notes = [];
2433
2824
  if (cachesAtStart !== null) {
@@ -2441,24 +2832,48 @@ export async function main(argv, io = {}) {
2441
2832
  };
2442
2833
  if (snapshotB) {
2443
2834
  // The compile covers the commit only if the snapshot still is its tree after every scan ran (E6).
2444
- try { (await import("../snapshot-check.mjs")).verifySnapshotDir({ checkout: fs.realpathSync.native(root), dir: snapshotB, ...snapshotBCommit, when: "after the compile" }); }
2835
+ directTiming.afterStart = Date.now();
2836
+ try {
2837
+ // With the door's ledger the verify returns { tree, ledger: admitted } (item 5 row 5.09d, snapshot-check.mjs); the entries it admitted
2838
+ // are handed to the door, which holds them against its own walk at the stage's end (closeSnapshotStage). Without a ledger it returns the tree id.
2839
+ const verified = (await import("../snapshot-check.mjs")).verifySnapshotDir({ checkout: fs.realpathSync.native(root), dir: snapshotB, ...snapshotBCommit, when: "after the compile", ...(o.snapshotLedger ? { ledger: o.snapshotLedger, admitNew: true } : {}) });
2840
+ if (o.snapshotLedger) o.onCompileLedger?.(Array.isArray(verified?.ledger) ? verified.ledger : null);
2841
+ }
2445
2842
  catch (error) { o.err(`${tag} RK_CHECK_ERROR: ${error.message}; the compile covers no known bytes`); return 2; }
2843
+ directTiming.afterEnd = Date.now();
2446
2844
  o.out(`${tag} snapshot ${snapshotB} is still tree ${snapshotBCommit.tree} after the compile`);
2447
- // The direct host's own receipt (GATE-CONTRACT "Broker interface": the same field names as the broker's evidence, without the
2448
- // broker-only ones): this run, the commit, the snapshot's tree at start and exit, the ownership, where the outputs went, the
2449
- // result and when it was issued. Rebuilt units are the freshness census of joint plan step 6, not measured here.
2450
- const workspaces = Object.keys(targetDirs ?? {});
2451
- const directReceipt = {
2452
- schema: "rightkit.gate-direct-receipt/1", requestId: randomUUID(), executor: "direct", issuedAt: new Date().toISOString(),
2453
- commit: { sha: snapshotBCommit.commit, tree: snapshotBCommit.tree },
2454
- snapshot: { path: snapshotB, treeAtStart: snapshotBCommit.tree, treeAtExit: snapshotBCommit.tree },
2455
- ownership: { handle: o.buildOwnership?.handle ?? null },
2456
- outputDir: workspaces.length === 1 ? targetDirs[workspaces[0]] : null, outputDirs: { ...(targetDirs ?? {}) },
2457
- cargoWrapper: cargoShim ? { identity: cargoShim.identity, workspaces: cargoShim.workspaces } : null,
2458
- exitStatus: failures.length ? 1 : 0, rebuilt: null,
2459
- };
2460
- o.onDirectReceipt?.(directReceipt);
2461
- o.out(`${tag} direct receipt ${JSON.stringify(directReceipt)}`);
2845
+ // A broker host's evidence is the broker's own signed receipts (each carried scan's row.snapshotEvidence, verified above); the
2846
+ // gate writes no receipt that says `executor: direct` for it.
2847
+ if (snapshotBroker) o.out(`${tag} broker compile: ${scanResult.scans.filter((r) => r.snapshotEvidence).length} carried request(s) with verified signed receipts; no direct receipt on a broker host`);
2848
+ else {
2849
+ // The direct host's own receipt (GATE-CONTRACT "Broker interface", row 7.18): the contract's returned-evidence names minus the
2850
+ // broker-only ones (admission, queue, reusedFrom): the run, the commit, the snapshot's tree at start and exit, the declared
2851
+ // inputs digested in place, the verdict, the freshness census (7.19), the timings, the exit status, the structured
2852
+ // diagnostics, the resolved output directory (7.09, with its inputs) and `refreshed` (nothing is reused on a direct host).
2853
+ // The gate refuses its own receipt unless it verifies, so a consumer never meets a receipt with a name missing.
2854
+ const requestId = randomUUID();
2855
+ const exitCode = failures.length ? 1 : 0;
2856
+ const diagnosticsDir = (() => {
2857
+ try { return path.join(o.env.RIGHTKIT_SCRATCH_ROOT || path.join(resolveManagedRoots({ env: o.env }).dataRoot, "runs"), "rightkit-gate-direct-diagnostics"); } catch { return path.join(os.tmpdir(), "rightkit-gate-direct-diagnostics"); }
2858
+ })();
2859
+ let directReceipt;
2860
+ try {
2861
+ const wrote = Date.now();
2862
+ const diagnosticsPath = writeDirectDiagnostics({ dir: diagnosticsDir, requestId, commit: { sha: snapshotBCommit.commit, tree: snapshotBCommit.tree }, exit: exitCode, scans: scanResult.scans, failures, deferrals, notes: [...scanResult.notes, ...directDeclaredNotes], freshness });
2863
+ directReceipt = buildDirectReceipt({
2864
+ requestId, issuedAt: new Date().toISOString(), commit: { sha: snapshotBCommit.commit, tree: snapshotBCommit.tree },
2865
+ snapshotPath: snapshotB, treeAtStart: snapshotBCommit.tree, treeAtExit: snapshotBCommit.tree, ownershipHandle: o.buildOwnership?.handle ?? null,
2866
+ declaredInputDigests: directDeclared, verdict: directVerdict({ scans: scanResult.scans, failures, deferrals }), freshness,
2867
+ timingsMs: { bind: directTiming.bindEnd - directTiming.bindStart, cargo: directTiming.cargoEnd - directTiming.cargoStart, afterExitBind: directTiming.afterEnd - directTiming.afterStart, diagnostics: directTiming.diagnostics + (Date.now() - wrote) },
2868
+ exit: exitCode, diagnosticsPath, outputDirs: targetDirs ?? {}, outputResolution,
2869
+ cargoWrapper: cargoShim ? { identity: cargoShim.identity, workspaces: cargoShim.workspaces } : null,
2870
+ });
2871
+ } catch (error) { o.err(`${tag} BLOCKED: RK_DIRECT_RECEIPT: the direct receipt cannot be written (${error.message}); the compile's evidence is incomplete`); return 1; }
2872
+ const receiptProblems = verifyDirectReceipt(directReceipt);
2873
+ if (receiptProblems.length) { o.err(`${tag} BLOCKED: RK_DIRECT_RECEIPT: the direct receipt does not verify: ${receiptProblems.join("; ")}`); return 1; }
2874
+ o.onDirectReceipt?.(directReceipt);
2875
+ o.out(`${tag} direct receipt ${JSON.stringify(directReceipt)}`);
2876
+ }
2462
2877
  }
2463
2878
  if (failures.length) {
2464
2879
  o.err(`${tag} BLOCKED (${failures.length})`);
@@ -2526,5 +2941,7 @@ export const callingGatePath = () => fs.realpathSync(fileURLToPath(import.meta.u
2526
2941
  if (invokedDirectly) {
2527
2942
  // Every child this run starts that can reach rustright (scans, unit stages, right-release dev/test, their builds) inherits it.
2528
2943
  process.env.RIGHTKIT_CALLING_GATE = callingGatePath();
2529
- main(process.argv.slice(2)).then((code) => process.exit(code), (e) => { console.error(`rightkit-gate: ${e?.stack ?? e}`); process.exit(2); });
2944
+ // Production carries the RustRight snapshot carrier (`o.snapshotBroker`, item 7 rows 7.20 to 7.24): the door's seam for a broker host.
2945
+ // The per-compile broker (pins, declared inputs, ownership, receipts) is built where the snapshot compile runs. Tests inject their own.
2946
+ import("../snapshot-broker.mjs").then(({ PRODUCTION_SNAPSHOT_CARRIER }) => main(process.argv.slice(2), { snapshotBroker: PRODUCTION_SNAPSHOT_CARRIER })).then((code) => process.exit(code), (e) => { console.error(`rightkit-gate: ${e?.stack ?? e}`); process.exit(2); });
2530
2947
  }