@rightkit/release 0.6.4 → 0.6.6

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 (48) hide show
  1. package/README.md +109 -7
  2. package/admission-files.mjs +279 -0
  3. package/admission-host.mjs +403 -0
  4. package/admission.mjs +146 -0
  5. package/android-play-upload.mjs +4 -4
  6. package/app-check.mjs +1326 -0
  7. package/apple-profile-create.mjs +4 -3
  8. package/apple-profile-renew.mjs +5 -4
  9. package/bin/darwin/rightkit-swap +0 -0
  10. package/bin/rightkit-gate.mjs +495 -69
  11. package/binary-license-notices.mjs +2 -1
  12. package/build-release.mjs +87 -26
  13. package/cache-command.mjs +4 -3
  14. package/cargo-license-notices.mjs +2 -1
  15. package/cargo-manifest-text.mjs +25 -0
  16. package/cargo-profile-contract.mjs +26 -13
  17. package/cli/right-release.mjs +45 -5
  18. package/command-spec.mjs +4 -5
  19. package/deps.mjs +5 -4
  20. package/deterministic-scans.mjs +301 -37
  21. package/development.mjs +5 -5
  22. package/github-release.mjs +5 -3
  23. package/github-upload-gate.mjs +6 -6
  24. package/init-command.mjs +134 -8
  25. package/inventory-check.mjs +12 -1
  26. package/ios-upload.mjs +4 -4
  27. package/mirror-root-artifact.mjs +3 -4
  28. package/npm-license-notices.mjs +2 -1
  29. package/outdated-command.mjs +91 -0
  30. package/ownership-dependency-allowlist.json +2 -0
  31. package/package.json +41 -3
  32. package/pipeline-admission.mjs +12 -0
  33. package/prebuild-gates.mjs +7 -3
  34. package/publish-cargo.mjs +215 -3
  35. package/publish-update.mjs +5 -3
  36. package/release-config.mjs +36 -5
  37. package/release-dependencies-command.mjs +4 -3
  38. package/release-qa-audit.mjs +12 -10
  39. package/release-qa-command.mjs +42 -7
  40. package/release-qa-hardening.mjs +10 -3
  41. package/release-qa-policy.mjs +214 -47
  42. package/release.mjs +117 -62
  43. package/rightkit-registry-resolver.mjs +5 -2
  44. package/rightkit-versions.json +18 -13
  45. package/stage-sidecar-command.mjs +5 -5
  46. package/test-command.mjs +3 -2
  47. package/upload-release.mjs +3 -2
  48. 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
@@ -24,14 +27,30 @@
24
27
  // rightkit-gate [--base <ref>] [--plan] [--no-cache]
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
30
+ // rightkit-gate check [--repo <dir>] [--commit <rev> | --worktree] [--only <rule,...>] [--list] [--print-baseline] [--baseline <file>]
31
+ // the offline app check (../app-check.mjs): committed files only, no network, compile or
32
+ // broker, every finding in one run. Exit 0 pass, 1 findings, 2 could not run
27
33
  // rightkit-gate install-hook [--force] write .githooks/pre-push and set core.hooksPath. core.hooksPath hides .git/hooks, so the
28
- // pre-push hook runs the repository's own .git/hooks/pre-push first and every other hook
29
- // present in .git/hooks gets a .githooks/<name> trampoline that runs it; the hook finds
30
- // the gate binary in the root node_modules/.bin, then any workspace package's, then pnpm exec
34
+ // pre-push hook runs the repository's own .git/hooks/pre-push first, then `rightkit-gate
35
+ // check --commit HEAD`, then the gate; every other hook present in .git/hooks gets a
36
+ // .githooks/<name> trampoline that runs it; the hook finds the gate binary in the root
37
+ // node_modules/.bin, then any workspace package's, then pnpm exec. Where @rightkit/release is
38
+ // installed it also writes the advisory ownership pre-commit step (`right-release hooks install`
39
+ // is an alias of this command)
31
40
  //
32
41
  // From the pre-push hook, git's ref lines arrive on stdin: the commit being pushed must be the checked-out HEAD (broker scans
33
42
  // read the live checkout), and the remote's current commit is the diff base. To push an earlier commit of the local stack,
34
43
  // check it out first (a detached HEAD is fine), push HEAD:<branch>, return.
44
+ // A host with neither `rustright` nor the managed cargo shim (a cloud session) refuses the Rust gate, with one exception: when git's
45
+ // stdin names only non-protected branches (not main, master, trunk, release/*, the .rightgit.json defaultBranch, or a tag) and the
46
+ // process is not in CI (no CI or GITHUB_ACTIONS), the gate defers the Rust gate to host qualification. It reads the Rust units from the
47
+ // tracked manifests (no cargo), runs every scan that needs no broker, prints "rust gate DEFERRED to host qualification" with the Rust
48
+ // units and scans it did not run, exits 0 and writes nothing into the tree. An npm test scan whose package script chain runs `rustright` or
49
+ // `cargo` (planScans marks it `brokerCall`) cannot run there either and is deferred the same way, named with its reason. The pull request then
50
+ // needs a HOST RECEIPT before merge; that receipt stays the merge gate. RIGHTKIT_GATE_NO_DEFER=1 keeps the refusal on such a host. Every pushed ref is judged, whatever its place
51
+ // in the push; git's stdin is split on single ASCII spaces (a ref name may hold Unicode whitespace) and a line that is not four fields defers
52
+ // nothing. The default branch is named by the checkout, the base ref, the remote commits being replaced and origin/HEAD, so a commit cannot
53
+ // unprotect it by editing .rightgit.json. A refusal prints the old message and, on a second line, why nothing was deferred.
35
54
  // ci/gate.config.json `rust` (optional) sets per-crate feature sets, exactly as CI's gate reads it.
36
55
  // Result cache: a passing class B scan is reused only when its scan id, command, tool version, environment and input digest
37
56
  // (the gated commit's git object ids for the crate's workspace closure, the shared build inputs, ci/gate.config.json and every
@@ -47,7 +66,7 @@ import fs from "node:fs";
47
66
  import os from "node:os";
48
67
  import path from "node:path";
49
68
  import { fileURLToPath } from "node:url";
50
- import { WORKSPACE_RUST_FILE, ancestorConfigChanged, ancestorDirsOf, canonicalJson, defaultSpawn, digestScanSet, fileScanCache, metadataArgs, planScans, runScans, scanDeferrals, scanFailures } from "../deterministic-scans.mjs";
69
+ 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";
51
70
  import { changedLockNames, parseLock, reach } from "../lock-graph.mjs";
52
71
 
53
72
  const ZERO = /^0+$/;
@@ -58,7 +77,7 @@ const text = (r) => String(r?.stdout ?? "");
58
77
  const failed = (r) => !r || Boolean(r.error) || r.status !== 0;
59
78
  const why = (r) => String(r?.error?.message ?? r?.stderr ?? "").trim().split(/\r?\n/).slice(-3).join(" | ");
60
79
 
61
- const USAGE = `usage: rightkit-gate [--base <ref>] [--plan] [--no-cache] [--pre-push] | rightkit-gate install-hook [--force] | --version`;
80
+ const USAGE = `usage: rightkit-gate [--base <ref>] [--plan] [--no-cache] [--pre-push] | rightkit-gate install-hook [--force] | rightkit-gate check [--repo <dir>] [--commit <rev> | --worktree] [--only <rule,...>] [--list] [--print-baseline] [--baseline <file>] | --version`;
62
81
 
63
82
  // ---- pure helpers (exported for tests) ----------------------------------------------------------------------
64
83
 
@@ -109,31 +128,77 @@ const unitName = (pkgName, dir) => {
109
128
  return SAFE_NAME.test(n) ? n : dir === "." ? "root" : dir.replace(/\//g, "-");
110
129
  };
111
130
 
131
+ // 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
132
+ // some of them would hide the rest.
133
+ const PNPM_ROOT_MAX_DEPTH = 6;
134
+ const PNPM_ROOT_MAX_COUNT = 64;
135
+
136
+ /** Tracked files with exactly this name (a lister that ignores the name is filtered here). */
137
+ const trackedNamed = (tracked, name) => tracked(name).map(posix).filter((p) => path.posix.basename(p) === name);
138
+
139
+ /**
140
+ * The pnpm roots of the repository, sorted: "." when the repository root holds a pnpm-workspace.yaml or a package.json, and every other
141
+ * tracked directory (outside node_modules, at most 6 levels deep) that holds a pnpm-lock.yaml or a pnpm-workspace.yaml beside a
142
+ * package.json. A repository such as CodeRight (apps/coderight-tauri and apps/coderight-web, each with its own lock, nothing at the root) has
143
+ * 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.
144
+ */
145
+ export function discoverPnpmRoots({ root, tracked, exists }) {
146
+ const manifestDirs = new Set(trackedNamed(tracked, "package.json").map((p) => path.posix.dirname(p)));
147
+ const roots = new Set();
148
+ if (exists(path.join(root, "pnpm-workspace.yaml")) || exists(path.join(root, "package.json"))) roots.add(".");
149
+ for (const file of [...trackedNamed(tracked, "pnpm-lock.yaml"), ...trackedNamed(tracked, "pnpm-workspace.yaml")]) {
150
+ const dir = path.posix.dirname(file);
151
+ const parts = dir.split("/");
152
+ if (dir === "." || parts.includes("node_modules") || parts.length > PNPM_ROOT_MAX_DEPTH || !manifestDirs.has(dir)) continue;
153
+ roots.add(dir);
154
+ }
155
+ 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)`);
156
+ return [...roots].sort();
157
+ }
158
+
112
159
  /**
113
- * npm units: pnpm-workspace.yaml globs over tracked package.json files, else the root package.json. The root package.json is a
114
- * unit as well when the workspace file is absent, has no `packages:` (or an empty one) or declares "." (or "./"); with a
115
- * `packages:` list that does not name the root it is only the workspace's own manifest and is not a unit.
160
+ * npm units, per pnpm root (discoverPnpmRoots). Within a root: pnpm-workspace.yaml globs over the tracked package.json files, else the root's
161
+ * 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
162
+ * "." (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
163
+ * belongs to the deepest root that holds it. The repository root's units are named from their package names (`@scope/x` -> `scope-x`, a
164
+ * 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
165
+ * from their directory (`apps/coderight-web` -> `apps-coderight-web`), so their scan ids carry the root path, and carry `root`.
116
166
  */
117
167
  export function discoverNpmUnits({ root, tracked, readFile, exists }) {
118
- let dirs;
119
- const workspaceFile = path.join(root, "pnpm-workspace.yaml");
120
- if (exists(workspaceFile)) {
121
- const globs = parseWorkspaceGlobs(readFile(workspaceFile));
122
- const inc = globs.filter((g) => !g.startsWith("!")).map(globToRegExp);
123
- const exc = globs.filter((g) => g.startsWith("!")).map((g) => globToRegExp(g.slice(1)));
124
- const positive = globs.filter((g) => !g.startsWith("!"));
125
- const rootDeclared = positive.length === 0 || positive.some((g) => g === "." || g === "./");
126
- 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)));
127
- } else dirs = exists(path.join(root, "package.json")) ? ["."] : [];
128
- dirs = [...new Set(dirs)].sort();
129
- const rows = dirs.map((dir) => {
168
+ const roots = discoverPnpmRoots({ root, tracked, exists });
169
+ const manifestDirs = [...new Set(trackedNamed(tracked, "package.json").map((p) => path.posix.dirname(p)))];
170
+ const within = (rootDir, dir) => (rootDir === "." ? dir : dir === rootDir ? "." : dir.startsWith(`${rootDir}/`) ? dir.slice(rootDir.length + 1) : null);
171
+ const joined = (rootDir, rel) => (rootDir === "." ? rel : rel === "." ? rootDir : `${rootDir}/${rel}`);
172
+ const ownerOf = (dir) => roots.reduce((best, r) => (within(r, dir) !== null && (best === null || r.length > best.length) ? r : best), null);
173
+ const found = new Map();
174
+ for (const rootDir of roots) {
175
+ let dirs;
176
+ const workspaceFile = path.join(root, joined(rootDir, "pnpm-workspace.yaml"));
177
+ if (exists(workspaceFile)) {
178
+ const globs = parseWorkspaceGlobs(readFile(workspaceFile));
179
+ const inc = globs.filter((g) => !g.startsWith("!")).map(globToRegExp);
180
+ const exc = globs.filter((g) => g.startsWith("!")).map((g) => globToRegExp(g.slice(1)));
181
+ const positive = globs.filter((g) => !g.startsWith("!"));
182
+ const rootDeclared = positive.length === 0 || positive.some((g) => g === "." || g === "./");
183
+ 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));
184
+ } else dirs = exists(path.join(root, joined(rootDir, "package.json"))) ? [rootDir] : [];
185
+ for (const dir of dirs) if (ownerOf(dir) === rootDir) found.set(dir, rootDir);
186
+ }
187
+ const rows = [...found.keys()].sort().map((dir) => {
130
188
  let manifest;
131
189
  try { manifest = JSON.parse(readFile(path.join(root, dir, "package.json")).replace(/^/, "")); } catch (e) { throw new Error(`${dir}/package.json: ${e.message}`); }
132
- return { dir, name: unitName(manifest.name, dir) };
190
+ const rootDir = found.get(dir);
191
+ return { dir, root: rootDir, name: rootDir === "." ? unitName(manifest.name, dir) : dir.replace(/\//g, "-") };
133
192
  });
134
193
  const count = new Map();
135
194
  for (const r of rows) count.set(r.name, (count.get(r.name) ?? 0) + 1);
136
- return rows.map((r) => ({ name: count.get(r.name) > 1 ? unitName("", r.dir === "." ? "root" : r.dir) : r.name, dir: r.dir }));
195
+ const named = rows.map((r) => ({ ...r, name: count.get(r.name) > 1 && r.root === "." ? unitName("", r.dir === "." ? "root" : r.dir) : r.name }));
196
+ const taken = new Set();
197
+ for (const r of named) {
198
+ if (taken.has(r.name)) throw new Error(`two npm units are named ${r.name} (${r.dir}); rename one of the packages`);
199
+ taken.add(r.name);
200
+ }
201
+ return named.map((r) => ({ name: r.name, dir: r.dir, ...(r.root === "." ? {} : { root: r.root }) }));
137
202
  }
138
203
 
139
204
  const dirOfManifest = (m) => path.posix.dirname(m);
@@ -199,13 +264,56 @@ export function manifestTables(text) {
199
264
  return tables;
200
265
  }
201
266
 
267
+ /**
268
+ * The value text of `key` in the top-level table `table` of a manifest read by manifestTables (everything from the `=` on), whether it is
269
+ * written under `[table]` or as the dotted root key `table.key = ...`; null when absent. No value parsing: tomlString and tomlStrings read it.
270
+ */
271
+ export function tomlValue(tables, table, key) {
272
+ const esc = (x) => x.replace(/[.*+?^${}()|[\]\\-]/g, "\\$&");
273
+ const own = tables.get(table) ?? "";
274
+ const inTable = new RegExp(`^[ \\t]*${esc(key)}[ \\t]*=[ \\t]*`, "m").exec(own);
275
+ if (inTable) return own.slice(inTable.index + inTable[0].length);
276
+ const root = tables.get("preamble") ?? "";
277
+ const dotted = new RegExp(`^[ \\t]*${esc(table)}\\.${esc(key)}[ \\t]*=[ \\t]*`, "m").exec(root);
278
+ return dotted ? root.slice(dotted.index + dotted[0].length) : null;
279
+ }
280
+
281
+ /** True when the manifest has the top-level table `name`: as `[name]`, as a `[name.sub]` table, or as dotted root keys `name.key = ...`. */
282
+ 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") ?? "");
283
+
284
+ /** The string a TOML value starts with ("basic" or 'literal'); null when the value is not a string. */
285
+ export function tomlString(value) {
286
+ const m = /^(?:"((?:[^"\\\n]|\\.)*)"|'([^'\n]*)')/.exec(String(value ?? ""));
287
+ return m ? (m[1] !== undefined ? m[1].replace(/\\(.)/g, "$1") : m[2]) : null;
288
+ }
289
+
290
+ /**
291
+ * The strings of the TOML array a value starts with: "basic" and 'literal' strings, across lines, with `#` comments (and any `]` in
292
+ * them or in a string) skipped. [] when the value is not an array.
293
+ */
294
+ export function tomlStrings(value) {
295
+ const s = String(value ?? "");
296
+ if (s[0] !== "[") return [];
297
+ const out = [];
298
+ for (let i = 1; i < s.length;) {
299
+ const c = s[i];
300
+ if (c === "]") break;
301
+ if (c === "#") { while (i < s.length && s[i] !== "\n") i += 1; continue; }
302
+ if (c === '"' || c === "'") {
303
+ let j = i + 1; let v = "";
304
+ 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; } }
305
+ out.push(v); i = j + 1; continue;
306
+ }
307
+ i += 1;
308
+ }
309
+ return out;
310
+ }
311
+
202
312
  /** The repository-relative directories a workspace manifest `exclude`s (`[workspace] exclude = [...]`); [] when none or unreadable. */
203
313
  export function workspaceExcludes(readFile, root, cargoDir) {
204
314
  let text;
205
315
  try { text = readFile(path.join(root, cargoDir === "." ? "Cargo.toml" : `${cargoDir}/Cargo.toml`)); } catch { return []; }
206
- const body = manifestTables(text).get("workspace") ?? "";
207
- const list = /^\s*exclude\s*=\s*\[([\s\S]*?)\]/m.exec(body)?.[1] ?? "";
208
- return [...list.matchAll(/"([^"]+)"/g)].map((m) => posix(m[1]).replace(/\/+$/, "")).map((d) => (cargoDir === "." ? d : `${cargoDir}/${d}`));
316
+ return tomlStrings(tomlValue(manifestTables(text), "workspace", "exclude")).map((d) => posix(d).replace(/\/+$/, "")).map((d) => (cargoDir === "." ? d : `${cargoDir}/${d}`));
209
317
  }
210
318
 
211
319
  // The manifest tables whose every effect on a build is also written into Cargo.lock: a changed source ([patch.*]) and workspace
@@ -248,8 +356,10 @@ export function manifestChangeIsLockReflected(before, after) {
248
356
  * A member whose Cargo.toml is not tracked (untracked or ignored) is dropped into `skipped`, as are `untracked()` manifests.
249
357
  * Returns { present, workspaces: [{ cargoDir, units }], units, cargoDir, skipped: [{ manifest, why }] }; `units` is every workspace's
250
358
  * [{ name, crate, dir, publish, cargoDir }] and `cargoDir` the first workspace's (the repository root's when it has one).
359
+ * `metadata(dir)` (optional) replaces the cargo call with a reader of the same { cargoDir, members } shape (extra fields are ignored): the
360
+ * deferred pre-push gate passes manifestWorkspaceMetadata where no broker can run cargo.
251
361
  */
252
- export function discoverRustWorkspaces({ root, spawn, cargoCommand, tracked, untracked = () => [], realpath = fs.realpathSync, fixtures = [], readFile = (f) => fs.readFileSync(f, "utf8") }) {
362
+ export function discoverRustWorkspaces({ root, spawn, cargoCommand, tracked, untracked = () => [], realpath = fs.realpathSync, fixtures = [], readFile = (f) => fs.readFileSync(f, "utf8"), metadata = null }) {
253
363
  const manifests = tracked("Cargo.toml");
254
364
  const trackedSet = new Set(manifests);
255
365
  const locks = new Set(tracked("Cargo.lock"));
@@ -277,7 +387,7 @@ export function discoverRustWorkspaces({ root, spawn, cargoCommand, tracked, unt
277
387
  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; }
278
388
  }
279
389
  let found;
280
- try { found = workspaceMetadata({ root, spawn, cargoCommand, dir, realpath }); } catch (e) {
390
+ try { found = metadata ? metadata(dir) : workspaceMetadata({ root, spawn, cargoCommand, dir, realpath }); } catch (e) {
281
391
  if (!parent) throw e;
282
392
  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 [^)]*\)$/, "")})` });
283
393
  continue;
@@ -300,6 +410,212 @@ export function discoverRustWorkspaces({ root, spawn, cargoCommand, tracked, unt
300
410
  return { present: true, workspaces, units, cargoDir: workspaces[0]?.cargoDir ?? ".", skipped };
301
411
  }
302
412
 
413
+ // ---- the Rust gate on a host without the broker: deferral to host qualification --------------------------------------
414
+
415
+ // A push that only carries non-protected branches, from a host that has neither `rustright` nor the managed cargo shim and is not
416
+ // CI, does not refuse on the missing broker: the Rust scans cannot run here, so the gate runs every other scan, names the Rust
417
+ // units and scans it did not run, and exits 0. The pull request then needs a HOST RECEIPT (a host with the broker ran the full gate)
418
+ // before it merges; that receipt, not this hook, is the merge gate. Everything else keeps today's behaviour: a push that includes a
419
+ // protected branch, a host with the broker or the shim, CI, and RIGHTKIT_GATE_NO_DEFER=1 all refuse as before.
420
+
421
+ /** Branches that never defer (compared case-insensitively); `release/*` and the repository's default branch join them. */
422
+ export const PROTECTED_BRANCHES = Object.freeze(["main", "master", "trunk"]);
423
+ /** Set to anything but ""/0/false to keep the refusal on a broker-less host. */
424
+ export const DEFER_OPT_OUT_ENV = "RIGHTKIT_GATE_NO_DEFER";
425
+
426
+ const envOn = (v) => v !== undefined && v !== null && !["", "0", "false"].includes(String(v).trim().toLowerCase());
427
+
428
+ /**
429
+ * True when `ref` (a pushed remote ref) is a plain branch the gate may defer for. A tag, a ref outside refs/heads/, main, master,
430
+ * trunk, release/* and the `extra` names (the repository's own default branch, `.rightgit.json` defaultBranch) are not.
431
+ */
432
+ export function isDeferrableRef(ref, extra = []) {
433
+ const m = /^refs\/heads\/(.+)$/.exec(String(ref ?? ""));
434
+ if (!m) return false;
435
+ const name = m[1].toLowerCase();
436
+ return !(PROTECTED_BRANCHES.includes(name) || extra.some((x) => String(x).toLowerCase() === name) || name.startsWith("release/"));
437
+ }
438
+
439
+ /**
440
+ * Whether the Rust gate defers to host qualification. `refs` are the remote refs of a git pre-push's stdin (deletions included); an
441
+ * empty list is not a pre-push run. `unparsed` are the stdin lines parsePushLines could not read as four fields: a push whose refs
442
+ * cannot all be read defers nothing (main refuses such a push before it gets here, on every host; this is the second lock). Every
443
+ * pushed ref is judged, whatever its position. Returns { defer, why }. The caller asks only when no broker or shim resolved.
444
+ */
445
+ export function rustDeferral({ refs = [], env = {}, protectedBranches = [], unparsed = [] }) {
446
+ if (envOn(env[DEFER_OPT_OUT_ENV])) return { defer: false, why: `${DEFER_OPT_OUT_ENV} is set` };
447
+ if (envOn(env.CI) || envOn(env.GITHUB_ACTIONS)) return { defer: false, why: "running in CI" };
448
+ if (unparsed.length) return { defer: false, why: `pre-push stdin line ${unparsed[0].number} is not "<local ref> <local sha> <remote ref> <remote sha>" (four fields separated by single spaces), so the pushed refs cannot all be read` };
449
+ if (!refs.length) return { defer: false, why: "no pushed refs on stdin: not a git pre-push run" };
450
+ const blocking = refs.find((r) => !isDeferrableRef(r, protectedBranches));
451
+ if (blocking) return { defer: false, why: `${blocking} is not a plain non-protected branch (main, master, trunk, release/*, the repository's default branch and tags are never deferred)` };
452
+ return { defer: true, why: `only non-protected branches are pushed (${refs.join(", ")}) and this is not CI` };
453
+ }
454
+
455
+ /**
456
+ * git's pre-push stdin: one "<local ref> <local sha> <remote ref> <remote sha>" per line, the four fields joined by single ASCII spaces.
457
+ * 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
458
+ * 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
459
+ * four non-empty fields is returned in `malformed` ({ number, line }), never dropped. Returns { lines: [{ localRef, localSha, remoteRef,
460
+ * remoteSha }], malformed }.
461
+ */
462
+ export function parsePushLines(stdin) {
463
+ const lines = []; const malformed = [];
464
+ String(stdin ?? "").split("\n").forEach((raw, index) => {
465
+ const line = raw.replace(/\r$/, "");
466
+ if (line === "") return;
467
+ const f = line.split(" ");
468
+ if (f.length !== 4 || f.some((x) => x === "")) { malformed.push({ number: index + 1, line }); return; }
469
+ lines.push({ localRef: f[0], localSha: f[1], remoteRef: f[2], remoteSha: f[3] });
470
+ });
471
+ return { lines, malformed };
472
+ }
473
+
474
+ const rightgitDefaultBranch = (raw) => { try { const d = JSON.parse(String(raw).replace(/^\uFEFF/, "")).defaultBranch; return typeof d === "string" && d ? d : null; } catch { return null; } };
475
+
476
+ /**
477
+ * The names this repository calls its default branch, read from every side a push can be judged by, so a commit that edits
478
+ * `.rightgit.json` cannot unprotect the branch it pushes: the manifest in the checkout, the manifest at origin/HEAD and at each of
479
+ * `revisions` (the base ref and the remote commits being replaced), and the branch origin/HEAD names. `git(...args)` answers like
480
+ * spawnSync; a revision git cannot show contributes nothing. Residual, not closable offline: with no origin/HEAD, no manifest at the base
481
+ * and a replaced remote commit that is not in the local object store (a force-push whose tip was never fetched), the only manifest left
482
+ * is the checkout's, which the pushed commit supplies. The union only ever adds names, so a local branch or tag called `origin/HEAD`
483
+ * cannot remove one: the unambiguous `refs/remotes/origin/HEAD` symbolic-ref read still names the real branch.
484
+ */
485
+ export function defaultBranchNames({ git, readCheckout, revisions = [] }) {
486
+ const names = new Set();
487
+ const add = (n) => { if (n) names.add(n); };
488
+ try { add(rightgitDefaultBranch(readCheckout())); } catch { /* no right-git manifest in the checkout */ }
489
+ // A revision that starts with "-" would be read by git as an option, not a revision.
490
+ for (const rev of new Set(["origin/HEAD", ...revisions.filter((r) => typeof r === "string" && r && !r.startsWith("-"))])) {
491
+ const shown = git("show", `${rev}:.rightgit.json`);
492
+ if (!failed(shown)) add(rightgitDefaultBranch(text(shown)));
493
+ }
494
+ const head = git("symbolic-ref", "--quiet", "refs/remotes/origin/HEAD");
495
+ if (!failed(head)) add(/^refs\/remotes\/origin\/(.+)$/.exec(text(head).trim())?.[1]);
496
+ return [...names];
497
+ }
498
+
499
+ // The path dependencies a manifest declares: `path = "..."` in a [dependencies], [dev-dependencies], [build-dependencies],
500
+ // [target.*.dependencies] or [workspace.dependencies] table, inline (`foo = { path = "../foo" }`) or under `[dependencies.foo]`.
501
+ function pathDependencies(tables) {
502
+ const out = [];
503
+ for (const [name, body] of tables) {
504
+ if (!/(?:^|\.)(?:dev-|build-)?dependencies(?:\.|$)/.test(name)) continue;
505
+ for (const m of body.matchAll(/(?:^|[\s{,.])path[ \t]*=[ \t]*(?:"((?:[^"\\\n]|\\.)*)"|'([^'\n]*)')/gm)) out.push(m[1] ?? m[2]);
506
+ }
507
+ return out;
508
+ }
509
+
510
+ /**
511
+ * A cargo-free stand-in for `cargo metadata --no-deps` in one workspace directory, read from the tracked manifests: the same
512
+ * { cargoDir, members } as workspaceMetadata. A workspace root lists its `[package]` and every tracked manifest below it that its
513
+ * `members` globs name and its `exclude` does not, then (as cargo does) every tracked package below the root that a member, or the root's
514
+ * [workspace.dependencies], reaches by a path dependency and `exclude` does not name. A manifest with no `[workspace]` is a standalone
515
+ * package unless an ancestor workspace owns that directory (cargo refuses it there, so this throws and discoverRustWorkspaces skips it).
516
+ * Strings may be "basic" or 'literal', arrays may carry comments, and `package.name = ...` / `workspace.members = [...]` dotted keys read like
517
+ * their tables. It is a reader, not a TOML parser: a manifest a glob or a path dependency reaches but whose package name it cannot read is
518
+ * returned in `unplaced` (beside { cargoDir, members }; empty for a standalone package, which has nothing to place), any other it cannot place
519
+ * is reported by unmatchedManifests, and the host receipt runs the real metadata.
520
+ */
521
+ export function manifestWorkspaceMetadata({ root, dir, tracked, readFile }) {
522
+ const manifestOf = (d) => (d === "." ? "Cargo.toml" : `${d}/Cargo.toml`);
523
+ const cache = new Map();
524
+ const read = (m) => { if (!cache.has(m)) cache.set(m, manifestTables(readFile(path.join(root, m)))); return cache.get(m); };
525
+ const hasPackage = (m) => hasTomlTable(read(m), "package");
526
+ const memberOf = (m) => {
527
+ const tables = read(m);
528
+ const name = tomlString(tomlValue(tables, "package", "name"));
529
+ if (!name) return null;
530
+ const publish = tomlValue(tables, "package", "publish");
531
+ return { name, dir: dirOfManifest(m), manifest: m, publish: !(publish !== null && /^(?:false\b|\[\s*\])/.test(publish)) };
532
+ };
533
+ const all = tracked("Cargo.toml");
534
+ const tables = read(manifestOf(dir));
535
+ const byName = (a, b) => (a.name < b.name ? -1 : 1);
536
+ if (!hasTomlTable(tables, "workspace")) {
537
+ for (let up = dir; up !== "."; ) {
538
+ up = path.posix.dirname(up);
539
+ if (all.includes(manifestOf(up)) && hasTomlTable(read(manifestOf(up)), "workspace")) throw new Error(`current package believes it's in a workspace when it's not (the workspace root is ${up === "." ? "the repository root" : up})`);
540
+ }
541
+ const self = memberOf(manifestOf(dir));
542
+ if (!self) throw new Error(`${manifestOf(dir)} has neither a [package] with a name nor a [workspace]`);
543
+ return { cargoDir: dir, members: [self], unplaced: [] };
544
+ }
545
+ const patterns = tomlStrings(tomlValue(tables, "workspace", "members")).map((g) => globToRegExp(g));
546
+ const excluded = workspaceExcludes(readFile, root, dir);
547
+ const isExcluded = (d) => excluded.some((x) => d === x || d.startsWith(`${x}/`));
548
+ const rel = (d) => (d === dir ? "." : dir === "." ? d : d.startsWith(`${dir}/`) ? d.slice(dir.length + 1) : null);
549
+ const found = new Map(); const unplaced = new Set();
550
+ const add = (m) => {
551
+ if (found.has(m)) return false;
552
+ const member = memberOf(m);
553
+ if (member) found.set(m, member); else unplaced.add(m);
554
+ return Boolean(member);
555
+ };
556
+ for (const m of all) {
557
+ const d = dirOfManifest(m);
558
+ const r = rel(d);
559
+ if (r === null || r === "." || isExcluded(d) || !patterns.some((re) => re.test(r))) continue;
560
+ add(m);
561
+ }
562
+ if (hasPackage(manifestOf(dir))) add(manifestOf(dir));
563
+ // Path dependencies below the workspace root become members on their own, transitively.
564
+ const queue = [manifestOf(dir), ...found.keys()];
565
+ for (let m = queue.pop(); m !== undefined; m = queue.pop()) {
566
+ for (const p of pathDependencies(read(m))) {
567
+ const target = path.posix.normalize(path.posix.join(dirOfManifest(m), p)).replace(/\/$/, "");
568
+ const tm = manifestOf(target);
569
+ if (rel(target) === null || rel(target) === "." || isExcluded(target) || !all.includes(tm) || found.has(tm) || !hasPackage(tm)) continue;
570
+ if (add(tm)) queue.push(tm);
571
+ }
572
+ }
573
+ return { cargoDir: dir, members: [...found.values()].sort(byName), unplaced: [...unplaced].sort() };
574
+ }
575
+
576
+ /**
577
+ * The tracked Cargo.toml with a package that no unit covers and discoverRustWorkspaces did not skip: what the manifest reader could
578
+ * not place (an unreadable name, a shape it does not know). A manifest it cannot read at all is listed too. Sorted.
579
+ */
580
+ export function unmatchedManifests({ root, manifests, rust, readFile }) {
581
+ const covered = new Set([...rust.units.map((u) => (u.dir === "." ? "Cargo.toml" : `${u.dir}/Cargo.toml`)), ...rust.skipped.map((k) => k.manifest)]);
582
+ return manifests.filter((m) => {
583
+ if (covered.has(m)) return false;
584
+ try { return hasTomlTable(manifestTables(readFile(path.join(root, m))), "package"); } catch { return true; }
585
+ }).sort();
586
+ }
587
+
588
+ /** `plan` without the scans in `dropped` (by id), its digests recomputed over what is left, and `note` appended. */
589
+ export function withoutScans(plan, dropped, note) {
590
+ const gone = new Set(dropped.map((s) => s.id));
591
+ const scans = plan.scans.filter((s) => !gone.has(s.id));
592
+ return {
593
+ ...plan, scans, deferred: plan.deferred.filter((id) => !gone.has(id)),
594
+ scanSetDigest: digestScanSet(scans),
595
+ classDigests: { A: digestScanSet(scans.filter((s) => s.class === "A")), B: digestScanSet(scans.filter((s) => s.class === "B")) },
596
+ notes: note ? [...plan.notes, note] : plan.notes,
597
+ };
598
+ }
599
+
600
+ /**
601
+ * The lines the gate prints when it defers: the Rust units and scans that did not run, the pushed refs, and who gates the merge.
602
+ * `manifests` is the number of tracked Cargo.toml: when it is above zero and no unit was found, the block says the reader found none
603
+ * and that the scans cannot be named, instead of claiming no Rust scan was due. `unmatched` lists the tracked manifests with a package
604
+ * that no unit covers (unmatchedManifests); they get a line of their own. `npmCalls` are the npm test scans deferred with the Rust
605
+ * gate because their package script runs `rustright` or `cargo` ([{ id, tool, via, command }]); `notRun` names them too, and a line of their
606
+ * own gives each one's reason.
607
+ */
608
+ export function deferredBlock({ units, notRun, refs, manifests = 0, unmatched = [], tag = "rightkit-gate:", npmCalls = [] }) {
609
+ const noUnits = units.length === 0 && manifests > 0;
610
+ const lines = [
611
+ `${tag} rust gate DEFERRED to host qualification (no RustRight broker on this host): rust units ${units.length ? units.join(", ") : noUnits ? `none found among the ${manifests} tracked Cargo.toml (read without cargo)` : "none found"}; scans not run ${notRun.length ? notRun.join(", ") : noUnits ? "unknown (no unit was found, so no Rust scan could be planned)" : "none (no Rust scan was due for this diff)"}; the pull request needs a HOST RECEIPT before merge`,
612
+ `${tag} pushed ${refs.join(", ")} (non-protected, not CI). The HOST RECEIPT (a host with the RustRight broker ran the full gate) stays the merge gate; ${DEFER_OPT_OUT_ENV}=1 keeps the refusal on this host.`,
613
+ ];
614
+ if (unmatched.length) lines.push(`${tag} tracked Cargo.toml with a package that matched no unit (the manifest reader runs no cargo; the HOST RECEIPT does): ${unmatched.join(", ")}`);
615
+ if (npmCalls.length) lines.push(`${tag} npm test scan(s) deferred with the Rust gate, never run and never passed, because the package script runs ${[...new Set(npmCalls.map((c) => c.tool))].join(" or ")} and this host has no broker: ${npmCalls.map((c) => `${c.id} (script ${c.via.join(" -> ")}: ${c.command})`).join("; ")}`);
616
+ return lines;
617
+ }
618
+
303
619
  /**
304
620
  * The crates a lock-reflected workspace change reaches (the lock graph CI's selection uses): a Cargo.lock change, a vendor/ change
305
621
  * (the vendored crate is a lock package), and a workspace manifest change confined to the tables the lock also records
@@ -340,13 +656,15 @@ export function lockScope({ changed, cargoDir = ".", unitNames, showBase, readLo
340
656
  /**
341
657
  * The pre-push hook. git's ref lines arrive on stdin and the gate consumes them, so stdin is buffered once and fed to both the
342
658
  * repository's own .git/hooks/pre-push (which core.hooksPath=.githooks would otherwise hide; it runs first and a failure stops the
343
- * push) and the gate. The gate binary is the root node_modules/.bin/rightkit-gate, else any workspace package's (up to three
344
- * levels down: tauri-app-next, apps/*, packages/*), else `pnpm exec rightkit-gate`, else node with a package-relative path when
345
- * pnpm is absent; the line it chose is printed. No absolute path is written: everything is resolved from git at run time.
659
+ * push) and the gate. Between them runs the app check, `rightkit-gate check --commit HEAD`: seconds, committed files only, so a
660
+ * red tree stops the push before the gate's scans start; a push that only deletes refs has no content to check and skips it. The
661
+ * gate binary is the root node_modules/.bin/rightkit-gate, else any workspace package's (up to three levels down: tauri-app-next,
662
+ * apps/*, packages/*), else `pnpm exec rightkit-gate`, else node with a package-relative path when pnpm is absent; the line it
663
+ * chose is printed once. No absolute path is written: everything is resolved from git at run time.
346
664
  */
347
665
  export function hookText(nodePath) {
348
666
  return `#!/bin/sh
349
- # Mandatory local gate before every push (rightkit-gate install-hook). Never bypass with --no-verify.
667
+ # Mandatory local check and gate before every push (rightkit-gate install-hook). Never bypass with --no-verify.
350
668
  cd "$(git rev-parse --show-toplevel)" || exit 1
351
669
  stdin=$(mktemp "\${TMPDIR:-/tmp}/rightkit-gate.XXXXXX") || exit 1
352
670
  trap 'rm -f "$stdin"' EXIT
@@ -358,18 +676,27 @@ if [ -x "$own" ]; then
358
676
  "$own" "$@" < "$stdin" || exit $?
359
677
  fi
360
678
  # The gate binary: the root install, any workspace package's install, then pnpm exec.
679
+ found=""
361
680
  for bin in node_modules/.bin/rightkit-gate */node_modules/.bin/rightkit-gate */*/node_modules/.bin/rightkit-gate */*/*/node_modules/.bin/rightkit-gate; do
362
- if [ -x "$bin" ]; then
363
- echo "rightkit-gate: using $bin" >&2
364
- ("$bin" --base origin/main) < "$stdin"; exit $?
365
- fi
681
+ if [ -x "$bin" ]; then found="$bin"; break; fi
366
682
  done
367
- if command -v pnpm >/dev/null 2>&1; then
683
+ if [ -n "$found" ]; then
684
+ echo "rightkit-gate: using $found" >&2
685
+ rightkit_gate() { "$found" "$@"; }
686
+ elif command -v pnpm >/dev/null 2>&1; then
368
687
  echo "rightkit-gate: using pnpm exec rightkit-gate" >&2
369
- (exec pnpm exec rightkit-gate --base origin/main) < "$stdin"; exit $?
688
+ rightkit_gate() { pnpm exec rightkit-gate "$@"; }
689
+ else
690
+ echo "rightkit-gate: using node ${nodePath}" >&2
691
+ rightkit_gate() { node "${nodePath}" "$@"; }
692
+ fi
693
+ # The check first, over the committed files of HEAD; a push that only deletes refs has nothing to check.
694
+ if [ -s "$stdin" ] && ! awk '$2 !~ /^0+$/ { found = 1 } END { exit !found }' "$stdin"; then
695
+ echo "rightkit-gate: only deletions are pushed; nothing to check" >&2
696
+ else
697
+ rightkit_gate check --commit HEAD < /dev/null >&2 || exit $?
370
698
  fi
371
- echo "rightkit-gate: using node ${nodePath}" >&2
372
- (exec node "${nodePath}" --base origin/main) < "$stdin"; exit $?
699
+ rightkit_gate --base origin/main < "$stdin"; exit $?
373
700
  `;
374
701
  }
375
702
 
@@ -551,7 +878,7 @@ const plainRelative = (p) => typeof p === "string" && p !== "" && !path.posix.is
551
878
  * when declared. Without a declaration, a closure source that navigates out of its crate at run time without a literal path
552
879
  * (externalInputs `leavesCrate`: workspace root, current_dir, a bare "..", the manifest directory's parent) keys the scan on the
553
880
  * whole HEAD tree, since its tests may read anything.
554
- * - An npm package scan (test:npm, tsc, eslint) keys on the package's workspace closure (its `workspace:` dependencies,
881
+ * - An npm package scan (test:npm, tsc, eslint, biome) keys on the package's workspace closure (its `workspace:` dependencies,
555
882
  * transitively, by package name) and the root npm inputs; test:scripts-ci on scripts/, ci/, .github/, packages/release and the
556
883
  * root install graph. Every other class B scan (generated-docs, swift-build) uses the whole tree.
557
884
  * - Class A: cargo-deny, cargo-audit, pnpm-audit, pnpm-lockfile and advisory-exceptions are never cached (live answers); rustfmt,
@@ -560,6 +887,9 @@ const plainRelative = (p) => typeof p === "string" && p !== "" && !path.posix.is
560
887
  */
561
888
  // Root npm inputs every package scan reads: the install graph and the shared tool configuration.
562
889
  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"];
890
+ // ESLint and Biome look their configuration up from the package directory through every ancestor. A config in an ancestor directory (or of another
891
+ // 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.
892
+ const LINT_CONFIG_NAMES = [...ESLINT_CONFIG_FILES, ...BIOME_CONFIG_FILES];
563
893
  // The gate's own test suite reads the CI scripts, the gate config and the release package it imports from.
564
894
  const SCRIPTS_CI_INPUTS = ["scripts", "ci", ".github", "packages/release", "package.json", "pnpm-lock.yaml"];
565
895
 
@@ -580,7 +910,7 @@ export function buildCacheDigest({ objectId, headTree, rustUnits = [], npmUnits
580
910
  };
581
911
  let npmByPkgName = null;
582
912
  const npmClosure = (unit) => {
583
- if (npmByPkgName === null) { npmByPkgName = new Map(); for (const u of npmUnits) { const m = npmManifest(u); if (m?.name) npmByPkgName.set(m.name, u); } }
913
+ 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); } }
584
914
  const seen = new Map([[unit.name, unit]]); const stack = [unit]; let unresolved = false;
585
915
  while (stack.length) {
586
916
  const u = stack.pop(); const m = npmManifest(u);
@@ -588,7 +918,7 @@ export function buildCacheDigest({ objectId, headTree, rustUnits = [], npmUnits
588
918
  for (const section of ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"]) {
589
919
  for (const [name, spec] of Object.entries(m[section] ?? {})) {
590
920
  if (!/^(workspace:|link:|file:)/.test(String(spec))) continue;
591
- const dep = npmByPkgName.get(name);
921
+ const dep = npmByPkgName.get(`${u.root ?? "."}\0${name}`);
592
922
  if (!dep) { unresolved = true; continue; }
593
923
  if (!seen.has(dep.name)) { seen.set(dep.name, dep); stack.push(dep); }
594
924
  }
@@ -596,6 +926,18 @@ export function buildCacheDigest({ objectId, headTree, rustUnits = [], npmUnits
596
926
  }
597
927
  return { members: [...seen.values()].sort((a, b) => (a.name < b.name ? -1 : 1)), unresolved };
598
928
  };
929
+ // 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),
930
+ // the same files of every other pnpm root the units belong to, and the linter configuration of their ancestor directories that the commit has.
931
+ const sharedNpmInputs = (members) => {
932
+ const required = new Set(SHARED_NPM_INPUTS); const optional = new Set();
933
+ for (const m of members) {
934
+ if ((m.root ?? ".") !== ".") for (const p of SHARED_NPM_INPUTS) required.add(inWs(m.root, p));
935
+ for (const dir of ancestorDirsOf(m.dir)) if (dir !== m.dir) for (const f of LINT_CONFIG_NAMES) optional.add(inWs(dir, f));
936
+ }
937
+ const nested = [...required].filter((p) => !SHARED_NPM_INPUTS.includes(p)).sort();
938
+ const extra = [...optional].filter((p) => !required.has(p)).sort();
939
+ return [...SHARED_NPM_INPUTS, ...nested].map((p) => [p, idOf(p)]).concat(extra.map((p) => [p, idOf(p)]).filter(([, id]) => id !== null && id !== undefined));
940
+ };
599
941
  // A unit belongs to the Cargo workspace it names (rustUnits[].cargoDir, else the global one) and is a package of that workspace's
600
942
  // own lock under its crate name (rustUnits[].crate, else its name); its closure never crosses into another workspace.
601
943
  const wsOf = (u) => u.cargoDir ?? cargoDir;
@@ -650,11 +992,11 @@ export function buildCacheDigest({ objectId, headTree, rustUnits = [], npmUnits
650
992
  if (!unit) {
651
993
  if (typeof readBlob !== "function") return whole();
652
994
  // npm package scans: the package's workspace closure plus the root npm inputs.
653
- const npmMatch = /^(test:npm|tsc|eslint):(.+)$/.exec(scan.id);
995
+ const npmMatch = /^(test:npm|tsc|eslint|biome):(.+)$/.exec(scan.id);
654
996
  if (npmMatch && npmByUnit.has(npmMatch[2])) {
655
997
  const { members, unresolved } = npmClosure(npmByUnit.get(npmMatch[2]));
656
998
  if (unresolved) return whole();
657
- 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)]) });
999
+ return canonicalJson({ kind: "npm", id: scan.id, members: members.map((m) => [m.name, m.dir, idOf(m.dir)]), shared: sharedNpmInputs(members) });
658
1000
  }
659
1001
  if (scan.id === "test:scripts-ci") return canonicalJson({ kind: "dirs", id: scan.id, inputs: SCRIPTS_CI_INPUTS.map((p) => [p, idOf(p)]) });
660
1002
  return whole();
@@ -750,6 +1092,12 @@ export function quietLeaseLane(o, { tag = "rightkit-gate:", reason = "rightkit-g
750
1092
 
751
1093
  // ---- command resolution -------------------------------------------------------------------------------------
752
1094
 
1095
+ // The rustfmt this host can run: the one findRustfmt names, when it answers `--version` (a rustup proxy with no toolchain does not).
1096
+ function usableRustfmt(o) {
1097
+ const found = findRustfmt({ env: o.env, exists: o.exists, readdir: o.readdir, platform: o.platform, spawn: o.spawn });
1098
+ return found && !failed(o.spawn(found, ["--version"], { cwd: o.cwd })) ? found : null;
1099
+ }
1100
+
753
1101
  // Cargo only through the broker: `rustright cargo`, or a `cargo` on PATH that is the managed shim (it runs cargo-guard.mjs).
754
1102
  function resolveCargoCommand(o) {
755
1103
  const which = (cmd) => {
@@ -780,7 +1128,12 @@ function releaseProvider(o, root, git) {
780
1128
  return null;
781
1129
  }
782
1130
 
783
- function installHook(o, root, force) {
1131
+ // The advisory ownership pre-commit step (`right-release hooks install`, ownership-hooks.mjs) runs the pinned @rightkit/release, so
1132
+ // it is written only where that is installed. Its own hook and the repository's .git/hooks/pre-commit both keep running.
1133
+ const PINNED_CLI = "node_modules/@rightkit/release/cli/right-release.mjs";
1134
+ const FOREIGN_PRE_COMMIT = "pre-commit.rightkit-foreign";
1135
+
1136
+ async function installHook(o, root, force) {
784
1137
  const git = (...a) => o.spawn("git", a, { cwd: root });
785
1138
  const hooksDir = path.join(root, ".githooks");
786
1139
  const hookPath = path.join(hooksDir, "pre-push");
@@ -797,15 +1150,29 @@ function installHook(o, root, force) {
797
1150
  const gitDirResult = git("rev-parse", "--git-dir");
798
1151
  let present = [];
799
1152
  if (!failed(gitDirResult) && text(gitDirResult).trim()) { try { present = o.readdir(path.join(path.resolve(root, text(gitDirResult).trim()), "hooks")); } catch { present = []; } }
1153
+ const gitHooksDir = !failed(gitDirResult) && text(gitDirResult).trim() ? path.join(path.resolve(root, text(gitDirResult).trim()), "hooks") : null;
800
1154
  const own = present.filter(chainableHook).sort();
801
- const skipped = present.filter((name) => !name.endsWith(".sample") && !chainableHook(name)).sort();
1155
+ // The ownership step takes .githooks/pre-commit. The repository's own pre-commit hook keeps running first, through a trampoline
1156
+ // beside it, unless it is the ownership hook a plain `right-release hooks install` wrote (this one replaces that), whose own
1157
+ // chained hook, pre-commit.rightkit-foreign, then runs instead. A name that is no plain hook name is reported, never chained.
1158
+ const ownership = o.exists(path.join(root, PINNED_CLI)) ? await import("../ownership-hooks.mjs") : null;
1159
+ const readHook = (name) => { try { return o.readFile(path.join(gitHooksDir, name)); } catch { return ""; } };
1160
+ const chainName = !ownership ? null : own.includes("pre-commit") && !readHook("pre-commit").includes(ownership.HOOK_MARKER) ? "pre-commit" : present.includes(FOREIGN_PRE_COMMIT) ? FOREIGN_PRE_COMMIT : null;
1161
+ const skipped = present.filter((name) => !name.endsWith(".sample") && !chainableHook(name) && !(ownership && name === FOREIGN_PRE_COMMIT)).sort();
802
1162
  if (skipped.length) o.err(`rightkit-gate: WARNING: not chaining .git/hooks/${skipped.join(", .git/hooks/")}: not a plain hook file name (letters, digits, - and _ only); run it from a hook of its own.`);
803
- const trampolines = own.filter((name) => name !== "pre-push").map((name) => ({ name, file: path.join(hooksDir, name), wanted: trampolineText(name) }));
1163
+ const trampolines = own.filter((name) => name !== "pre-push" && !(ownership && name === "pre-commit")).map((name) => ({ name, file: path.join(hooksDir, name), wanted: trampolineText(name), note: `runs .git/hooks/${name}` }));
1164
+ if (ownership) {
1165
+ trampolines.push({ name: "pre-commit", file: path.join(hooksDir, "pre-commit"), wanted: ownership.renderPreCommitHook(), ownership: true, replaces: [trampolineText("pre-commit")], marker: ownership.HOOK_MARKER, note: "the advisory ownership gate: right-release ownership --staged" });
1166
+ if (chainName) trampolines.push({ name: FOREIGN_PRE_COMMIT, file: path.join(hooksDir, FOREIGN_PRE_COMMIT), wanted: trampolineText(chainName), note: `runs .git/hooks/${chainName}, before the ownership gate` });
1167
+ }
804
1168
  for (const t of trampolines) {
805
1169
  t.exists = o.exists(t.file);
806
- t.current = t.exists ? o.readFile(t.file).replace(/\r\n/g, "\n") === t.wanted : false;
807
- if (t.exists && !t.current && !force) {
808
- o.err(`rightkit-gate: .githooks/${t.name} already exists and differs; nothing changed. Review it, or run \`rightkit-gate install-hook --force\` to replace it with a trampoline to .git/hooks/${t.name}.`);
1170
+ const found = t.exists ? o.readFile(t.file).replace(/\r\n/g, "\n") : null;
1171
+ t.current = found === t.wanted;
1172
+ // Our own earlier artifacts are updated in place: a plain trampoline where the ownership hook now goes, and an older ownership hook.
1173
+ const ours = found !== null && (t.current || t.replaces?.includes(found) || (t.marker && found.includes(t.marker)));
1174
+ if (t.exists && !ours && !force) {
1175
+ o.err(`rightkit-gate: .githooks/${t.name} already exists and differs; nothing changed. Review it, or run \`rightkit-gate install-hook --force\` to replace it with ${t.ownership ? "the ownership pre-commit hook" : `a trampoline to .git/hooks/${chainName ?? t.name}`}.`);
809
1176
  return 1;
810
1177
  }
811
1178
  }
@@ -825,7 +1192,7 @@ function installHook(o, root, force) {
825
1192
  else put(hookPath, wanted, `${hasHook ? "replaced" : "wrote"} .githooks/pre-push`, ` (${nodePath} without pnpm)`);
826
1193
  for (const t of trampolines) {
827
1194
  if (t.current) done.push(`.githooks/${t.name} already current`);
828
- else put(t.file, t.wanted, `${t.exists ? "replaced" : "wrote"} .githooks/${t.name}`, ` (runs .git/hooks/${t.name})`);
1195
+ else put(t.file, t.wanted, `${t.exists ? "replaced" : "wrote"} .githooks/${t.name}`, ` (${t.note})`);
829
1196
  }
830
1197
  if (existingPath === ".githooks") done.push("core.hooksPath already .githooks");
831
1198
  else {
@@ -836,6 +1203,7 @@ function installHook(o, root, force) {
836
1203
  for (const line of done) o.out(`rightkit-gate: ${line}`);
837
1204
  const chained = own.length ? own.map((name) => (name === "pre-push" ? "pre-push (run first, then the gate)" : name)) : [];
838
1205
  o.out(`rightkit-gate: chained hooks from .git/hooks: ${chained.length ? chained.join(", ") : "none"}${own.includes("pre-push") ? "" : "; the pre-push hook still runs .git/hooks/pre-push first if one appears later"}`);
1206
+ 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`);
839
1207
  const files = [".githooks/pre-push", ...trampolines.map((t) => `.githooks/${t.name}`)];
840
1208
  o.out(`rightkit-gate: commit the hooks with executable mode (git add --chmod=+x ${files.join(" ")}); the pre-push hook finds rightkit-gate in node_modules/.bin of the repository root, else of any workspace package, else through \`pnpm exec\`.`);
841
1209
  const provider = releaseProvider(o, root, git);
@@ -849,7 +1217,8 @@ function installHook(o, root, force) {
849
1217
  /**
850
1218
  * Run the CLI. Returns the exit code (0 pass, 1 blocked or failed, 2 cannot run, 128+n cancelled). `io` overrides the
851
1219
  * process, fs and output bindings so tests execute nothing real: spawn, exists, readFile, writeFile, mkdir, chmod,
852
- * readStdin, out, err, cwd, env, platform, abort (an AbortController).
1220
+ * readStdin, out, err, cwd, env, platform, abort (an AbortController), runScans and findRustfmt (the scan runner and the
1221
+ * lookup of a usable rustfmt, which the deferred Rust gate needs).
853
1222
  */
854
1223
  export async function main(argv, io = {}) {
855
1224
  const o = {
@@ -865,24 +1234,39 @@ export async function main(argv, io = {}) {
865
1234
  }
866
1235
  if (args.includes("--help") || args.includes("-h")) { o.out(USAGE); return 0; }
867
1236
  const sub = args[0] && !args[0].startsWith("-") ? args[0] : null;
868
- if (sub && sub !== "install-hook") { o.err(`rightkit-gate: unknown command ${JSON.stringify(sub)}\n${USAGE}`); return 2; }
1237
+ if (sub && sub !== "install-hook" && sub !== "check") { o.err(`rightkit-gate: unknown command ${JSON.stringify(sub)}\n${USAGE}`); return 2; }
1238
+ // The app check has its own export and reads no gate state: it needs neither the base ref nor the pushed refs on stdin.
1239
+ if (sub === "check") return (o.appCheck ?? (await import("../app-check.mjs")).main)(args.slice(1), { out: o.out, err: o.err, label: "check", cwd: o.cwd });
869
1240
  if (args.includes("--base") && (!option("--base") || option("--base").startsWith("--"))) { o.err("rightkit-gate: --base needs a ref"); return 2; }
870
1241
 
871
1242
  const top = o.spawn("git", ["rev-parse", "--show-toplevel"], { cwd: o.cwd });
872
1243
  if (failed(top) || !text(top).trim()) { o.err("rightkit-gate: not inside a git repository"); return 2; }
873
1244
  const root = path.resolve(text(top).trim());
874
1245
  const git = (...a) => o.spawn("git", a, { cwd: root });
875
- if (sub === "install-hook") return installHook(o, root, args.includes("--force"));
1246
+ if (sub === "install-hook") return await installHook(o, root, args.includes("--force"));
876
1247
 
877
1248
  const planOnly = args.includes("--plan");
878
1249
  let base = option("--base") ?? "origin/main";
879
1250
  const tag = "rightkit-gate:";
880
1251
 
881
1252
  // git pre-push: "<local ref> <local sha> <remote ref> <remote sha>" per pushed ref. A plan reads no stdin.
1253
+ // `pushedRefs` is every remote ref named, deletions included: it decides whether the Rust gate may defer (rustDeferral). The fields are
1254
+ // split on single ASCII spaces only (parsePushLines): a ref name may hold Unicode whitespace. A line that does not read as four fields
1255
+ // (git hands the local field over verbatim, so a rev expression such as `HEAD@{1 minute ago}` or `HEAD^{/one thing}` holds spaces)
1256
+ // refuses the push on every host and in every repository, never a line that disappears: its pushed commit could not be compared with
1257
+ // HEAD and its remote ref could not be judged. `pushedRemoteShas` are the remote commits being replaced.
1258
+ const pushedRefs = []; const pushedRemoteShas = [];
1259
+ const askedBase = base;
882
1260
  if (!planOnly) {
883
1261
  const head = text(git("rev-parse", "HEAD")).trim();
884
- const lines = o.readStdin().split(/\r?\n/).map((l) => l.trim().split(/\s+/)).filter((f) => f.length === 4);
885
- for (const [, localSha, remoteRef, remoteSha] of lines) {
1262
+ const parsed = parsePushLines(o.readStdin());
1263
+ if (parsed.malformed.length) {
1264
+ 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>)`);
1265
+ return 1;
1266
+ }
1267
+ for (const { localSha, remoteRef, remoteSha } of parsed.lines) {
1268
+ pushedRefs.push(remoteRef);
1269
+ if (!ZERO.test(remoteSha)) pushedRemoteShas.push(remoteSha);
886
1270
  if (ZERO.test(localSha)) continue; // a delete pushes no content
887
1271
  if (localSha !== head) {
888
1272
  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`);
@@ -900,13 +1284,25 @@ export async function main(argv, io = {}) {
900
1284
  const readLive = (f) => o.readFile(f);
901
1285
 
902
1286
  // Units come from the live checkout (cargo reads it); a dirty checkout then blocks below.
903
- let rust; let npm; let cargoCommand = ["rustright", "cargo"];
1287
+ let rust; let npm; let npmRoots = []; let cargoCommand = ["rustright", "cargo"]; let deferral = null; let manifests = []; const unplaced = [];
904
1288
  try {
905
- const manifests = tracked("Cargo.toml");
1289
+ manifests = tracked("Cargo.toml");
906
1290
  if (manifests.length) {
907
1291
  cargoCommand = resolveCargoCommand(o);
908
- 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; }
909
- 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; }
1292
+ if (!cargoCommand) {
1293
+ // No broker and no shim. A pre-push of non-protected branches outside CI defers the Rust gate to host qualification; anything
1294
+ // else refuses exactly as before. The cargo command is then only the text of the argv the deferred scans would have had.
1295
+ // The default branch is named by the checkout, the base, the remote commits being replaced and origin/HEAD, never by the pushed commit alone.
1296
+ const configured = defaultBranchNames({ git, readCheckout: () => o.readFile(path.join(root, ".rightgit.json")), revisions: [askedBase, ...pushedRemoteShas] });
1297
+ const decision = rustDeferral({ refs: pushedRefs, env: o.env, protectedBranches: configured });
1298
+ if (!decision.defer) {
1299
+ o.err(`${tag} BLOCKED: neither \`rustright\` nor the managed cargo shim (cargo-guard.mjs) is first on PATH; plain cargo is never used`);
1300
+ o.err(`${tag} not deferred to host qualification: ${decision.why}`);
1301
+ return 1;
1302
+ }
1303
+ deferral = decision;
1304
+ cargoCommand = ["rustright", "cargo"];
1305
+ } else if (!o.exists(path.join(root, GRANT_FILE))) { o.err(`${tag} BLOCKED: no local compile grant (${GRANT_FILE}); the broker refuses cargo, so the Rust units cannot be discovered or scanned`); return 1; }
910
1306
  }
911
1307
  // Untracked (not ignored) manifests only: git does not descend into ignored directories, so this stays cheap. A member of a
912
1308
  // workspace that is untracked or ignored is dropped by discoverRustWorkspaces itself.
@@ -918,16 +1314,23 @@ export async function main(argv, io = {}) {
918
1314
  // consumer rows of the crates they exercise (CI treats them the same: scripts/ci/gate.mjs consumerRowInvocations), never as units.
919
1315
  let fixtures = [];
920
1316
  try { const f = JSON.parse(o.readFile(path.join(root, GATE_CONFIG))).fixtures; if (Array.isArray(f)) fixtures = f.filter((d) => typeof d === "string"); } catch { /* no config: no fixtures */ }
921
- rust = discoverRustWorkspaces({ root, spawn: (c, a, opts) => o.spawn(c, a, opts), cargoCommand, tracked, untracked, fixtures, readFile: readLive });
1317
+ rust = discoverRustWorkspaces({
1318
+ root, spawn: (c, a, opts) => o.spawn(c, a, opts), cargoCommand, tracked, untracked, fixtures, readFile: readLive,
1319
+ ...(deferral ? { metadata: (dir) => { const read = manifestWorkspaceMetadata({ root, dir, tracked, readFile: readLive }); unplaced.push(...(read.unplaced ?? [])); return read; } } : {}),
1320
+ });
922
1321
  npm = discoverNpmUnits({ root, tracked, readFile: readLive, exists: o.exists });
1322
+ npmRoots = discoverPnpmRoots({ root, tracked, exists: o.exists });
923
1323
  } catch (e) { o.err(`${tag} BLOCKED: cannot discover the repository's units: ${e.message}`); return 1; }
924
1324
  for (const k of rust.skipped) o.out(`${tag} note: ${k.manifest} is skipped, not scanned: ${k.why}`);
925
1325
 
926
1326
  // Broker scans (cargo, pnpm) read the live checkout, so it must equal HEAD where they read it: an uncommitted fix could
927
1327
  // otherwise hide a committed failure. Any change or untracked file under those paths blocks the gate.
928
1328
  if (!planOnly) {
1329
+ // The install graph and linter configuration of a pnpm root: the repository root's, and those of every other root (its units' directories
1330
+ // hold only the units, and a workspace member's lock sits above it).
1331
+ const npmRootFiles = ["package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml", ".npmrc", ...ESLINT_CONFIG_FILES, ...BIOME_CONFIG_FILES];
929
1332
  const inputs = [...new Set([...rust.units.map((u) => u.dir), ...npm.map((u) => u.dir), ...rust.workspaces.map((w) => w.cargoDir), "ci", "scripts", "vendor", "Cargo.toml", "Cargo.lock", ".cargo", "rust-toolchain.toml", "rust-toolchain",
930
- "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"])];
1333
+ "deny.toml", "clippy.toml", "rustfmt.toml", ...npmRootFiles, ...npmRoots.filter((r) => r !== ".").flatMap((r) => npmRootFiles.map((f) => `${r}/${f}`))])];
931
1334
  const dirty = git("status", "--porcelain", "--untracked-files=all", "--", ...inputs);
932
1335
  const dirtyFiles = failed(dirty) ? [`git status failed: ${why(dirty)}`]
933
1336
  : text(dirty).split(/\r?\n/).filter(Boolean).filter((l) => !l.includes("node_modules") && !l.includes(GRANT_FILE));
@@ -981,11 +1384,27 @@ export async function main(argv, io = {}) {
981
1384
  // The quiet tests (port, pipe, desktop and wall-clock bound) are proven by the CI shards, one crate per runner, unless
982
1385
  // RK_GATE_QUIET_TESTS=local asks for the serial quiet lane here (no CI, or a CI outage).
983
1386
  quietTests: o.env.RK_GATE_QUIET_TESTS === "local" ? "local" : "ci", packageFeatures,
984
- rustUnits: rust.units, npmUnits: npm, cargoDir: ".", rustLocks, crateFeatures,
985
- localCompile: o.exists(path.join(root, GRANT_FILE)),
1387
+ rustUnits: rust.units, npmUnits: npm, npmRoots, cargoDir: ".", rustLocks, crateFeatures,
1388
+ localCompile: deferral ? true : o.exists(path.join(root, GRANT_FILE)),
986
1389
  exists: o.exists, readFile: o.readFile, platform: o.platform,
987
1390
  });
988
- const plan = args.includes("--pre-push") ? prePushPlan(fullPlan) : fullPlan;
1391
+ let plan = args.includes("--pre-push") ? prePushPlan(fullPlan) : fullPlan;
1392
+ // Deferred Rust gate: every scan that goes through the broker is taken out of the plan and named; rustfmt too when this host has
1393
+ // none (it is a Rust-toolchain scan, not a content scan, on such a host). Every other scan stays exactly as planned.
1394
+ let notRun = []; let deferNote = null; let npmCalls = [];
1395
+ if (deferral) {
1396
+ const rustfmt = (o.findRustfmt ?? usableRustfmt)(o);
1397
+ const dropped = plan.scans.filter((s) => s.tool === "rustright" || (s.tool === "rustfmt" && !rustfmt));
1398
+ // An npm test scan whose script runs `rustright` or `cargo` through its package script chain (planScans marks it `brokerCall`: ViewRight's
1399
+ // test:e2e is `rustright storage run ... -- right-release test`) cannot run on this host either. It goes the way of the Rust scans, named
1400
+ // with its reason and never passed. On a host with the broker, in CI, on a protected ref or with the opt-out there is no deferral and it runs.
1401
+ const npmDropped = plan.scans.filter((s) => s.brokerCall && s.tool === "pnpm" && !dropped.includes(s));
1402
+ npmCalls = npmDropped.map((s) => ({ id: s.id, ...s.brokerCall }));
1403
+ notRun = [...dropped, ...npmDropped].map((s) => s.id);
1404
+ plan = withoutScans(plan, [...dropped, ...npmDropped]);
1405
+ deferNote = `no RustRight broker on this host: ${dropped.length} Rust scan(s) are deferred to host qualification, not run and not passed${rustfmt ? "" : " (rustfmt is not usable on this host either)"}`
1406
+ + (npmDropped.length ? `; ${npmDropped.length} npm test scan(s) whose script runs the broker or cargo are deferred with them, not run and not passed: ${npmDropped.map((s) => s.id).join(", ")}` : "");
1407
+ }
989
1408
  const jobs = Math.max(1, Number(o.env.RK_GATE_JOBS ?? 4) || 4);
990
1409
  const testJobs = Math.max(1, Number(o.env.RK_GATE_TEST_JOBS ?? jobs) || jobs);
991
1410
 
@@ -1000,6 +1419,7 @@ export async function main(argv, io = {}) {
1000
1419
  }
1001
1420
 
1002
1421
  for (const n of plan.notes) o.out(`${tag} note: ${n}`);
1422
+ if (deferNote) o.out(`${tag} note: ${deferNote}`);
1003
1423
  const quietCount = plan.scans.filter((s) => s.quiet === true).length;
1004
1424
  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` : ""}`);
1005
1425
  let signalCode = 0;
@@ -1041,7 +1461,7 @@ export async function main(argv, io = {}) {
1041
1461
  }
1042
1462
  } catch { cache = null; }
1043
1463
  }
1044
- const scanResult = await runScans(plan, {
1464
+ const scanResult = await (o.runScans ?? runScans)(plan, {
1045
1465
  classes: ["A", "B"], jobs, testJobs, signal: ac.signal, ...(cache ? { cache } : {}),
1046
1466
  quietLane: quietLeaseLane(o, { tag, reason: `rightkit-gate-quiet-tests@${text(git("rev-parse", "--short", "HEAD")).trim()}` }),
1047
1467
  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)}` : ""}`),
@@ -1063,6 +1483,12 @@ export async function main(argv, io = {}) {
1063
1483
  o.err("Fix and push again. Never bypass with --no-verify.");
1064
1484
  return 1;
1065
1485
  }
1486
+ if (deferral) {
1487
+ o.out(`${tag} the scans that could run here passed (${changed.length} changed file(s) vs ${base}${scanResult.cacheHits.length ? `; ${scanResult.cacheHits.length} reused from the result cache` : ""})`);
1488
+ const unmatched = [...new Set([...unplaced, ...unmatchedManifests({ root, manifests, rust, readFile: readLive })])].sort();
1489
+ for (const line of deferredBlock({ units: rust.units.map((u) => u.name), notRun, refs: pushedRefs, manifests: manifests.length, unmatched, npmCalls, tag })) o.err(line);
1490
+ return 0;
1491
+ }
1066
1492
  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` : ""}`);
1067
1493
  return 0;
1068
1494
  } finally {