@rightkit/release 0.6.5 → 0.6.7

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 (73) hide show
  1. package/README.md +196 -11
  2. package/admission-files.mjs +3 -2
  3. package/advisory-db.json +4 -0
  4. package/advisory-db.mjs +124 -0
  5. package/advisory-exceptions.mjs +13 -10
  6. package/android-play-upload.mjs +4 -4
  7. package/app-check.mjs +702 -26
  8. package/apple-profile-create.mjs +4 -3
  9. package/apple-profile-renew.mjs +5 -4
  10. package/bin/darwin/rightkit-swap +0 -0
  11. package/bin/rightkit-agent-hook.mjs +9 -0
  12. package/bin/rightkit-gate.mjs +671 -133
  13. package/binary-license-notices.mjs +2 -1
  14. package/build-release.mjs +20 -12
  15. package/cache-command.mjs +4 -3
  16. package/cargo-license-notices.mjs +2 -1
  17. package/cargo-manifest-text.mjs +25 -0
  18. package/cargo-profile-contract.mjs +26 -13
  19. package/cli/right-release.mjs +37 -7
  20. package/command-spec.mjs +4 -5
  21. package/compile-inputs.mjs +66 -0
  22. package/consumer-contracts.json +1044 -0
  23. package/consumer-contracts.mjs +627 -0
  24. package/consumer-input-contracts.mjs +422 -0
  25. package/consumer-policy.mjs +588 -0
  26. package/consumer-source-contracts.mjs +469 -0
  27. package/deny-default.toml +42 -0
  28. package/deps.mjs +5 -4
  29. package/deterministic-scans.mjs +513 -54
  30. package/development.mjs +5 -5
  31. package/disk-command.mjs +270 -30
  32. package/disk-policy.json +50 -10
  33. package/disk-policy.mjs +482 -35
  34. package/disk-report.mjs +129 -0
  35. package/door-policy.mjs +228 -0
  36. package/gate-executor.mjs +30 -0
  37. package/gate-stages.mjs +110 -0
  38. package/github-release.mjs +5 -3
  39. package/github-updater-release.mjs +48 -8
  40. package/github-upload-gate.mjs +6 -6
  41. package/init-command.mjs +175 -8
  42. package/ios-upload.mjs +4 -4
  43. package/legion-semantic-contracts.mjs +428 -0
  44. package/mirror-root-artifact.mjs +3 -4
  45. package/npm-license-notices.mjs +2 -1
  46. package/outdated-command.mjs +91 -0
  47. package/ownership-class-rules.mjs +277 -0
  48. package/ownership-key-authority.mjs +3 -1
  49. package/ownership-mandatory-set.mjs +3 -2
  50. package/package.json +46 -3
  51. package/pipeline-admission.mjs +12 -0
  52. package/publish-cargo.mjs +219 -4
  53. package/publish-update.mjs +19 -14
  54. package/release-config.mjs +87 -11
  55. package/release-dependencies-command.mjs +4 -3
  56. package/release-qa-audit.mjs +12 -10
  57. package/release-qa-command.mjs +42 -7
  58. package/release-qa-hardening.mjs +10 -3
  59. package/release-qa-policy.mjs +214 -47
  60. package/release.mjs +4 -4
  61. package/rightkit-registry-resolver.mjs +5 -2
  62. package/rightkit-versions.json +178 -170
  63. package/shell-contract-bridge.json +251 -0
  64. package/shell-contract.mjs +1069 -0
  65. package/sign-updater.mjs +24 -4
  66. package/source-closure-rules.mjs +204 -0
  67. package/stage-sidecar-command.mjs +7 -6
  68. package/stage-sidecar.mjs +437 -114
  69. package/suite-api.generated.mjs +96 -6
  70. package/suite-trust-roots.mjs +198 -0
  71. package/test-command.mjs +3 -2
  72. package/upload-release.mjs +3 -2
  73. package/web-build.mjs +2 -1
@@ -3,7 +3,7 @@
3
3
  // "local deterministic checks ... mandatory for all builds"). Runs from the pre-push hook that `rightkit-gate install-hook`
4
4
  // writes, so it never depends on an agent remembering. Every applicable scan for the pushed change runs through the one
5
5
  // scan module (../deterministic-scans.mjs); Rust units come from `cargo metadata` (one run per Cargo workspace), npm units from
6
- // pnpm-workspace.yaml, so the layout is the repository's own. CI still compiles, lints and tests everything.
6
+ // the pnpm roots' workspace files, so the layout is the repository's own. CI still compiles, lints and tests everything.
7
7
  //
8
8
  // Layout it covers (nothing is partly covered silently):
9
9
  // - Rust: every tracked Cargo.toml that is a workspace root or a standalone package not inside another workspace is its own Cargo
@@ -12,8 +12,11 @@
12
12
  // 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
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
14
14
  // Cargo.toml are skipped with a note on the first lines of the run, never silently.
15
- // - npm: the pnpm-workspace.yaml globs over tracked package.json files; when that file is absent, has no `packages:` or declares ".",
16
- // the root package.json is a unit too (tsc, eslint and test rows like any package).
15
+ // - npm: every pnpm root (discoverPnpmRoots: the repository root, and any tracked directory with a pnpm-lock.yaml or pnpm-workspace.yaml
16
+ // beside a package.json, outside node_modules) has its own units: the pnpm-workspace.yaml globs over tracked package.json files; when that
17
+ // file is absent, has no `packages:` or declares ".", the root's package.json is a unit too. pnpm-lockfile and pnpm-audit run once per
18
+ // root in its directory (`pnpm-lockfile:<root>` below the repository root), and each unit gets tsc, the linter it configures (eslint
19
+ // or biome, by config file) and its tests like any package. A unit of a root below the repository root is named from its directory.
17
20
  //
18
21
  // Release evidence for a PRODUCT repository (an app): the pin's "a release qualified locally on both hosts" is the app's own
19
22
  // `right-release` release QA run on both hosts (docs/CI.md "Release evidence for an app"). `right-release qualify cargo` qualifies
@@ -25,20 +28,31 @@
25
28
  // run the gate (default base origin/main); --plan prints the scans and runs none;
26
29
  // --no-cache (or RK_GATE_CACHE=0) reruns every scan
27
30
  // rightkit-gate check [--repo <dir>] [--commit <rev> | --worktree] [--only <rule,...>] [--list] [--print-baseline] [--baseline <file>]
28
- // the offline app check (../app-check.mjs): committed files only, no network, compile or
29
- // broker, every finding in one run. Exit 0 pass, 1 findings, 2 could not run
31
+ // the deterministic stage: the app check (../app-check.mjs), then every class A scan
32
+ // (lockfiles, cargo-deny/audit, secrets, licences, manifests, workflow lint, syntax) over the
33
+ // whole tree of the commit, not a diff. Nothing compiles; without a broker class A uses
34
+ // plain cargo for metadata/deny/audit only. The RustRight broker runs this before it admits
35
+ // any compile. --list/--print-baseline/--only show the app check alone. Exit 0 pass,
36
+ // 1 findings, 2 could not run
30
37
  // rightkit-gate install-hook [--force] write .githooks/pre-push and set core.hooksPath. core.hooksPath hides .git/hooks, so the
31
38
  // pre-push hook runs the repository's own .git/hooks/pre-push first, then `rightkit-gate
32
39
  // check --commit HEAD`, then the gate; every other hook present in .git/hooks gets a
33
40
  // .githooks/<name> trampoline that runs it; the hook finds the gate binary in the root
34
- // node_modules/.bin, then any workspace package's, then pnpm exec. Where @rightkit/release is
41
+ // node_modules/.bin, then any workspace package's (each package that declares @rightkit/release by
42
+ // name when the root does not declare it), then pnpm exec, then node at a stable path inside the
43
+ // repository (hookGatePlan: from the declarations, never from what is installed). Where @rightkit/release is
35
44
  // installed it also writes the advisory ownership pre-commit step (`right-release hooks install`
36
45
  // is an alias of this command)
37
46
  //
38
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
39
48
  // read the live checkout), and the remote's current commit is the diff base. To push an earlier commit of the local stack,
40
49
  // check it out first (a detached HEAD is fine), push HEAD:<branch>, return.
41
- // ci/gate.config.json `rust` (optional) sets per-crate feature sets, exactly as CI's gate reads it.
50
+ // The executor is chosen by the gate, never the caller (gate-executor.mjs resolveExecutor): the RustRight broker where this machine
51
+ // has it (`rustright` on PATH, or its managed cargo shim), a refusal on a broker-managed machine whose broker is unreachable, and
52
+ // direct cargo on any other host (a cloud container, a CI runner). Every host runs the same gates in the same order (app rules, class A,
53
+ // the compile scans); an unmanaged host is never told to find a broker. The pre-push hook judges every pushed ref.
54
+ // ci/gate.config.json `rust` (optional) sets per-crate feature sets, exactly as CI's gate reads it; `npm.<unit>` sets `quietTests` and
55
+ // `requiresBroker: { "reason": "..." }` (a test the package scripts do not show reaching the broker: node -e, node scripts, sh -c, pnpm --dir).
42
56
  // Result cache: a passing class B scan is reused only when its scan id, command, tool version, environment and input digest
43
57
  // (the gated commit's git object ids for the crate's workspace closure, the shared build inputs, ci/gate.config.json and every
44
58
  // path the closure's sources include from outside their crate; the whole HEAD tree for Rust tests unless ci/gate.config.json
@@ -53,18 +67,22 @@ import fs from "node:fs";
53
67
  import os from "node:os";
54
68
  import path from "node:path";
55
69
  import { fileURLToPath } from "node:url";
56
- import { WORKSPACE_RUST_FILE, ancestorConfigChanged, ancestorDirsOf, canonicalJson, defaultSpawn, digestScanSet, fileScanCache, metadataArgs, planScans, runScans, scanDeferrals, scanFailures } from "../deterministic-scans.mjs";
70
+ import { BIOME_CONFIG_FILES, ESLINT_CONFIG_FILES, WORKSPACE_RUST_FILE, ancestorConfigChanged, ancestorDirsOf, canonicalJson, defaultSpawn, digestScanSet, fileScanCache, findRustfmt, metadataArgs, planScans, runScans, scanDeferrals, scanFailures } from "../deterministic-scans.mjs";
57
71
  import { changedLockNames, parseLock, reach } from "../lock-graph.mjs";
72
+ import { resolveExecutor } from "../gate-executor.mjs";
73
+ import { bindingOf, endRun, readReceipt, RECEIPT_SCHEMA, sameBinding, sameSource, sealedFor, stagesTo, startRun, STAGES, writeReceipt } from "../gate-stages.mjs";
74
+ import { spawnSync as spawnInherit } from "../background-process.mjs";
58
75
 
59
76
  const ZERO = /^0+$/;
60
- const GRANT_FILE = ".rightkit-local-build.json";
61
77
  const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
62
78
  const posix = (p) => String(p).replaceAll("\\", "/");
63
79
  const text = (r) => String(r?.stdout ?? "");
64
80
  const failed = (r) => !r || Boolean(r.error) || r.status !== 0;
65
81
  const why = (r) => String(r?.error?.message ?? r?.stderr ?? "").trim().split(/\r?\n/).slice(-3).join(" | ");
66
82
 
67
- const USAGE = `usage: rightkit-gate [--base <ref>] [--plan] [--no-cache] [--pre-push] | rightkit-gate install-hook [--force] | rightkit-gate check [--repo <dir>] [--commit <rev> | --worktree] [--only <rule,...>] [--list] [--print-baseline] [--baseline <file>] | --version`;
83
+ // A green `check --only` view: app rules passed but no class A scan ran. Distinct from 0 so no caller can admit it as a full check.
84
+ export const PARTIAL_EXIT = 3;
85
+ const USAGE = `usage: rightkit-gate --to check|compile|package|qualify|release [--platform mac|win] [--tier patch|update] [--receipt-in <file>] [--receipt-out <file>] | rightkit-gate --to dev|test [-- <right-release args>] | rightkit-gate stage --platform mac|win [--tier patch|update] | rightkit-gate [--base <ref>] [--plan] [--no-cache] [--pre-push] | rightkit-gate install-hook [--force] | rightkit-gate check [--repo <dir>] [--commit <rev> | --worktree] [--only <rule,...>] [--list] [--print-baseline] [--baseline <file>] | --version`;
68
86
 
69
87
  // ---- pure helpers (exported for tests) ----------------------------------------------------------------------
70
88
 
@@ -115,31 +133,77 @@ const unitName = (pkgName, dir) => {
115
133
  return SAFE_NAME.test(n) ? n : dir === "." ? "root" : dir.replace(/\//g, "-");
116
134
  };
117
135
 
136
+ // The bounds of the pnpm root walk. A lock deeper than this is a buried fixture, not an app; more roots than this is refused, because covering
137
+ // some of them would hide the rest.
138
+ const PNPM_ROOT_MAX_DEPTH = 6;
139
+ const PNPM_ROOT_MAX_COUNT = 64;
140
+
141
+ /** Tracked files with exactly this name (a lister that ignores the name is filtered here). */
142
+ const trackedNamed = (tracked, name) => tracked(name).map(posix).filter((p) => path.posix.basename(p) === name);
143
+
144
+ /**
145
+ * The pnpm roots of the repository, sorted: "." when the repository root holds a pnpm-workspace.yaml or a package.json, and every other
146
+ * tracked directory (outside node_modules, at most 6 levels deep) that holds a pnpm-lock.yaml or a pnpm-workspace.yaml beside a
147
+ * package.json. A repository such as CodeRight (apps/coderight-tauri and apps/coderight-web, each with its own lock, nothing at the root) has
148
+ * two; a plain repository has "." only. A walk over the tracked files, never the file system: nothing in node_modules or untracked is a root.
149
+ */
150
+ export function discoverPnpmRoots({ root, tracked, exists }) {
151
+ const manifestDirs = new Set(trackedNamed(tracked, "package.json").map((p) => path.posix.dirname(p)));
152
+ const roots = new Set();
153
+ if (exists(path.join(root, "pnpm-workspace.yaml")) || exists(path.join(root, "package.json"))) roots.add(".");
154
+ for (const file of [...trackedNamed(tracked, "pnpm-lock.yaml"), ...trackedNamed(tracked, "pnpm-workspace.yaml")]) {
155
+ const dir = path.posix.dirname(file);
156
+ const parts = dir.split("/");
157
+ if (dir === "." || parts.includes("node_modules") || parts.length > PNPM_ROOT_MAX_DEPTH || !manifestDirs.has(dir)) continue;
158
+ roots.add(dir);
159
+ }
160
+ if (roots.size > PNPM_ROOT_MAX_COUNT) throw new Error(`${roots.size} pnpm roots (the gate covers at most ${PNPM_ROOT_MAX_COUNT}, and covering some would hide the rest)`);
161
+ return [...roots].sort();
162
+ }
163
+
118
164
  /**
119
- * npm units: pnpm-workspace.yaml globs over tracked package.json files, else the root package.json. The root package.json is a
120
- * unit as well when the workspace file is absent, has no `packages:` (or an empty one) or declares "." (or "./"); with a
121
- * `packages:` list that does not name the root it is only the workspace's own manifest and is not a unit.
165
+ * npm units, per pnpm root (discoverPnpmRoots). Within a root: pnpm-workspace.yaml globs over the tracked package.json files, else the root's
166
+ * own package.json. The root package.json is a unit as well when the workspace file is absent, has no `packages:` (or an empty one) or declares
167
+ * "." (or "./"); with a `packages:` list that does not name the root it is only the workspace's own manifest and is not a unit. A directory
168
+ * belongs to the deepest root that holds it. The repository root's units are named from their package names (`@scope/x` -> `scope-x`, a
169
+ * collision falls back to the directory) and carry no `root`, exactly as in a repository with one root. The units of any other root are named
170
+ * from their directory (`apps/coderight-web` -> `apps-coderight-web`), so their scan ids carry the root path, and carry `root`.
122
171
  */
123
172
  export function discoverNpmUnits({ root, tracked, readFile, exists }) {
124
- let dirs;
125
- const workspaceFile = path.join(root, "pnpm-workspace.yaml");
126
- if (exists(workspaceFile)) {
127
- const globs = parseWorkspaceGlobs(readFile(workspaceFile));
128
- const inc = globs.filter((g) => !g.startsWith("!")).map(globToRegExp);
129
- const exc = globs.filter((g) => g.startsWith("!")).map((g) => globToRegExp(g.slice(1)));
130
- const positive = globs.filter((g) => !g.startsWith("!"));
131
- const rootDeclared = positive.length === 0 || positive.some((g) => g === "." || g === "./");
132
- dirs = tracked("package.json").map((p) => path.posix.dirname(p)).filter((d) => (d === "." ? rootDeclared : inc.some((r) => r.test(d))) && !exc.some((r) => r.test(d)));
133
- } else dirs = exists(path.join(root, "package.json")) ? ["."] : [];
134
- dirs = [...new Set(dirs)].sort();
135
- const rows = dirs.map((dir) => {
173
+ const roots = discoverPnpmRoots({ root, tracked, exists });
174
+ const manifestDirs = [...new Set(trackedNamed(tracked, "package.json").map((p) => path.posix.dirname(p)))];
175
+ const within = (rootDir, dir) => (rootDir === "." ? dir : dir === rootDir ? "." : dir.startsWith(`${rootDir}/`) ? dir.slice(rootDir.length + 1) : null);
176
+ const joined = (rootDir, rel) => (rootDir === "." ? rel : rel === "." ? rootDir : `${rootDir}/${rel}`);
177
+ const ownerOf = (dir) => roots.reduce((best, r) => (within(r, dir) !== null && (best === null || r.length > best.length) ? r : best), null);
178
+ const found = new Map();
179
+ for (const rootDir of roots) {
180
+ let dirs;
181
+ const workspaceFile = path.join(root, joined(rootDir, "pnpm-workspace.yaml"));
182
+ if (exists(workspaceFile)) {
183
+ const globs = parseWorkspaceGlobs(readFile(workspaceFile));
184
+ const inc = globs.filter((g) => !g.startsWith("!")).map(globToRegExp);
185
+ const exc = globs.filter((g) => g.startsWith("!")).map((g) => globToRegExp(g.slice(1)));
186
+ const positive = globs.filter((g) => !g.startsWith("!"));
187
+ const rootDeclared = positive.length === 0 || positive.some((g) => g === "." || g === "./");
188
+ dirs = manifestDirs.map((d) => within(rootDir, d)).filter((rel) => rel !== null && (rel === "." ? rootDeclared : inc.some((r) => r.test(rel))) && !exc.some((r) => r.test(rel))).map((rel) => joined(rootDir, rel));
189
+ } else dirs = exists(path.join(root, joined(rootDir, "package.json"))) ? [rootDir] : [];
190
+ for (const dir of dirs) if (ownerOf(dir) === rootDir) found.set(dir, rootDir);
191
+ }
192
+ const rows = [...found.keys()].sort().map((dir) => {
136
193
  let manifest;
137
194
  try { manifest = JSON.parse(readFile(path.join(root, dir, "package.json")).replace(/^/, "")); } catch (e) { throw new Error(`${dir}/package.json: ${e.message}`); }
138
- return { dir, name: unitName(manifest.name, dir) };
195
+ const rootDir = found.get(dir);
196
+ return { dir, root: rootDir, name: rootDir === "." ? unitName(manifest.name, dir) : dir.replace(/\//g, "-") };
139
197
  });
140
198
  const count = new Map();
141
199
  for (const r of rows) count.set(r.name, (count.get(r.name) ?? 0) + 1);
142
- return rows.map((r) => ({ name: count.get(r.name) > 1 ? unitName("", r.dir === "." ? "root" : r.dir) : r.name, dir: r.dir }));
200
+ const named = rows.map((r) => ({ ...r, name: count.get(r.name) > 1 && r.root === "." ? unitName("", r.dir === "." ? "root" : r.dir) : r.name }));
201
+ const taken = new Set();
202
+ for (const r of named) {
203
+ if (taken.has(r.name)) throw new Error(`two npm units are named ${r.name} (${r.dir}); rename one of the packages`);
204
+ taken.add(r.name);
205
+ }
206
+ return named.map((r) => ({ name: r.name, dir: r.dir, ...(r.root === "." ? {} : { root: r.root }) }));
143
207
  }
144
208
 
145
209
  const dirOfManifest = (m) => path.posix.dirname(m);
@@ -205,13 +269,56 @@ export function manifestTables(text) {
205
269
  return tables;
206
270
  }
207
271
 
272
+ /**
273
+ * The value text of `key` in the top-level table `table` of a manifest read by manifestTables (everything from the `=` on), whether it is
274
+ * written under `[table]` or as the dotted root key `table.key = ...`; null when absent. No value parsing: tomlString and tomlStrings read it.
275
+ */
276
+ export function tomlValue(tables, table, key) {
277
+ const esc = (x) => x.replace(/[.*+?^${}()|[\]\\-]/g, "\\$&");
278
+ const own = tables.get(table) ?? "";
279
+ const inTable = new RegExp(`^[ \\t]*${esc(key)}[ \\t]*=[ \\t]*`, "m").exec(own);
280
+ if (inTable) return own.slice(inTable.index + inTable[0].length);
281
+ const root = tables.get("preamble") ?? "";
282
+ const dotted = new RegExp(`^[ \\t]*${esc(table)}\\.${esc(key)}[ \\t]*=[ \\t]*`, "m").exec(root);
283
+ return dotted ? root.slice(dotted.index + dotted[0].length) : null;
284
+ }
285
+
286
+ /** True when the manifest has the top-level table `name`: as `[name]`, as a `[name.sub]` table, or as dotted root keys `name.key = ...`. */
287
+ export const hasTomlTable = (tables, name) => tables.has(name) || [...tables.keys()].some((k) => k.startsWith(`${name}.`)) || new RegExp(`^[ \\t]*${name.replace(/\W/g, "\\$&")}\\.`, "m").test(tables.get("preamble") ?? "");
288
+
289
+ /** The string a TOML value starts with ("basic" or 'literal'); null when the value is not a string. */
290
+ export function tomlString(value) {
291
+ const m = /^(?:"((?:[^"\\\n]|\\.)*)"|'([^'\n]*)')/.exec(String(value ?? ""));
292
+ return m ? (m[1] !== undefined ? m[1].replace(/\\(.)/g, "$1") : m[2]) : null;
293
+ }
294
+
295
+ /**
296
+ * The strings of the TOML array a value starts with: "basic" and 'literal' strings, across lines, with `#` comments (and any `]` in
297
+ * them or in a string) skipped. [] when the value is not an array.
298
+ */
299
+ export function tomlStrings(value) {
300
+ const s = String(value ?? "");
301
+ if (s[0] !== "[") return [];
302
+ const out = [];
303
+ for (let i = 1; i < s.length;) {
304
+ const c = s[i];
305
+ if (c === "]") break;
306
+ if (c === "#") { while (i < s.length && s[i] !== "\n") i += 1; continue; }
307
+ if (c === '"' || c === "'") {
308
+ let j = i + 1; let v = "";
309
+ while (j < s.length && s[j] !== c && s[j] !== "\n") { if (c === '"' && s[j] === "\\" && j + 1 < s.length) { v += s[j + 1]; j += 2; } else { v += s[j]; j += 1; } }
310
+ out.push(v); i = j + 1; continue;
311
+ }
312
+ i += 1;
313
+ }
314
+ return out;
315
+ }
316
+
208
317
  /** The repository-relative directories a workspace manifest `exclude`s (`[workspace] exclude = [...]`); [] when none or unreadable. */
209
318
  export function workspaceExcludes(readFile, root, cargoDir) {
210
319
  let text;
211
320
  try { text = readFile(path.join(root, cargoDir === "." ? "Cargo.toml" : `${cargoDir}/Cargo.toml`)); } catch { return []; }
212
- const body = manifestTables(text).get("workspace") ?? "";
213
- const list = /^\s*exclude\s*=\s*\[([\s\S]*?)\]/m.exec(body)?.[1] ?? "";
214
- return [...list.matchAll(/"([^"]+)"/g)].map((m) => posix(m[1]).replace(/\/+$/, "")).map((d) => (cargoDir === "." ? d : `${cargoDir}/${d}`));
321
+ return tomlStrings(tomlValue(manifestTables(text), "workspace", "exclude")).map((d) => posix(d).replace(/\/+$/, "")).map((d) => (cargoDir === "." ? d : `${cargoDir}/${d}`));
215
322
  }
216
323
 
217
324
  // The manifest tables whose every effect on a build is also written into Cargo.lock: a changed source ([patch.*]) and workspace
@@ -254,8 +361,10 @@ export function manifestChangeIsLockReflected(before, after) {
254
361
  * A member whose Cargo.toml is not tracked (untracked or ignored) is dropped into `skipped`, as are `untracked()` manifests.
255
362
  * Returns { present, workspaces: [{ cargoDir, units }], units, cargoDir, skipped: [{ manifest, why }] }; `units` is every workspace's
256
363
  * [{ name, crate, dir, publish, cargoDir }] and `cargoDir` the first workspace's (the repository root's when it has one).
364
+ * `metadata(dir)` (optional) replaces the cargo call with a reader of the same { cargoDir, members } shape (extra fields are ignored): the
365
+ * deferred pre-push gate passes manifestWorkspaceMetadata where no broker can run cargo.
257
366
  */
258
- export function discoverRustWorkspaces({ root, spawn, cargoCommand, tracked, untracked = () => [], realpath = fs.realpathSync, fixtures = [], readFile = (f) => fs.readFileSync(f, "utf8") }) {
367
+ export function discoverRustWorkspaces({ root, spawn, cargoCommand, tracked, untracked = () => [], realpath = fs.realpathSync, fixtures = [], readFile = (f) => fs.readFileSync(f, "utf8"), metadata = null }) {
259
368
  const manifests = tracked("Cargo.toml");
260
369
  const trackedSet = new Set(manifests);
261
370
  const locks = new Set(tracked("Cargo.lock"));
@@ -283,7 +392,7 @@ export function discoverRustWorkspaces({ root, spawn, cargoCommand, tracked, unt
283
392
  if (!locks.has(`${dir}/Cargo.lock`)) { skipped.push({ manifest: m, why: `below the Cargo workspace ${parent.cargoDir} with no committed Cargo.lock, so its build is not reproducible; gated through the consumer rows of the crate it exercises` }); continue; }
284
393
  }
285
394
  let found;
286
- try { found = workspaceMetadata({ root, spawn, cargoCommand, dir, realpath }); } catch (e) {
395
+ try { found = metadata ? metadata(dir) : workspaceMetadata({ root, spawn, cargoCommand, dir, realpath }); } catch (e) {
287
396
  if (!parent) throw e;
288
397
  skipped.push({ manifest: m, why: `below the Cargo workspace ${parent.cargoDir} but not one of its members, and cargo refuses it on its own (${String(e.message).replace(/^cargo metadata failed: /, "").replace(/ \(in [^)]*\)$/, "")})` });
289
398
  continue;
@@ -306,6 +415,27 @@ export function discoverRustWorkspaces({ root, spawn, cargoCommand, tracked, unt
306
415
  return { present: true, workspaces, units, cargoDir: workspaces[0]?.cargoDir ?? ".", skipped };
307
416
  }
308
417
 
418
+ // ---- pre-push input and lock scope ------------------------------------------------------------------------
419
+
420
+ /**
421
+ * git's pre-push stdin: one "<local ref> <local sha> <remote ref> <remote sha>" per line, the four fields joined by single ASCII spaces.
422
+ * Git forbids an ASCII space in a ref name but allows Unicode whitespace (U+00A0, U+2003, U+3000, ...), so the split is on " " alone: a
423
+ * whitespace split would cut such a ref into extra fields and the line would vanish. Blank lines are skipped; any other line that is not
424
+ * four non-empty fields is returned in `malformed` ({ number, line }), never dropped. Returns { lines: [{ localRef, localSha, remoteRef,
425
+ * remoteSha }], malformed }.
426
+ */
427
+ export function parsePushLines(stdin) {
428
+ const lines = []; const malformed = [];
429
+ String(stdin ?? "").split("\n").forEach((raw, index) => {
430
+ const line = raw.replace(/\r$/, "");
431
+ if (line === "") return;
432
+ const f = line.split(" ");
433
+ if (f.length !== 4 || f.some((x) => x === "")) { malformed.push({ number: index + 1, line }); return; }
434
+ lines.push({ localRef: f[0], localSha: f[1], remoteRef: f[2], remoteSha: f[3] });
435
+ });
436
+ return { lines, malformed };
437
+ }
438
+
309
439
  /**
310
440
  * The crates a lock-reflected workspace change reaches (the lock graph CI's selection uses): a Cargo.lock change, a vendor/ change
311
441
  * (the vendored crate is a lock package), and a workspace manifest change confined to the tables the lock also records
@@ -351,8 +481,18 @@ export function lockScope({ changed, cargoDir = ".", unitNames, showBase, readLo
351
481
  * gate binary is the root node_modules/.bin/rightkit-gate, else any workspace package's (up to three levels down: tauri-app-next,
352
482
  * apps/*, packages/*), else `pnpm exec rightkit-gate`, else node with a package-relative path when pnpm is absent; the line it
353
483
  * chose is printed once. No absolute path is written: everything is resolved from git at run time.
484
+ *
485
+ * `packages` (default none) is the multi-root form: the repository-relative directories of the packages that declare @rightkit/release when the
486
+ * 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
487
+ * 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`;
488
+ * 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
489
+ * (fixtures/published-gate-hooks.json holds its digest, and a repository whose root installs the package must keep getting it, byte for byte,
490
+ * or the committed hook of every app copied from the template stops being "already current"). `nodePath` and every package are written into
491
+ * a shell script: installHook passes only plain relative paths (hookGatePlan).
354
492
  */
355
- export function hookText(nodePath) {
493
+ export function hookText(nodePath, packages = []) {
494
+ const bins = packages.map((dir) => ` ${dir}/node_modules/.bin/rightkit-gate`).join("");
495
+ const pnpmExec = packages.length ? `pnpm --dir ${packages[0]} exec rightkit-gate` : "pnpm exec rightkit-gate";
356
496
  return `#!/bin/sh
357
497
  # Mandatory local check and gate before every push (rightkit-gate install-hook). Never bypass with --no-verify.
358
498
  cd "$(git rev-parse --show-toplevel)" || exit 1
@@ -365,17 +505,17 @@ if [ -x "$own" ]; then
365
505
  echo "rightkit-gate: running $own" >&2
366
506
  "$own" "$@" < "$stdin" || exit $?
367
507
  fi
368
- # The gate binary: the root install, any workspace package's install, then pnpm exec.
508
+ # 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"}.
369
509
  found=""
370
- for bin in node_modules/.bin/rightkit-gate */node_modules/.bin/rightkit-gate */*/node_modules/.bin/rightkit-gate */*/*/node_modules/.bin/rightkit-gate; do
510
+ 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
371
511
  if [ -x "$bin" ]; then found="$bin"; break; fi
372
512
  done
373
513
  if [ -n "$found" ]; then
374
514
  echo "rightkit-gate: using $found" >&2
375
515
  rightkit_gate() { "$found" "$@"; }
376
516
  elif command -v pnpm >/dev/null 2>&1; then
377
- echo "rightkit-gate: using pnpm exec rightkit-gate" >&2
378
- rightkit_gate() { pnpm exec rightkit-gate "$@"; }
517
+ echo "rightkit-gate: using ${pnpmExec}" >&2
518
+ rightkit_gate() { ${pnpmExec} "$@"; }
379
519
  else
380
520
  echo "rightkit-gate: using node ${nodePath}" >&2
381
521
  rightkit_gate() { node "${nodePath}" "$@"; }
@@ -405,7 +545,6 @@ exit 0
405
545
  // names are chained; anything else is reported and skipped, never interpolated.
406
546
  const HOOK_FILE_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
407
547
  const chainableHook = (name) => !name.endsWith(".sample") && HOOK_FILE_NAME.test(name);
408
-
409
548
  // ---- result cache inputs ------------------------------------------------------------------------------------
410
549
 
411
550
  // The build inputs that change how every crate compiles, relative to the Cargo workspace root, plus the gate's own config.
@@ -568,7 +707,7 @@ const plainRelative = (p) => typeof p === "string" && p !== "" && !path.posix.is
568
707
  * when declared. Without a declaration, a closure source that navigates out of its crate at run time without a literal path
569
708
  * (externalInputs `leavesCrate`: workspace root, current_dir, a bare "..", the manifest directory's parent) keys the scan on the
570
709
  * whole HEAD tree, since its tests may read anything.
571
- * - An npm package scan (test:npm, tsc, eslint) keys on the package's workspace closure (its `workspace:` dependencies,
710
+ * - An npm package scan (test:npm, tsc, eslint, biome) keys on the package's workspace closure (its `workspace:` dependencies,
572
711
  * transitively, by package name) and the root npm inputs; test:scripts-ci on scripts/, ci/, .github/, packages/release and the
573
712
  * root install graph. Every other class B scan (generated-docs, swift-build) uses the whole tree.
574
713
  * - Class A: cargo-deny, cargo-audit, pnpm-audit, pnpm-lockfile and advisory-exceptions are never cached (live answers); rustfmt,
@@ -577,6 +716,9 @@ const plainRelative = (p) => typeof p === "string" && p !== "" && !path.posix.is
577
716
  */
578
717
  // Root npm inputs every package scan reads: the install graph and the shared tool configuration.
579
718
  const SHARED_NPM_INPUTS = ["package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml", ".npmrc", "tsconfig.json", "tsconfig.base.json", "eslint.config.js", "eslint.config.mjs", ".eslintrc.json", "biome.json", "vitest.config.ts", "vitest.config.mjs", "vitest.workspace.ts"];
719
+ // ESLint and Biome look their configuration up from the package directory through every ancestor. A config in an ancestor directory (or of another
720
+ // name than the shared list above) is part of a package's key only when the commit has it, so a repository without one keeps the key it had.
721
+ const LINT_CONFIG_NAMES = [...ESLINT_CONFIG_FILES, ...BIOME_CONFIG_FILES];
580
722
  // The gate's own test suite reads the CI scripts, the gate config and the release package it imports from.
581
723
  const SCRIPTS_CI_INPUTS = ["scripts", "ci", ".github", "packages/release", "package.json", "pnpm-lock.yaml"];
582
724
 
@@ -597,7 +739,7 @@ export function buildCacheDigest({ objectId, headTree, rustUnits = [], npmUnits
597
739
  };
598
740
  let npmByPkgName = null;
599
741
  const npmClosure = (unit) => {
600
- if (npmByPkgName === null) { npmByPkgName = new Map(); for (const u of npmUnits) { const m = npmManifest(u); if (m?.name) npmByPkgName.set(m.name, u); } }
742
+ if (npmByPkgName === null) { npmByPkgName = new Map(); for (const u of npmUnits) { const m = npmManifest(u); if (m?.name) npmByPkgName.set(`${u.root ?? "."}\0${m.name}`, u); } }
601
743
  const seen = new Map([[unit.name, unit]]); const stack = [unit]; let unresolved = false;
602
744
  while (stack.length) {
603
745
  const u = stack.pop(); const m = npmManifest(u);
@@ -605,7 +747,7 @@ export function buildCacheDigest({ objectId, headTree, rustUnits = [], npmUnits
605
747
  for (const section of ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"]) {
606
748
  for (const [name, spec] of Object.entries(m[section] ?? {})) {
607
749
  if (!/^(workspace:|link:|file:)/.test(String(spec))) continue;
608
- const dep = npmByPkgName.get(name);
750
+ const dep = npmByPkgName.get(`${u.root ?? "."}\0${name}`);
609
751
  if (!dep) { unresolved = true; continue; }
610
752
  if (!seen.has(dep.name)) { seen.set(dep.name, dep); stack.push(dep); }
611
753
  }
@@ -613,6 +755,18 @@ export function buildCacheDigest({ objectId, headTree, rustUnits = [], npmUnits
613
755
  }
614
756
  return { members: [...seen.values()].sort((a, b) => (a.name < b.name ? -1 : 1)), unresolved };
615
757
  };
758
+ // The npm inputs shared by a set of units: the repository root's (always, null when absent: the key a one-root repository has always had),
759
+ // the same files of every other pnpm root the units belong to, and the linter configuration of their ancestor directories that the commit has.
760
+ const sharedNpmInputs = (members) => {
761
+ const required = new Set(SHARED_NPM_INPUTS); const optional = new Set();
762
+ for (const m of members) {
763
+ if ((m.root ?? ".") !== ".") for (const p of SHARED_NPM_INPUTS) required.add(inWs(m.root, p));
764
+ for (const dir of ancestorDirsOf(m.dir)) if (dir !== m.dir) for (const f of LINT_CONFIG_NAMES) optional.add(inWs(dir, f));
765
+ }
766
+ const nested = [...required].filter((p) => !SHARED_NPM_INPUTS.includes(p)).sort();
767
+ const extra = [...optional].filter((p) => !required.has(p)).sort();
768
+ return [...SHARED_NPM_INPUTS, ...nested].map((p) => [p, idOf(p)]).concat(extra.map((p) => [p, idOf(p)]).filter(([, id]) => id !== null && id !== undefined));
769
+ };
616
770
  // A unit belongs to the Cargo workspace it names (rustUnits[].cargoDir, else the global one) and is a package of that workspace's
617
771
  // own lock under its crate name (rustUnits[].crate, else its name); its closure never crosses into another workspace.
618
772
  const wsOf = (u) => u.cargoDir ?? cargoDir;
@@ -667,11 +821,11 @@ export function buildCacheDigest({ objectId, headTree, rustUnits = [], npmUnits
667
821
  if (!unit) {
668
822
  if (typeof readBlob !== "function") return whole();
669
823
  // npm package scans: the package's workspace closure plus the root npm inputs.
670
- const npmMatch = /^(test:npm|tsc|eslint):(.+)$/.exec(scan.id);
824
+ const npmMatch = /^(test:npm|tsc|eslint|biome):(.+)$/.exec(scan.id);
671
825
  if (npmMatch && npmByUnit.has(npmMatch[2])) {
672
826
  const { members, unresolved } = npmClosure(npmByUnit.get(npmMatch[2]));
673
827
  if (unresolved) return whole();
674
- return canonicalJson({ kind: "npm", id: scan.id, members: members.map((m) => [m.name, m.dir, idOf(m.dir)]), shared: SHARED_NPM_INPUTS.map((p) => [p, idOf(p)]) });
828
+ return canonicalJson({ kind: "npm", id: scan.id, members: members.map((m) => [m.name, m.dir, idOf(m.dir)]), shared: sharedNpmInputs(members) });
675
829
  }
676
830
  if (scan.id === "test:scripts-ci") return canonicalJson({ kind: "dirs", id: scan.id, inputs: SCRIPTS_CI_INPUTS.map((p) => [p, idOf(p)]) });
677
831
  return whole();
@@ -765,36 +919,67 @@ export function quietLeaseLane(o, { tag = "rightkit-gate:", reason = "rightkit-g
765
919
  };
766
920
  }
767
921
 
768
- // ---- command resolution -------------------------------------------------------------------------------------
769
922
 
770
- // Cargo only through the broker: `rustright cargo`, or a `cargo` on PATH that is the managed shim (it runs cargo-guard.mjs).
771
- function resolveCargoCommand(o) {
772
- const which = (cmd) => {
773
- const r = o.spawn(o.platform === "win32" ? "where" : "which", [cmd]);
774
- return failed(r) ? [] : text(r).split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
775
- };
776
- if (which("rustright").length) return ["rustright", "cargo"];
777
- const first = which("cargo")[0];
778
- if (first) { try { if (o.readFile(first).includes("cargo-guard.mjs")) return ["cargo"]; } catch { /* unreadable: not the shim */ } }
779
- return null;
780
- }
781
923
 
782
924
  // ---- install-hook -------------------------------------------------------------------------------------------
783
925
 
784
- /** The first tracked package.json (or the root one when git cannot list) that declares @rightkit/release, or is that package itself. */
785
- function releaseProvider(o, root, git) {
926
+ /**
927
+ * The tracked package.json files (the root one when git cannot list) that matter to the hook: `provider` is the first that declares
928
+ * @rightkit/release or is that package itself, `declaring` the directories of every one that declares it ("." for the root; the package itself is
929
+ * not one: it installs nothing of itself).
930
+ */
931
+ function releasePackages(o, root, git) {
786
932
  let files = ["package.json"];
787
933
  const listed = git("ls-files", "-z", "--", ":(glob)**/package.json");
788
934
  if (!failed(listed)) files = text(listed).split("\0").filter(Boolean).map(posix);
935
+ let provider = null; const declaring = [];
789
936
  for (const file of files) {
790
937
  const abs = path.join(root, file);
791
938
  if (!o.exists(abs)) continue;
792
939
  let manifest;
793
940
  try { manifest = JSON.parse(o.readFile(abs).replace(/^\uFEFF/, "")); } catch { continue; }
794
- if (manifest?.name === "@rightkit/release") return file;
795
- for (const section of ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"]) if (manifest?.[section] && Object.hasOwn(manifest[section], "@rightkit/release")) return file;
941
+ if (manifest?.name === "@rightkit/release") { provider ??= file; continue; }
942
+ if (["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"].some((section) => manifest?.[section] && Object.hasOwn(manifest[section], "@rightkit/release"))) {
943
+ provider ??= file;
944
+ declaring.push(path.posix.dirname(file));
945
+ }
796
946
  }
797
- return null;
947
+ return { provider, declaring: [...new Set(declaring)].sort() };
948
+ }
949
+
950
+ // 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
951
+ // 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).
952
+ const GATE_IN_PACKAGE = "node_modules/@rightkit/release/bin/rightkit-gate.mjs";
953
+ // What may be written into the committed shell script: a plain relative path of letters, digits and _ - . @ + /, no `..`, no `.` segment, no
954
+ // leading dash (it would read as an option of `pnpm --dir`).
955
+ const HOOK_PATH_SEGMENT = /^(?!\.{1,2}$)[A-Za-z0-9_@.+-]+$/;
956
+ export const hookPathIsPlain = (value) => typeof value === "string" && value !== "" && !value.startsWith("-") && value.split("/").every((segment) => HOOK_PATH_SEGMENT.test(segment));
957
+
958
+ /**
959
+ * What the pre-push hook is told about where the gate lives: { nodePath, packages, unsafe }. `nodePath` is the last resort the hook runs with
960
+ * node, `packages` the extra directories hookText searches (multi-root form), `unsafe` the declaring packages whose directory cannot be written
961
+ * into a shell script. `declaring` is releasePackages(...).declaring; `gateFile` is where this gate runs from.
962
+ *
963
+ * 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
964
+ * clone, an exports checkout, after `pnpm install` and after a version bump, so `install-hook` (a `prepare` script) never meets a hook it
965
+ * wrote earlier as one that "already exists and differs". It takes no `exists`, so that cannot creep back in. Always a stable,
966
+ * version-independent path relative to the repository, never one outside it, absolute, or inside the pnpm store:
967
+ * 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
968
+ * it at the root and in hub/; RightKit itself at the root).
969
+ * 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
970
+ * installed, and every one of them is searched.
971
+ * 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
972
+ * 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
973
+ * outside the repository. This is the one case that reads where the gate runs from, because the repository says nothing.
974
+ */
975
+ export function hookGatePlan({ root, gateFile, declaring }) {
976
+ const plain = declaring.filter((d) => d !== "." && hookPathIsPlain(d));
977
+ const unsafe = declaring.filter((d) => d !== "." && !hookPathIsPlain(d));
978
+ if (declaring.includes(".")) return { nodePath: GATE_IN_PACKAGE, packages: [], unsafe };
979
+ if (plain.length) return { nodePath: `${plain[0]}/${GATE_IN_PACKAGE}`, packages: plain, unsafe };
980
+ const relative = posix(path.relative(root, gateFile));
981
+ const inside = relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative) && !relative.split("/").includes("node_modules") && hookPathIsPlain(relative);
982
+ return { nodePath: inside ? relative : GATE_IN_PACKAGE, packages: [], unsafe };
798
983
  }
799
984
 
800
985
  // The advisory ownership pre-commit step (`right-release hooks install`, ownership-hooks.mjs) runs the pinned @rightkit/release, so
@@ -806,9 +991,10 @@ async function installHook(o, root, force) {
806
991
  const git = (...a) => o.spawn("git", a, { cwd: root });
807
992
  const hooksDir = path.join(root, ".githooks");
808
993
  const hookPath = path.join(hooksDir, "pre-push");
809
- const installed = "node_modules/@rightkit/release/bin/rightkit-gate.mjs";
810
- const nodePath = o.exists(path.join(root, installed)) ? installed : posix(path.relative(root, fileURLToPath(import.meta.url)));
811
- const wanted = hookText(nodePath);
994
+ const { provider, declaring } = releasePackages(o, root, git);
995
+ const { nodePath, packages, unsafe } = hookGatePlan({ root, gateFile: o.gateFile, declaring });
996
+ 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.`);
997
+ const wanted = hookText(nodePath, packages);
812
998
  const hasHook = o.exists(hookPath);
813
999
  const current = hasHook ? o.readFile(hookPath).replace(/\r\n/g, "\n") : null;
814
1000
  if (hasHook && current !== wanted && !force) {
@@ -869,30 +1055,276 @@ async function installHook(o, root, force) {
869
1055
  if (failed(set)) { o.err(`rightkit-gate: git config core.hooksPath failed: ${why(set)}`); return 2; }
870
1056
  done.push(`set core.hooksPath to .githooks${existingPath ? ` (was ${existingPath})` : ""}`);
871
1057
  }
1058
+ // 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
1059
+ // shell command, on every host (the broker-less cloud included), and denies any build that does not go through rightkit-gate.
1060
+ const agentHookFile = "node_modules/@rightkit/release/bin/rightkit-agent-hook.mjs";
1061
+ const agentHook = o.exists(path.join(root, agentHookFile)) ? agentHookFile : posix(path.relative(root, fileURLToPath(new URL("./rightkit-agent-hook.mjs", import.meta.url))));
1062
+ const settingsPath = path.join(root, ".claude", "settings.json");
1063
+ let settings = {};
1064
+ if (o.exists(settingsPath)) { try { settings = JSON.parse(o.readFile(settingsPath)); } catch { o.err("rightkit-gate: .claude/settings.json is not JSON; the agent hook was not added"); settings = null; } }
1065
+ if (settings) {
1066
+ const command = `node ${agentHook}`;
1067
+ const pre = Array.isArray(settings.hooks?.PreToolUse) ? settings.hooks.PreToolUse : [];
1068
+ const has = pre.some((entry) => (entry.hooks ?? []).some((h) => typeof h.command === "string" && h.command.includes("rightkit-agent-hook")));
1069
+ if (has) done.push(".claude/settings.json agent hook already present");
1070
+ else {
1071
+ settings.hooks = { ...(settings.hooks ?? {}), PreToolUse: [...pre, { matcher: "Bash", hooks: [{ type: "command", command }] }] };
1072
+ try { o.mkdir(path.dirname(settingsPath), { recursive: true }); } catch { /* exists */ }
1073
+ o.writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
1074
+ done.push("added the one-door agent hook to .claude/settings.json (commit it)");
1075
+ }
1076
+ }
872
1077
  for (const line of done) o.out(`rightkit-gate: ${line}`);
873
1078
  const chained = own.length ? own.map((name) => (name === "pre-push" ? "pre-push (run first, then the gate)" : name)) : [];
874
1079
  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"}`);
875
1080
  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`);
876
1081
  const files = [".githooks/pre-push", ...trampolines.map((t) => `.githooks/${t.name}`)];
877
- o.out(`rightkit-gate: commit the hooks with executable mode (git add --chmod=+x ${files.join(" ")}); the pre-push hook finds rightkit-gate in node_modules/.bin of the repository root, else of any workspace package, else through \`pnpm exec\`.`);
878
- const provider = releaseProvider(o, root, git);
1082
+ 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\`"}.`);
879
1083
  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.");
880
1084
  else o.out(`rightkit-gate: @rightkit/release is declared in ${provider}`);
881
1085
  return 0;
882
1086
  }
883
1087
 
1088
+ // ---- the stages: rightkit-gate --to <stage> --------------------------------------------------------------------------------
1089
+
1090
+ /**
1091
+ * Runs every stage up to `--to`, in order, for HEAD: check (class A), compile (the whole gate over every file), then, through the existing
1092
+ * owners, package (source QA, then `right-release build`, which seals the exact artifact set), qualify (installed QA on that sealed set)
1093
+ * and release (`right-release upload` of that same sealed set). Stops at the first stage that fails. check and compile are reused from
1094
+ * the receipt only when every bound input is unchanged; the right-release stages run under a live door run, so they refuse any other
1095
+ * caller (gate-stages.mjs requireDoorRun).
1096
+ */
1097
+ async function runStages(o, args, root, git, io) {
1098
+ const option = (name) => { const i = args.indexOf(name); return i < 0 ? undefined : args[i + 1]; };
1099
+ const tag = "rightkit-gate:";
1100
+ const to = option("--to");
1101
+ if (to === "dev" || to === "test") return await runWorktreeCompile(o, args, root, git, io, to);
1102
+ const stages = stagesTo(to);
1103
+ if (!stages) { o.err(`${tag} --to must be one of ${STAGES.join(", ")}`); return 2; }
1104
+ const platform = option("--platform");
1105
+ const tier = option("--tier");
1106
+ if (stages.includes("package") && !["mac", "win"].includes(platform)) { o.err(`${tag} --to ${to} needs --platform mac|win`); return 2; }
1107
+ if (stages.includes("release") && !["patch", "update"].includes(tier)) { o.err(`${tag} --to release needs --tier patch|update (chosen explicitly, never defaulted)`); return 2; }
1108
+ const commit = text(git("rev-parse", "HEAD")).trim();
1109
+ const tree = text(git("rev-parse", "HEAD^{tree}")).trim();
1110
+ const common = text(git("rev-parse", "--git-common-dir")).trim();
1111
+ if (!/^[0-9a-f]{40}$/.test(commit) || !tree || !common) { o.err(`${tag} cannot read HEAD and the git directory`); return 2; }
1112
+ const commonDir = path.resolve(root, common);
1113
+ const dirty = git("status", "--porcelain", "--untracked-files=no");
1114
+ 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; }
1115
+
1116
+ 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); };
1117
+ const executor = (o.resolveExecutor ?? resolveExecutor)({ env: o.env, platform: o.platform, exists: o.exists, which, readFile: o.readFile });
1118
+ if (executor.kind === "refuse") { o.err(`${tag} BLOCKED: ${executor.problem}`); return 1; }
1119
+ const version = (cmd, a) => { const r = o.spawn(cmd, a, { cwd: root }); return failed(r) ? null : text(r).trim().split(/\r?\n/)[0]; };
1120
+ const listed = git("ls-tree", "-r", "--name-only", "-z", "HEAD");
1121
+ const lockFiles = {};
1122
+ for (const f of failed(listed) ? [] : text(listed).split("\0").filter((x) => /(^|\/)(Cargo\.lock|pnpm-lock\.yaml)$/.test(x))) {
1123
+ const shown = git("show", `HEAD:${f}`); if (!failed(shown)) lockFiles[f] = text(shown);
1124
+ }
1125
+ const binding = bindingOf({
1126
+ tree, lockFiles, gateFile: fileURLToPath(import.meta.url), executor: executor.kind,
1127
+ toolchain: { classA: { "cargo-deny": version("cargo-deny", ["--version"]), "cargo-audit": version("cargo-audit", ["--version"]), actionlint: version("actionlint", ["-version"]), shellcheck: version("shellcheck", ["--version"]) }, node: process.version, rustc: version(executor.kind === "broker" && executor.cargo[0] === "rustright" ? "rustright" : "rustc", executor.kind === "broker" && executor.cargo[0] === "rustright" ? ["rustc", "-V"] : ["-V"]), pnpm: version("pnpm", ["--version"]) },
1128
+ });
1129
+ // Release writes to R2. The write credential lives only in the trusted publisher: a GitHub Actions job in the protected
1130
+ // publication environment, which sets RIGHTKIT_PUBLISHER=github-environment. Hosts and agents never hold it, so the door refuses the
1131
+ // stage anywhere else rather than fail half way through an upload.
1132
+ if (stages.includes("release") && !(o.env.GITHUB_ACTIONS === "true" && o.env.RIGHTKIT_PUBLISHER === "github-environment")) {
1133
+ o.err(`${tag} BLOCKED: --to release runs only in the trusted publisher (the release workflow's protected publication job). Run --to qualify here, then dispatch the release workflow for ${commit.slice(0, 12)}`);
1134
+ return 1;
1135
+ }
1136
+ // --receipt-in <file>: the receipt an earlier job of the same pipeline wrote (another runner: CI's package job, then its qualify and
1137
+ // publish jobs). It is taken only for this commit and the same source binding (tree, lockfiles, gate); it then stands in for the
1138
+ // stages it records, and the sealed bytes on disk must still hash to its manifest digest before anything uses them.
1139
+ const receiptIn = option("--receipt-in");
1140
+ if (receiptIn !== undefined) {
1141
+ let imported = null;
1142
+ try { imported = JSON.parse(o.readFile(path.resolve(o.cwd, receiptIn))); } catch { /* refused below */ }
1143
+ if (!imported || imported.schema !== RECEIPT_SCHEMA || imported.commit !== commit || !sameSource(imported.binding, binding)) {
1144
+ o.err(`${tag} BLOCKED: --receipt-in ${receiptIn} is not a ${RECEIPT_SCHEMA} receipt for ${commit.slice(0, 12)} with this tree, these lockfiles and this gate`);
1145
+ return 1;
1146
+ }
1147
+ (o.writeReceipt ?? writeReceipt)({ commonDir, receipt: imported });
1148
+ }
1149
+ const previous = (o.readReceipt ?? readReceipt)({ commonDir, commit });
1150
+ // Same host and inputs: every recorded stage counts. Same source on another host: only the stages bound to sealed bytes (package and
1151
+ // later) carry over; check and compile run again here.
1152
+ const receipt = previous && sameBinding(previous.binding, binding) ? previous
1153
+ : previous && sameSource(previous.binding, binding) ? { ...previous, binding, stages: Object.fromEntries(Object.entries(previous.stages ?? {}).filter(([k]) => k !== "check" && k !== "compile")) }
1154
+ : { schema: RECEIPT_SCHEMA, commit, binding, stages: {} };
1155
+ const receiptOut = option("--receipt-out");
1156
+ const save = () => {
1157
+ (o.writeReceipt ?? writeReceipt)({ commonDir, receipt });
1158
+ if (receiptOut !== undefined) o.writeFile(path.resolve(o.cwd, receiptOut), `${JSON.stringify(receipt, null, 2)}\n`);
1159
+ };
1160
+
1161
+ const cli = fileURLToPath(new URL("../cli/right-release.mjs", import.meta.url));
1162
+ const rightRelease = (stage, cliArgs) => {
1163
+ const run = startRun({ commonDir, commit, tree, stage });
1164
+ try {
1165
+ o.out(`${tag} [${stage}] right-release ${cliArgs.join(" ")}`);
1166
+ const r = (o.spawnInherit ?? ((cmd, a, opts) => spawnInherit(cmd, a, { ...opts, stdio: "inherit" })))(process.execPath, [cli, ...cliArgs], { cwd: root, env: { ...o.env, ...run.env } });
1167
+ return r.status === 0;
1168
+ } finally { endRun(run); }
1169
+ };
1170
+
1171
+ o.out(`${tag} --to ${to} for ${commit.slice(0, 12)}: ${stages.join(" -> ")}; executor ${executor.kind}`);
1172
+ // A later stage on the sealed bytes: check, compile and package already passed for them (in this receipt) and the bytes on disk are
1173
+ // the ones they sealed, so they are not run again.
1174
+ const sealedNow = receipt.stages.package?.ok ? (o.sealedFor ?? sealedFor)({ root, commit, platform }) : null;
1175
+ const sealedIntact = Boolean(sealedNow && sealedNow.id === receipt.stages.package.sealed && sealedNow.digest === receipt.stages.package.manifestSha256);
1176
+ for (const stage of stages) {
1177
+ if ((stage === "check" || stage === "compile") && receipt.stages[stage]?.ok) { o.out(`${tag} [${stage}] already passed for this exact input (commit, tree, lockfiles, gate, toolchain)`); continue; }
1178
+ if ((stage === "check" || stage === "compile" || stage === "package") && sealedIntact) {
1179
+ o.out(`${tag} [${stage}] already passed for the sealed set ${sealedNow.id} (manifest ${sealedNow.digest.slice(0, 12)})`); continue;
1180
+ }
1181
+ // The publisher (another runner, often another OS) cannot repeat installed QA; it takes the qualify result recorded for exactly
1182
+ // these sealed bytes. --to qualify itself always runs it.
1183
+ if (stage === "qualify" && to !== "qualify" && sealedIntact && receipt.stages.qualify?.ok && receipt.stages.qualify.manifestSha256 === sealedNow.digest) {
1184
+ o.out(`${tag} [qualify] already passed for the sealed set ${sealedNow.id} (manifest ${sealedNow.digest.slice(0, 12)})`); continue;
1185
+ }
1186
+ // The publisher only uploads. It never checks out a build environment, signs or runs installed QA, so every earlier stage must
1187
+ // already stand in its receipt for exactly the sealed bytes on disk; anything else is refused, never rebuilt or requalified here.
1188
+ if (to === "release" && stage !== "release") {
1189
+ 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`);
1190
+ return 1;
1191
+ }
1192
+ let ok = false; const at = new Date().toISOString(); let extra = {};
1193
+ if (stage === "check") ok = (await (o.runCheck ?? (() => main(["check", "--commit", "HEAD"], { ...io, cwd: root })))()) === 0;
1194
+ else if (stage === "compile") {
1195
+ // A Tauri crate with externalBin needs its sidecars in src-tauri/binaries before it compiles (its build script refuses
1196
+ // otherwise), so every release config that declares `sidecars` is staged first, through right-release under this door run.
1197
+ const listedConfigs = git("ls-tree", "-r", "--name-only", "-z", "HEAD");
1198
+ const sidecarConfigs = (failed(listedConfigs) ? [] : text(listedConfigs).split("\0").filter((f) => /(^|\/)right-release\.config\.(json|mjs)$/.test(f)))
1199
+ .filter((f) => { try { return /\bsidecars\b/.test(o.readFile(path.join(root, f))); } catch { return false; } });
1200
+ ok = true;
1201
+ for (const config of sidecarConfigs) if (!rightRelease(stage, ["stage-sidecar", "--config", config])) { ok = false; break; }
1202
+ if (ok) ok = (await (o.runCompile ?? (() => main(["--all"], { ...io, cwd: root, readStdin: () => "" })))()) === 0;
1203
+ }
1204
+ else if (stage === "package") {
1205
+ ok = rightRelease(stage, ["qa", "--stage", "source", "--platform", platform]) && rightRelease(stage, ["build", "--platform", platform]);
1206
+ const sealed = ok ? (o.sealedFor ?? sealedFor)({ root, commit, platform }) : null;
1207
+ if (ok && !sealed) { o.err(`${tag} [package] the build reported success but sealed nothing for ${commit.slice(0, 8)} on ${platform}`); ok = false; }
1208
+ if (sealed) extra = { platform, sealed: sealed.id, manifestSha256: sealed.digest };
1209
+ } else {
1210
+ const sealed = receipt.stages.package?.ok ? receipt.stages.package : null;
1211
+ const current = sealed ? (o.sealedFor ?? sealedFor)({ root, commit, platform }) : null;
1212
+ if (!sealed || !current || current.id !== sealed.sealed || current.digest !== sealed.manifestSha256) {
1213
+ o.err(`${tag} BLOCKED: [${stage}] needs the sealed set the package stage produced for this commit, unchanged (sealed manifest digest)`);
1214
+ ok = false;
1215
+ } else if (stage === "qualify") {
1216
+ ok = rightRelease(stage, ["qa", "--stage", "installed", "--platform", platform, "--release", sealed.sealed]);
1217
+ extra = { sealed: sealed.sealed, manifestSha256: sealed.manifestSha256 };
1218
+ } else {
1219
+ if (!receipt.stages.qualify?.ok || receipt.stages.qualify.manifestSha256 !== sealed.manifestSha256) { o.err(`${tag} BLOCKED: [release] the sealed set was not qualified (installed QA) in this door run's receipt`); ok = false; }
1220
+ else { ok = rightRelease(stage, ["upload", "--release", sealed.sealed, "--platform", platform, "--tier", tier]); extra = { sealed: sealed.sealed, manifestSha256: sealed.manifestSha256, tier }; }
1221
+ }
1222
+ }
1223
+ receipt.stages[stage] = { ok, at, ...extra };
1224
+ save();
1225
+ if (!ok) { o.err(`${tag} --to ${to} stopped at ${stage}: nothing after it ran`); return 1; }
1226
+ }
1227
+ o.out(`${tag} --to ${to} passed: ${stages.join(", ")}`);
1228
+ return 0;
1229
+ }
1230
+
1231
+ /**
1232
+ * rightkit-gate stage --platform mac|win [--tier patch|update]: hands a qualified sealed set to the trusted publisher. The host signed,
1233
+ * sealed and qualified it (--to qualify); only the publisher holds the R2 write credential. Requires this host's receipt for HEAD with
1234
+ * package and qualify passed on the sealed bytes as they are now. Packs the sealed directory and the receipt into the draft GitHub
1235
+ * release `rightkit-staging-<commit>` (replacing that platform's asset), then, with --tier, dispatches RightKit's publish-app workflow
1236
+ * (the one trusted publisher for every app) for HEAD.
1237
+ */
1238
+ const PUBLISHER_REPO = "bogusyogi/rightkit";
1239
+ function stageForPublisher(o, args, root, git) {
1240
+ const option = (name) => { const i = args.indexOf(name); return i < 0 ? undefined : args[i + 1]; };
1241
+ const tag = "rightkit-gate:";
1242
+ const platform = option("--platform");
1243
+ const tier = option("--tier");
1244
+ if (!["mac", "win"].includes(platform)) { o.err(`${tag} stage needs --platform mac|win`); return 2; }
1245
+ if (tier !== undefined && !["patch", "update"].includes(tier)) { o.err(`${tag} stage --tier must be patch or update`); return 2; }
1246
+ const commit = text(git("rev-parse", "HEAD")).trim();
1247
+ const common = text(git("rev-parse", "--git-common-dir")).trim();
1248
+ if (!/^[0-9a-f]{40}$/.test(commit) || !common) { o.err(`${tag} cannot read HEAD and the git directory`); return 2; }
1249
+ const commonDir = path.resolve(root, common);
1250
+ const receipt = (o.readReceipt ?? readReceipt)({ commonDir, commit });
1251
+ const pack = receipt?.stages?.package; const qual = receipt?.stages?.qualify;
1252
+ const sealed = pack?.ok ? (o.sealedFor ?? sealedFor)({ root, commit, platform }) : null;
1253
+ if (!pack?.ok || !qual?.ok || pack.platform !== platform || qual.manifestSha256 !== pack.manifestSha256 || !sealed || sealed.id !== pack.sealed || sealed.digest !== pack.manifestSha256) {
1254
+ o.err(`${tag} BLOCKED: stage needs this host's receipt for ${commit.slice(0, 12)} with package and qualify passed on ${platform} and the sealed set unchanged; run rightkit-gate --to qualify --platform ${platform} first`);
1255
+ return 1;
1256
+ }
1257
+ const releaseTag = `rightkit-staging-${commit}`;
1258
+ const work = fs.mkdtempSync(path.join(os.tmpdir(), "rightkit-stage-"));
1259
+ try {
1260
+ const receiptFile = path.join(work, `receipt-${platform}.json`);
1261
+ fs.writeFileSync(receiptFile, `${JSON.stringify(receipt, null, 2)}\n`);
1262
+ const archive = path.join(work, `sealed-${platform}.tar.gz`);
1263
+ const tar = o.spawn("tar", ["-czf", archive, "-C", root, path.join(".right-release", "sealed", sealed.id, platform)]);
1264
+ if (failed(tar)) { o.err(`${tag} could not pack ${sealed.id}/${platform}`); return 1; }
1265
+ const gh = (...a) => o.spawn("gh", a, { cwd: root });
1266
+ if (failed(gh("release", "view", releaseTag))) {
1267
+ const made = gh("release", "create", releaseTag, "--draft", "--target", commit, "--title", `staging ${commit.slice(0, 12)}`, "--notes", "Sealed and qualified sets for the trusted publisher; not a release.");
1268
+ if (failed(made)) { o.err(`${tag} could not create the draft ${releaseTag}: ${text(made).trim()}`); return 1; }
1269
+ }
1270
+ const up = gh("release", "upload", releaseTag, archive, receiptFile, "--clobber");
1271
+ if (failed(up)) { o.err(`${tag} could not upload to ${releaseTag}: ${text(up).trim()}`); return 1; }
1272
+ o.out(`${tag} staged ${sealed.id}/${platform} (manifest ${sealed.digest.slice(0, 12)}) in draft ${releaseTag}`);
1273
+ if (tier) {
1274
+ // The trusted publisher is RightKit's publish-app workflow: app repositories are private and run no Actions.
1275
+ const slug = text(gh("repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner")).trim();
1276
+ const appDir = path.relative(root, o.cwd).replaceAll("\\", "/") || ".";
1277
+ const run = gh("workflow", "run", "publish-app.yml", "-R", PUBLISHER_REPO, "-f", `app=${slug}`, "-f", `app_dir=${appDir}`, "-f", `source_revision=${commit}`, "-f", `tier=${tier}`, "-f", `platforms=${platform}`);
1278
+ if (!slug || failed(run)) { o.err(`${tag} staged, but could not dispatch ${PUBLISHER_REPO} publish-app.yml: ${text(run).trim()}`); return 1; }
1279
+ o.out(`${tag} dispatched ${PUBLISHER_REPO} publish-app.yml for ${slug}@${commit.slice(0, 12)} (${platform}, ${tier}); the publisher re-runs class A, verifies the receipt and the bytes, then uploads`);
1280
+ }
1281
+ return 0;
1282
+ } finally { fs.rmSync(work, { recursive: true, force: true }); }
1283
+ }
1284
+
1285
+ /**
1286
+ * rightkit-gate --to dev|test [-- <right-release dev|test arguments>]: the door for the local compiles that work on the uncommitted tree.
1287
+ * Class A runs over the working tree first (nothing compiles while it is red), then `right-release dev|test` runs under a live door
1288
+ * 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
1289
+ * checks again), plain cargo on an unmanaged host, never around a managed host's broker.
1290
+ */
1291
+ async function runWorktreeCompile(o, args, root, git, io, to) {
1292
+ const tag = "rightkit-gate:";
1293
+ const passthrough = args.includes("--") ? args.slice(args.indexOf("--") + 1) : [];
1294
+ const commit = text(git("rev-parse", "HEAD")).trim();
1295
+ const tree = text(git("rev-parse", "HEAD^{tree}")).trim();
1296
+ const common = text(git("rev-parse", "--git-common-dir")).trim();
1297
+ if (!/^[0-9a-f]{40}$/.test(commit) || !common) { o.err(`${tag} cannot read HEAD and the git directory`); return 2; }
1298
+ 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); };
1299
+ const executor = (o.resolveExecutor ?? resolveExecutor)({ env: o.env, platform: o.platform, exists: o.exists, which, readFile: o.readFile });
1300
+ if (executor.kind === "refuse") { o.err(`${tag} BLOCKED: ${executor.problem}`); return 1; }
1301
+ o.out(`${tag} --to ${to} on the working tree of ${commit.slice(0, 12)}: check (class A) -> right-release ${to}; executor ${executor.kind}`);
1302
+ const checked = await (o.runCheck ?? (() => main(["check", "--worktree"], { ...io, cwd: root })))();
1303
+ if (checked !== 0) { o.err(`${tag} --to ${to} stopped at check: nothing compiled`); return 1; }
1304
+ const commonDir = path.resolve(root, common);
1305
+ const run = startRun({ commonDir, commit, tree, stage: to });
1306
+ try {
1307
+ const cli = fileURLToPath(new URL("../cli/right-release.mjs", import.meta.url));
1308
+ const r = (o.spawnInherit ?? ((cmd, a, opts) => spawnInherit(cmd, a, { ...opts, stdio: "inherit" })))(process.execPath, [cli, to, ...passthrough], { cwd: o.cwd, env: { ...o.env, ...run.env } });
1309
+ return r.status === 0 ? 0 : 1;
1310
+ } finally { endRun(run); }
1311
+ }
1312
+
884
1313
  // ---- main ---------------------------------------------------------------------------------------------------
885
1314
 
886
1315
  /**
887
1316
  * Run the CLI. Returns the exit code (0 pass, 1 blocked or failed, 2 cannot run, 128+n cancelled). `io` overrides the
888
1317
  * process, fs and output bindings so tests execute nothing real: spawn, exists, readFile, writeFile, mkdir, chmod,
889
- * readStdin, out, err, cwd, env, platform, abort (an AbortController).
1318
+ * readStdin, out, err, cwd, env, platform, abort (an AbortController), runScans and findRustfmt (the scan runner and the
1319
+ * lookup of a usable rustfmt, which the deferred Rust gate needs).
890
1320
  */
891
1321
  export async function main(argv, io = {}) {
892
1322
  const o = {
893
1323
  spawn: defaultSpawn, exists: fs.existsSync, readFile: (f) => fs.readFileSync(f, "utf8"), writeFile: fs.writeFileSync, mkdir: fs.mkdirSync, chmod: fs.chmodSync, readdir: fs.readdirSync,
894
1324
  readStdin: () => { try { return process.stdin.isTTY ? "" : fs.readFileSync(0, "utf8"); } catch { return ""; } },
895
- out: (m) => console.log(m), err: (m) => console.error(m), cwd: process.cwd(), env: process.env, platform: process.platform, ...io,
1325
+ out: (m) => console.log(m), err: (m) => console.error(m), cwd: process.cwd(), env: process.env, platform: process.platform,
1326
+ // Where this gate runs from (install-hook writes a path to it only when it is a plain path inside the repository: hookGatePlan).
1327
+ gateFile: fileURLToPath(import.meta.url), ...io,
896
1328
  };
897
1329
  const args = [...argv];
898
1330
  const option = (name) => { const i = args.indexOf(name); return i < 0 ? undefined : args[i + 1]; };
@@ -902,9 +1334,82 @@ export async function main(argv, io = {}) {
902
1334
  }
903
1335
  if (args.includes("--help") || args.includes("-h")) { o.out(USAGE); return 0; }
904
1336
  const sub = args[0] && !args[0].startsWith("-") ? args[0] : null;
905
- if (sub && sub !== "install-hook" && sub !== "check") { o.err(`rightkit-gate: unknown command ${JSON.stringify(sub)}\n${USAGE}`); return 2; }
906
- // The app check has its own export and reads no gate state: it needs neither the base ref nor the pushed refs on stdin.
907
- if (sub === "check") return (o.appCheck ?? (await import("../app-check.mjs")).main)(args.slice(1), { out: o.out, err: o.err, label: "check", cwd: o.cwd });
1337
+ if (sub && sub !== "install-hook" && sub !== "check" && sub !== "stage") { o.err(`rightkit-gate: unknown command ${JSON.stringify(sub)}\n${USAGE}`); return 2; }
1338
+ // `check` is the deterministic stage (Adrian, 2026-10-25: deterministic always first, one door): the app-check rules, then every
1339
+ // class A scan (lockfiles, cargo-deny, cargo-audit, secrets, licences, manifests, workflow lint, syntax...) over the whole tree of the
1340
+ // commit, never a diff. Nothing compiles. The RustRight broker runs exactly this before it admits any compile. `--list`,
1341
+ // `--print-baseline` and `--only` stay app-check-only views.
1342
+ if (sub === "check") {
1343
+ const appCheck = await (o.appCheck ?? (await import("../app-check.mjs")).main)(args.slice(1), { out: o.out, err: o.err, label: "check", cwd: o.cwd });
1344
+ if (args.includes("--list") || args.includes("--print-baseline") || args.includes("--only") || o.classA === false) {
1345
+ // 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.
1346
+ // A green partial view exits PARTIAL_EXIT (3), never 0, so a caller that admits only exit 0 rejects it by status alone.
1347
+ if (args.includes("--list") || args.includes("--print-baseline")) return appCheck;
1348
+ o.err("rightkit-gate: PARTIAL: app rules only (--only); no class A scan ran, so this is not a class A result");
1349
+ return appCheck === 0 ? PARTIAL_EXIT : appCheck;
1350
+ }
1351
+ const repoArg = option("--repo");
1352
+ const checkCwd = repoArg ? path.resolve(o.cwd, repoArg) : o.cwd;
1353
+ const classA = await main(["--class-a", ...(args.includes("--worktree") ? ["--worktree"] : ["--commit", option("--commit") ?? "HEAD"])], { ...io, cwd: checkCwd });
1354
+ // RightKit checking itself: its own rules (line endings, consumer recipes, npm surface, version copies and the version feed, dispatcher
1355
+ // pins, consumer locks, generated docs, temp paths: scripts/preflight.mjs) and its exports' import safety are class A for the RightKit repository. They run only when this gate is that
1356
+ // repository's own in-tree copy, so a gate checking any other repository (the broker's pinned one included) never runs
1357
+ // that repository's code.
1358
+ let selfRules = 0;
1359
+ const top = o.spawn("git", ["rev-parse", "--show-toplevel"], { cwd: checkCwd });
1360
+ const root = failed(top) ? null : path.resolve(text(top).trim());
1361
+ const self = o.selfPath ?? fileURLToPath(import.meta.url);
1362
+ // RightKit's own scripts are the checked repository's code: they run only after the app rules and every class A scan passed,
1363
+ // never after a failure, so a red tree executes nothing of its own.
1364
+ // Compared as real, long paths (and case-blind on Windows): git prints the long form of an 8.3 short path such as RUNNER~1.
1365
+ const canon = (p) => { let c; try { c = fs.realpathSync.native(p); } catch { c = path.resolve(p); } return process.platform === "win32" ? c.toLowerCase() : c; };
1366
+ if (root && canon(self).startsWith(canon(root) + path.sep) && o.exists(path.join(root, "scripts", "preflight.mjs")) && appCheck === 0 && classA === 0) {
1367
+ const launch = o.spawnInherit ?? ((cmd, a, opts) => spawnInherit(cmd, a, { ...opts, stdio: "inherit" }));
1368
+ const selector = args.includes("--worktree") ? ["--worktree"] : ["--commit", option("--commit") ?? "HEAD"];
1369
+ const r = launch(process.execPath, [path.join(root, "scripts", "preflight.mjs"), "--repo", root, ...selector], { cwd: root, env: o.env });
1370
+ selfRules = r.status === 0 ? 0 : 1;
1371
+ // And its exports import cleanly (scripts/ci/exports-import-effects.mjs: every exported module imported in a fresh, permission-
1372
+ // limited process; an import that prints, exits or reads its argv fails), which publish.yml's scans job ran alone before. That
1373
+ // check probes the checkout's HEAD, so it runs only when HEAD is the requested commit (or the working tree was asked for);
1374
+ // any other commit is refused rather than proven by other bytes.
1375
+ const effects = path.join(root, "scripts", "ci", "exports-import-effects.mjs");
1376
+ if (selfRules === 0 && o.exists(effects)) {
1377
+ const requested = args.includes("--worktree") ? null : option("--commit") ?? "HEAD";
1378
+ const at = (rev) => { const v = o.spawn("git", ["rev-parse", "--verify", "--quiet", `${rev}^{commit}`], { cwd: root }); return failed(v) ? null : text(v).trim(); };
1379
+ // The probe exports HEAD, so a working tree whose tracked or untracked (unignored) files differ from HEAD is refused too.
1380
+ const dirty = () => { const st = o.spawn("git", ["status", "--porcelain", "--untracked-files=all"], { cwd: root }); return failed(st) || text(st).trim() !== ""; };
1381
+ if (requested !== null && at(requested) !== at("HEAD")) {
1382
+ o.err(`rightkit-gate: BLOCKED: the exports import check probes the checkout's HEAD (${String(at("HEAD")).slice(0, 12)}), not the requested ${requested}; check out that commit to check it`);
1383
+ selfRules = 1;
1384
+ } else if (requested === null && dirty()) {
1385
+ o.err("rightkit-gate: BLOCKED: the exports import check probes the checkout's HEAD, and the working tree differs from it; commit or stash the changes, or check --commit HEAD");
1386
+ selfRules = 1;
1387
+ } else {
1388
+ // Like CI's scans job: exports that resolve to build output are INCOMPLETE here and handed, through --report, to the
1389
+ // sealed-byte check that publication runs; without --report the probe exits 3 and a green tree could never pass.
1390
+ const common = o.spawn("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], { cwd: root });
1391
+ const report = path.join(failed(common) ? path.join(root, ".git") : text(common).trim(), "rightkit-gate", "import-effects", "report.json");
1392
+ const e = launch(process.execPath, [effects, "--report", report], { cwd: root, env: o.env });
1393
+ if (e.status !== 0) selfRules = 1;
1394
+ }
1395
+ }
1396
+ }
1397
+ // A RustRight broker's check names the tree it captured for the compile: the declared inputs there (ignored files the
1398
+ // commit does not hold) are judged too, as the bytes the compile will read.
1399
+ let inputs = 0;
1400
+ const captured = o.env.RIGHTKIT_COMPILE_INPUTS;
1401
+ if (captured) {
1402
+ const { judgeCompileInputs } = await import("../compile-inputs.mjs");
1403
+ const judged = judgeCompileInputs(path.resolve(captured));
1404
+ for (const i of judged.inputs) o.out(`rightkit-gate: compile input ${i.path}: ${i.files} file(s), sha256 ${i.sha256}`);
1405
+ for (const f of judged.findings) o.err(`rightkit-gate: compile input FAIL ${f}`);
1406
+ if (!judged.inputs.length && !judged.findings.length) o.out("rightkit-gate: no declared compile inputs in the captured tree");
1407
+ inputs = judged.findings.length ? 1 : 0;
1408
+ }
1409
+ return Math.max(appCheck, classA, selfRules, inputs);
1410
+ }
1411
+ const classAOnly = args.includes("--class-a");
1412
+ const classACommit = classAOnly && !args.includes("--worktree") ? (option("--commit") ?? "HEAD") : null;
908
1413
  if (args.includes("--base") && (!option("--base") || option("--base").startsWith("--"))) { o.err("rightkit-gate: --base needs a ref"); return 2; }
909
1414
 
910
1415
  const top = o.spawn("git", ["rev-parse", "--show-toplevel"], { cwd: o.cwd });
@@ -912,16 +1417,26 @@ export async function main(argv, io = {}) {
912
1417
  const root = path.resolve(text(top).trim());
913
1418
  const git = (...a) => o.spawn("git", a, { cwd: root });
914
1419
  if (sub === "install-hook") return await installHook(o, root, args.includes("--force"));
1420
+ if (option("--to") !== undefined) return await runStages(o, args, root, git, io);
1421
+ if (sub === "stage") return stageForPublisher(o, args, root, git);
915
1422
 
916
1423
  const planOnly = args.includes("--plan");
917
1424
  let base = option("--base") ?? "origin/main";
918
1425
  const tag = "rightkit-gate:";
919
1426
 
920
1427
  // git pre-push: "<local ref> <local sha> <remote ref> <remote sha>" per pushed ref. A plan reads no stdin.
921
- if (!planOnly) {
1428
+ // git's push lines are split on single ASCII spaces only (parsePushLines): a ref name may hold Unicode whitespace. A line that does not read as four fields
1429
+ // (git hands the local field over verbatim, so a rev expression such as `HEAD@{1 minute ago}` or `HEAD^{/one thing}` holds spaces)
1430
+ // refuses the push on every host and in every repository, never a line that disappears: its pushed commit could not be compared with
1431
+ // HEAD and its remote ref could not be judged. `pushedRemoteShas` are the remote commits being replaced.
1432
+ if (!planOnly && !classAOnly) {
922
1433
  const head = text(git("rev-parse", "HEAD")).trim();
923
- const lines = o.readStdin().split(/\r?\n/).map((l) => l.trim().split(/\s+/)).filter((f) => f.length === 4);
924
- for (const [, localSha, remoteRef, remoteSha] of lines) {
1434
+ const parsed = parsePushLines(o.readStdin());
1435
+ if (parsed.malformed.length) {
1436
+ o.err(`${tag} BLOCKED: pre-push stdin line ${parsed.malformed[0].number} is not "<local ref> <local sha> <remote ref> <remote sha>" (four fields separated by single spaces), so the pushed refs cannot all be read and the pushed commit cannot be compared with HEAD; push a plain ref (git push <remote> HEAD:<branch>)`);
1437
+ return 1;
1438
+ }
1439
+ for (const { localSha, remoteRef, remoteSha } of parsed.lines) {
925
1440
  if (ZERO.test(localSha)) continue; // a delete pushes no content
926
1441
  if (localSha !== head) {
927
1442
  o.err(`${tag} BLOCKED: pushing ${localSha.slice(0, 10)} to ${remoteRef}, but HEAD is ${head.slice(0, 10)}; broker scans read the checkout, so check the pushed commit out first (git checkout --detach ${localSha.slice(0, 10)}), push HEAD:<branch>, then return`);
@@ -931,51 +1446,16 @@ export async function main(argv, io = {}) {
931
1446
  }
932
1447
  }
933
1448
 
934
- const tracked = (name) => {
935
- const r = git("ls-files", "-z", "--", `:(glob)**/${name}`);
936
- if (failed(r)) throw new Error(`git ls-files failed: ${why(r)}`);
937
- return text(r).split("\0").filter(Boolean).map(posix);
938
- };
939
- const readLive = (f) => o.readFile(f);
940
-
941
- // Units come from the live checkout (cargo reads it); a dirty checkout then blocks below.
942
- let rust; let npm; let cargoCommand = ["rustright", "cargo"];
943
- try {
944
- const manifests = tracked("Cargo.toml");
945
- if (manifests.length) {
946
- cargoCommand = resolveCargoCommand(o);
947
- if (!cargoCommand) { o.err(`${tag} BLOCKED: neither \`rustright\` nor the managed cargo shim (cargo-guard.mjs) is first on PATH; plain cargo is never used`); return 1; }
948
- if (!o.exists(path.join(root, GRANT_FILE))) { o.err(`${tag} BLOCKED: no local compile grant (${GRANT_FILE}); the broker refuses cargo, so the Rust units cannot be discovered or scanned`); return 1; }
949
- }
950
- // Untracked (not ignored) manifests only: git does not descend into ignored directories, so this stays cheap. A member of a
951
- // workspace that is untracked or ignored is dropped by discoverRustWorkspaces itself.
952
- const untracked = () => {
953
- const r = git("ls-files", "-z", "--others", "--exclude-standard", "--", ":(glob)**/Cargo.toml");
954
- return failed(r) ? [] : text(r).split("\0").filter(Boolean).map(posix);
955
- };
956
- // ci/gate.config.json `fixtures` (optional): directories whose manifests are consumer or sample crates, gated through the
957
- // consumer rows of the crates they exercise (CI treats them the same: scripts/ci/gate.mjs consumerRowInvocations), never as units.
958
- let fixtures = [];
959
- try { const f = JSON.parse(o.readFile(path.join(root, GATE_CONFIG))).fixtures; if (Array.isArray(f)) fixtures = f.filter((d) => typeof d === "string"); } catch { /* no config: no fixtures */ }
960
- rust = discoverRustWorkspaces({ root, spawn: (c, a, opts) => o.spawn(c, a, opts), cargoCommand, tracked, untracked, fixtures, readFile: readLive });
961
- npm = discoverNpmUnits({ root, tracked, readFile: readLive, exists: o.exists });
962
- } catch (e) { o.err(`${tag} BLOCKED: cannot discover the repository's units: ${e.message}`); return 1; }
963
- for (const k of rust.skipped) o.out(`${tag} note: ${k.manifest} is skipped, not scanned: ${k.why}`);
964
-
965
- // Broker scans (cargo, pnpm) read the live checkout, so it must equal HEAD where they read it: an uncommitted fix could
966
- // otherwise hide a committed failure. Any change or untracked file under those paths blocks the gate.
967
- if (!planOnly) {
968
- 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",
969
- "deny.toml", "clippy.toml", "rustfmt.toml", "package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml", ".npmrc", "eslint.config.js", "eslint.config.mjs", ".eslintrc.json", "biome.json"])];
970
- const dirty = git("status", "--porcelain", "--untracked-files=all", "--", ...inputs);
971
- const dirtyFiles = failed(dirty) ? [`git status failed: ${why(dirty)}`]
972
- : text(dirty).split(/\r?\n/).filter(Boolean).filter((l) => !l.includes("node_modules") && !l.includes(GRANT_FILE));
973
- if (dirtyFiles.length) {
974
- o.err(`${tag} BLOCKED: the checkout differs from HEAD where broker scans read it; commit or stash first:`);
975
- for (const l of dirtyFiles.slice(0, 20)) o.err(` ${l}`);
976
- return 1;
977
- }
978
- }
1449
+ // ---- one door: the executor, then the exact snapshot every scan plans from and reads -----------------------------------
1450
+ // The executor is chosen here, never by the caller (Adrian, 2026-10-25): the RustRight broker where this machine has it, a refusal
1451
+ // on a broker-managed machine whose broker cannot be reached, direct cargo on any other host (a cloud container, a CI runner).
1452
+ 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); };
1453
+ const executor = (o.resolveExecutor ?? resolveExecutor)({ env: o.env, platform: o.platform, exists: o.exists, which, readFile: o.readFile });
1454
+ if (executor.kind === "refuse") { o.err(`${tag} BLOCKED: ${executor.problem}`); return 1; }
1455
+ const cargoCommand = executor.cargo;
1456
+ // The snapshot: the commit or tree being checked (check --commit), HEAD for the gate, or the working tree (check --worktree, --plan).
1457
+ const allFilesMode = classAOnly || args.includes("--all");
1458
+ const snapshot = planOnly ? null : classAOnly ? classACommit : "HEAD";
979
1459
 
980
1460
  const ac = o.abort ?? new AbortController();
981
1461
  let exportRoot = null;
@@ -983,16 +1463,66 @@ export async function main(argv, io = {}) {
983
1463
  const signals = [];
984
1464
  try {
985
1465
  let repo = root;
986
- if (!planOnly) {
1466
+ if (snapshot) {
987
1467
  exportRoot = fs.mkdtempSync(path.join(o.env.RK_GATE_TMPDIR || os.tmpdir(), "rk-gate-"));
988
1468
  repo = path.join(exportRoot, "repo");
989
- const made = o.spawn("git", ["archive", "--format=tar", "--prefix=repo/", "-o", path.join(exportRoot, "head.tar"), "HEAD"], { cwd: root });
990
- const x = failed(made) ? made : o.spawn("tar", ["-xf", "head.tar"], { cwd: exportRoot });
991
- if (failed(made) || failed(x)) { o.err(`${tag} cannot export HEAD: ${why(made) || why(x)}`); return 2; }
1469
+ // Every file of the commit or tree: read-tree into a private index, then checkout-index. `git archive` would leave out files
1470
+ // marked export-ignore in .gitattributes, so they would never be scanned; this export has no such exclusion.
1471
+ const indexEnv = { ...o.env, GIT_INDEX_FILE: path.join(exportRoot, "index") };
1472
+ const made = o.spawn("git", ["read-tree", snapshot], { cwd: root, env: indexEnv });
1473
+ // git builds each path as <prefix><index path> with "/" separators and creates only the directories below the prefix, so the
1474
+ // export root must exist and the prefix use "/": a "C:\\...\\repo\\" prefix made checkout-index fail on Windows
1475
+ // (publish run 40282215043: "cannot create directory at ...\\repo\\.github").
1476
+ fs.mkdirSync(repo, { recursive: true });
1477
+ const x = failed(made) ? made : o.spawn("git", ["checkout-index", "--all", "--force", `--prefix=${repo.split(path.sep).join("/")}/`], { cwd: root, env: indexEnv });
1478
+ if (failed(made) || failed(x)) { o.err(`${tag} cannot export ${snapshot}: ${why(made) || why(x)}`); return 2; }
992
1479
  }
993
- const diff = git("diff", "--name-only", "--diff-filter=ACMR", `${base}...HEAD`);
994
- if (failed(diff)) { o.err(`${tag} cannot diff against ${base}: ${why(diff)}; pass --base <ref>`); return 2; }
995
- const changed = [...new Set(text(diff).split(/\r?\n/).map((f) => f.trim()).filter(Boolean))].filter((f) => o.exists(path.join(repo, f)));
1480
+ // Every file of the snapshot, once: units, manifests, lockfiles and the changed set are all read from what is scanned, never from
1481
+ // the live checkout (a live edit cannot change what gets planned).
1482
+ const listed = snapshot ? git("ls-tree", "-r", "--name-only", "-z", snapshot) : git("ls-files", "-z", "--cached", ...(classAOnly ? ["--others", "--exclude-standard"] : []));
1483
+ if (failed(listed)) { o.err(`${tag} cannot list the files of ${snapshot ?? "the working tree"}: ${why(listed)}`); return 2; }
1484
+ const files = text(listed).split("\0").filter(Boolean).map(posix);
1485
+ const tracked = (name) => files.filter((f) => f === name || f.endsWith(`/${name}`));
1486
+ const readSnap = (f) => o.readFile(f);
1487
+
1488
+ let rust; let npm; let npmRoots = []; let manifests = [];
1489
+ try {
1490
+ manifests = tracked("Cargo.toml");
1491
+ // ci/gate.config.json `fixtures` (optional): directories whose manifests are consumer or sample crates, gated through the
1492
+ // consumer rows of the crates they exercise (CI treats them the same), never as units.
1493
+ let fixtures = [];
1494
+ try { const f = JSON.parse(o.readFile(path.join(repo, GATE_CONFIG))).fixtures; if (Array.isArray(f)) fixtures = f.filter((d) => typeof d === "string"); } catch { /* no config: no fixtures */ }
1495
+ // A snapshot has nothing untracked; the working tree (--plan, check --worktree) names its untracked manifests as skipped.
1496
+ const untracked = () => {
1497
+ if (snapshot) return [];
1498
+ const r = git("ls-files", "-z", "--others", "--exclude-standard", "--", ":(glob)**/Cargo.toml");
1499
+ return failed(r) ? [] : text(r).split("\0").filter(Boolean).map(posix);
1500
+ };
1501
+ rust = discoverRustWorkspaces({ root: repo, spawn: (c, a, opts) => o.spawn(c, a, opts), cargoCommand, tracked, untracked, fixtures, readFile: readSnap });
1502
+ npm = discoverNpmUnits({ root: repo, tracked, readFile: readSnap, exists: o.exists });
1503
+ npmRoots = discoverPnpmRoots({ root: repo, tracked, exists: o.exists });
1504
+ } catch (e) { o.err(`${tag} BLOCKED: cannot discover the repository's units: ${e.message}`); return 1; }
1505
+ for (const k of rust.skipped) o.out(`${tag} note: ${k.manifest} is skipped, not scanned: ${k.why}`);
1506
+
1507
+ // Class B scans (compile, lint, tests) run in the live checkout, so it must equal HEAD where they read it: an uncommitted fix could
1508
+ // otherwise hide a committed failure. Class A reads only the snapshot.
1509
+ if (!planOnly && !classAOnly) {
1510
+ const npmRootFiles = ["package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml", ".npmrc", ...ESLINT_CONFIG_FILES, ...BIOME_CONFIG_FILES];
1511
+ 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",
1512
+ "deny.toml", "clippy.toml", "rustfmt.toml", ...npmRootFiles, ...npmRoots.filter((r) => r !== ".").flatMap((r) => npmRootFiles.map((f) => `${r}/${f}`))])];
1513
+ const dirty = git("status", "--porcelain", "--untracked-files=all", "--", ...inputs);
1514
+ const dirtyFiles = failed(dirty) ? [`git status failed: ${why(dirty)}`] : text(dirty).split(/\r?\n/).filter(Boolean).filter((l) => !l.includes("node_modules"));
1515
+ if (dirtyFiles.length) {
1516
+ o.err(`${tag} BLOCKED: the checkout differs from HEAD where compile scans read it; commit or stash first:`);
1517
+ for (const l of dirtyFiles.slice(0, 20)) o.err(` ${l}`);
1518
+ return 1;
1519
+ }
1520
+ }
1521
+
1522
+ const diff = allFilesMode ? null : git("diff", "--name-only", "--diff-filter=ACMR", `${base}...HEAD`);
1523
+ if (diff && failed(diff)) { o.err(`${tag} cannot diff against ${base}: ${why(diff)}; pass --base <ref> or --all`); return 2; }
1524
+ const changed = allFilesMode ? files : [...new Set(text(diff).split(/\r?\n/).map((f) => f.trim()).filter(Boolean))].filter((f) => o.exists(path.join(repo, f)));
1525
+ if (allFilesMode) base = snapshot ?? "the working tree";
996
1526
 
997
1527
  let crateFeatures = {}; let packageFeatures = {};
998
1528
  try { const gateConfig = JSON.parse(o.readFile(path.join(repo, "ci/gate.config.json"))); crateFeatures = gateConfig.rust ?? {}; packageFeatures = gateConfig.npm ?? {}; } catch { /* no config: default feature sets */ }
@@ -1016,15 +1546,18 @@ export async function main(argv, io = {}) {
1016
1546
  const rustScope = restricted ? scopeNames : null;
1017
1547
 
1018
1548
  const fullPlan = planScans({
1019
- changed, contentRoot: repo, liveRoot: root, includeTests: true, rustScope, cargoCommand,
1549
+ // Class A of a commit or tree reads only that snapshot: every scan, cargo's included, runs in the export, never in the live
1550
+ // checkout (which may hold other bytes). Compiles (class B) use the live checkout, whose tree the dirty check pinned to HEAD.
1551
+ changed, contentRoot: repo, liveRoot: classAOnly && snapshot ? repo : root, includeTests: true, rustScope, cargoCommand,
1020
1552
  // The quiet tests (port, pipe, desktop and wall-clock bound) are proven by the CI shards, one crate per runner, unless
1021
1553
  // RK_GATE_QUIET_TESTS=local asks for the serial quiet lane here (no CI, or a CI outage).
1022
1554
  quietTests: o.env.RK_GATE_QUIET_TESTS === "local" ? "local" : "ci", packageFeatures,
1023
- rustUnits: rust.units, npmUnits: npm, cargoDir: ".", rustLocks, crateFeatures,
1024
- localCompile: o.exists(path.join(root, GRANT_FILE)),
1555
+ rustUnits: rust.units, npmUnits: npm, npmRoots, cargoDir: ".", rustLocks, crateFeatures,
1556
+ // The broker (or, on a direct host, the gate itself) admits compiles; no separate grant file decides it here.
1557
+ localCompile: true,
1025
1558
  exists: o.exists, readFile: o.readFile, platform: o.platform,
1026
1559
  });
1027
- const plan = args.includes("--pre-push") ? prePushPlan(fullPlan) : fullPlan;
1560
+ let plan = args.includes("--pre-push") ? prePushPlan(fullPlan) : fullPlan;
1028
1561
  const jobs = Math.max(1, Number(o.env.RK_GATE_JOBS ?? 4) || 4);
1029
1562
  const testJobs = Math.max(1, Number(o.env.RK_GATE_TEST_JOBS ?? jobs) || jobs);
1030
1563
 
@@ -1039,6 +1572,7 @@ export async function main(argv, io = {}) {
1039
1572
  }
1040
1573
 
1041
1574
  for (const n of plan.notes) o.out(`${tag} note: ${n}`);
1575
+ o.out(`${tag} executor: ${executor.kind}${executor.kind === "broker" ? ` (${cargoCommand.join(" ")})` : " (no broker on this host: cargo runs directly, through the same gates)"}`);
1042
1576
  const quietCount = plan.scans.filter((s) => s.quiet === true).length;
1043
1577
  o.out(`${tag} ${plan.scans.length} scan(s) for ${changed.length} changed file(s) vs ${base.slice(0, 12)}; compile scans ${jobs} at a time, tests ${testJobs} at a time${quietCount ? `, ${quietCount} quiet test scan(s) last under the broker's quiet lease` : ""}`);
1044
1578
  let signalCode = 0;
@@ -1053,7 +1587,7 @@ export async function main(argv, io = {}) {
1053
1587
  }
1054
1588
  // The result cache keys on the gated commit's object ids; the pre-push check above made HEAD the pushed commit.
1055
1589
  let cache = null;
1056
- if (!args.includes("--no-cache") && o.env.RK_GATE_CACHE !== "0") {
1590
+ if (!classAOnly && !args.includes("--no-cache") && o.env.RK_GATE_CACHE !== "0") {
1057
1591
  try {
1058
1592
  const headSha = text(git("rev-parse", "HEAD")).trim();
1059
1593
  const treeId = text(git("rev-parse", `${headSha}^{tree}`)).trim();
@@ -1080,8 +1614,8 @@ export async function main(argv, io = {}) {
1080
1614
  }
1081
1615
  } catch { cache = null; }
1082
1616
  }
1083
- const scanResult = await runScans(plan, {
1084
- classes: ["A", "B"], jobs, testJobs, signal: ac.signal, ...(cache ? { cache } : {}),
1617
+ const scanResult = await (o.runScans ?? runScans)(plan, {
1618
+ classes: classAOnly ? ["A"] : ["A", "B"], jobs, testJobs, signal: ac.signal, ...(cache ? { cache } : {}),
1085
1619
  quietLane: quietLeaseLane(o, { tag, reason: `rightkit-gate-quiet-tests@${text(git("rev-parse", "--short", "HEAD")).trim()}` }),
1086
1620
  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)}` : ""}`),
1087
1621
  });
@@ -1099,9 +1633,13 @@ export async function main(argv, io = {}) {
1099
1633
  if (failures.length) {
1100
1634
  o.err(`${tag} BLOCKED (${failures.length})`);
1101
1635
  for (const f of failures) o.err(` - ${f}`);
1102
- o.err("Fix and push again. Never bypass with --no-verify.");
1636
+ o.err(classAOnly ? "Class A is red: nothing compiles until it is green." : "Fix and push again. Never bypass with --no-verify.");
1103
1637
  return 1;
1104
1638
  }
1639
+ if (classAOnly) {
1640
+ o.out(`${tag} class A pass: ${scanResult.scans.length} deterministic scan(s) over ${changed.length} file(s) of ${base}`);
1641
+ return 0;
1642
+ }
1105
1643
  o.out(`${tag} pass (${changed.length} changed file(s) vs ${base}); ${deferrals.length} scan(s) deferred, not passed${scanResult.cacheHits.length ? `; ${scanResult.cacheHits.length} reused from the result cache` : ""}`);
1106
1644
  return 0;
1107
1645
  } finally {