@rightkit/release 0.6.5 → 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 (42) hide show
  1. package/README.md +79 -7
  2. package/android-play-upload.mjs +4 -4
  3. package/app-check.mjs +474 -17
  4. package/apple-profile-create.mjs +4 -3
  5. package/apple-profile-renew.mjs +5 -4
  6. package/bin/darwin/rightkit-swap +0 -0
  7. package/bin/rightkit-gate.mjs +431 -44
  8. package/binary-license-notices.mjs +2 -1
  9. package/build-release.mjs +7 -6
  10. package/cache-command.mjs +4 -3
  11. package/cargo-license-notices.mjs +2 -1
  12. package/cargo-manifest-text.mjs +25 -0
  13. package/cargo-profile-contract.mjs +26 -13
  14. package/cli/right-release.mjs +8 -1
  15. package/command-spec.mjs +4 -5
  16. package/deps.mjs +5 -4
  17. package/deterministic-scans.mjs +280 -32
  18. package/development.mjs +5 -5
  19. package/github-release.mjs +5 -3
  20. package/github-upload-gate.mjs +6 -6
  21. package/init-command.mjs +134 -8
  22. package/ios-upload.mjs +4 -4
  23. package/mirror-root-artifact.mjs +3 -4
  24. package/npm-license-notices.mjs +2 -1
  25. package/outdated-command.mjs +91 -0
  26. package/package.json +37 -1
  27. package/pipeline-admission.mjs +12 -0
  28. package/publish-cargo.mjs +215 -3
  29. package/publish-update.mjs +5 -3
  30. package/release-config.mjs +36 -5
  31. package/release-dependencies-command.mjs +4 -3
  32. package/release-qa-audit.mjs +12 -10
  33. package/release-qa-command.mjs +42 -7
  34. package/release-qa-hardening.mjs +10 -3
  35. package/release-qa-policy.mjs +214 -47
  36. package/release.mjs +4 -4
  37. package/rightkit-registry-resolver.mjs +5 -2
  38. package/rightkit-versions.json +11 -8
  39. package/stage-sidecar-command.mjs +5 -5
  40. package/test-command.mjs +3 -2
  41. package/upload-release.mjs +3 -2
  42. 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
@@ -38,6 +41,16 @@
38
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
39
42
  // read the live checkout), and the remote's current commit is the diff base. To push an earlier commit of the local stack,
40
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.
41
54
  // ci/gate.config.json `rust` (optional) sets per-crate feature sets, exactly as CI's gate reads it.
42
55
  // Result cache: a passing class B scan is reused only when its scan id, command, tool version, environment and input digest
43
56
  // (the gated commit's git object ids for the crate's workspace closure, the shared build inputs, ci/gate.config.json and every
@@ -53,7 +66,7 @@ import fs from "node:fs";
53
66
  import os from "node:os";
54
67
  import path from "node:path";
55
68
  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";
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";
57
70
  import { changedLockNames, parseLock, reach } from "../lock-graph.mjs";
58
71
 
59
72
  const ZERO = /^0+$/;
@@ -115,31 +128,77 @@ const unitName = (pkgName, dir) => {
115
128
  return SAFE_NAME.test(n) ? n : dir === "." ? "root" : dir.replace(/\//g, "-");
116
129
  };
117
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
+
118
139
  /**
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.
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
+
159
+ /**
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`.
122
166
  */
123
167
  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) => {
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) => {
136
188
  let manifest;
137
189
  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) };
190
+ const rootDir = found.get(dir);
191
+ return { dir, root: rootDir, name: rootDir === "." ? unitName(manifest.name, dir) : dir.replace(/\//g, "-") };
139
192
  });
140
193
  const count = new Map();
141
194
  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 }));
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 }) }));
143
202
  }
144
203
 
145
204
  const dirOfManifest = (m) => path.posix.dirname(m);
@@ -205,13 +264,56 @@ export function manifestTables(text) {
205
264
  return tables;
206
265
  }
207
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
+
208
312
  /** The repository-relative directories a workspace manifest `exclude`s (`[workspace] exclude = [...]`); [] when none or unreadable. */
209
313
  export function workspaceExcludes(readFile, root, cargoDir) {
210
314
  let text;
211
315
  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}`));
316
+ return tomlStrings(tomlValue(manifestTables(text), "workspace", "exclude")).map((d) => posix(d).replace(/\/+$/, "")).map((d) => (cargoDir === "." ? d : `${cargoDir}/${d}`));
215
317
  }
216
318
 
217
319
  // The manifest tables whose every effect on a build is also written into Cargo.lock: a changed source ([patch.*]) and workspace
@@ -254,8 +356,10 @@ export function manifestChangeIsLockReflected(before, after) {
254
356
  * A member whose Cargo.toml is not tracked (untracked or ignored) is dropped into `skipped`, as are `untracked()` manifests.
255
357
  * Returns { present, workspaces: [{ cargoDir, units }], units, cargoDir, skipped: [{ manifest, why }] }; `units` is every workspace's
256
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.
257
361
  */
258
- 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 }) {
259
363
  const manifests = tracked("Cargo.toml");
260
364
  const trackedSet = new Set(manifests);
261
365
  const locks = new Set(tracked("Cargo.lock"));
@@ -283,7 +387,7 @@ export function discoverRustWorkspaces({ root, spawn, cargoCommand, tracked, unt
283
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; }
284
388
  }
285
389
  let found;
286
- try { found = workspaceMetadata({ root, spawn, cargoCommand, dir, realpath }); } catch (e) {
390
+ try { found = metadata ? metadata(dir) : workspaceMetadata({ root, spawn, cargoCommand, dir, realpath }); } catch (e) {
287
391
  if (!parent) throw e;
288
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 [^)]*\)$/, "")})` });
289
393
  continue;
@@ -306,6 +410,212 @@ export function discoverRustWorkspaces({ root, spawn, cargoCommand, tracked, unt
306
410
  return { present: true, workspaces, units, cargoDir: workspaces[0]?.cargoDir ?? ".", skipped };
307
411
  }
308
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
+
309
619
  /**
310
620
  * The crates a lock-reflected workspace change reaches (the lock graph CI's selection uses): a Cargo.lock change, a vendor/ change
311
621
  * (the vendored crate is a lock package), and a workspace manifest change confined to the tables the lock also records
@@ -568,7 +878,7 @@ const plainRelative = (p) => typeof p === "string" && p !== "" && !path.posix.is
568
878
  * when declared. Without a declaration, a closure source that navigates out of its crate at run time without a literal path
569
879
  * (externalInputs `leavesCrate`: workspace root, current_dir, a bare "..", the manifest directory's parent) keys the scan on the
570
880
  * 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,
881
+ * - An npm package scan (test:npm, tsc, eslint, biome) keys on the package's workspace closure (its `workspace:` dependencies,
572
882
  * transitively, by package name) and the root npm inputs; test:scripts-ci on scripts/, ci/, .github/, packages/release and the
573
883
  * root install graph. Every other class B scan (generated-docs, swift-build) uses the whole tree.
574
884
  * - Class A: cargo-deny, cargo-audit, pnpm-audit, pnpm-lockfile and advisory-exceptions are never cached (live answers); rustfmt,
@@ -577,6 +887,9 @@ const plainRelative = (p) => typeof p === "string" && p !== "" && !path.posix.is
577
887
  */
578
888
  // Root npm inputs every package scan reads: the install graph and the shared tool configuration.
579
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];
580
893
  // The gate's own test suite reads the CI scripts, the gate config and the release package it imports from.
581
894
  const SCRIPTS_CI_INPUTS = ["scripts", "ci", ".github", "packages/release", "package.json", "pnpm-lock.yaml"];
582
895
 
@@ -597,7 +910,7 @@ export function buildCacheDigest({ objectId, headTree, rustUnits = [], npmUnits
597
910
  };
598
911
  let npmByPkgName = null;
599
912
  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); } }
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); } }
601
914
  const seen = new Map([[unit.name, unit]]); const stack = [unit]; let unresolved = false;
602
915
  while (stack.length) {
603
916
  const u = stack.pop(); const m = npmManifest(u);
@@ -605,7 +918,7 @@ export function buildCacheDigest({ objectId, headTree, rustUnits = [], npmUnits
605
918
  for (const section of ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"]) {
606
919
  for (const [name, spec] of Object.entries(m[section] ?? {})) {
607
920
  if (!/^(workspace:|link:|file:)/.test(String(spec))) continue;
608
- const dep = npmByPkgName.get(name);
921
+ const dep = npmByPkgName.get(`${u.root ?? "."}\0${name}`);
609
922
  if (!dep) { unresolved = true; continue; }
610
923
  if (!seen.has(dep.name)) { seen.set(dep.name, dep); stack.push(dep); }
611
924
  }
@@ -613,6 +926,18 @@ export function buildCacheDigest({ objectId, headTree, rustUnits = [], npmUnits
613
926
  }
614
927
  return { members: [...seen.values()].sort((a, b) => (a.name < b.name ? -1 : 1)), unresolved };
615
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
+ };
616
941
  // A unit belongs to the Cargo workspace it names (rustUnits[].cargoDir, else the global one) and is a package of that workspace's
617
942
  // own lock under its crate name (rustUnits[].crate, else its name); its closure never crosses into another workspace.
618
943
  const wsOf = (u) => u.cargoDir ?? cargoDir;
@@ -667,11 +992,11 @@ export function buildCacheDigest({ objectId, headTree, rustUnits = [], npmUnits
667
992
  if (!unit) {
668
993
  if (typeof readBlob !== "function") return whole();
669
994
  // npm package scans: the package's workspace closure plus the root npm inputs.
670
- const npmMatch = /^(test:npm|tsc|eslint):(.+)$/.exec(scan.id);
995
+ const npmMatch = /^(test:npm|tsc|eslint|biome):(.+)$/.exec(scan.id);
671
996
  if (npmMatch && npmByUnit.has(npmMatch[2])) {
672
997
  const { members, unresolved } = npmClosure(npmByUnit.get(npmMatch[2]));
673
998
  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)]) });
999
+ return canonicalJson({ kind: "npm", id: scan.id, members: members.map((m) => [m.name, m.dir, idOf(m.dir)]), shared: sharedNpmInputs(members) });
675
1000
  }
676
1001
  if (scan.id === "test:scripts-ci") return canonicalJson({ kind: "dirs", id: scan.id, inputs: SCRIPTS_CI_INPUTS.map((p) => [p, idOf(p)]) });
677
1002
  return whole();
@@ -767,6 +1092,12 @@ export function quietLeaseLane(o, { tag = "rightkit-gate:", reason = "rightkit-g
767
1092
 
768
1093
  // ---- command resolution -------------------------------------------------------------------------------------
769
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
+
770
1101
  // Cargo only through the broker: `rustright cargo`, or a `cargo` on PATH that is the managed shim (it runs cargo-guard.mjs).
771
1102
  function resolveCargoCommand(o) {
772
1103
  const which = (cmd) => {
@@ -886,7 +1217,8 @@ async function installHook(o, root, force) {
886
1217
  /**
887
1218
  * Run the CLI. Returns the exit code (0 pass, 1 blocked or failed, 2 cannot run, 128+n cancelled). `io` overrides the
888
1219
  * 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).
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).
890
1222
  */
891
1223
  export async function main(argv, io = {}) {
892
1224
  const o = {
@@ -918,10 +1250,23 @@ export async function main(argv, io = {}) {
918
1250
  const tag = "rightkit-gate:";
919
1251
 
920
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;
921
1260
  if (!planOnly) {
922
1261
  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) {
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);
925
1270
  if (ZERO.test(localSha)) continue; // a delete pushes no content
926
1271
  if (localSha !== head) {
927
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`);
@@ -939,13 +1284,25 @@ export async function main(argv, io = {}) {
939
1284
  const readLive = (f) => o.readFile(f);
940
1285
 
941
1286
  // Units come from the live checkout (cargo reads it); a dirty checkout then blocks below.
942
- 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 = [];
943
1288
  try {
944
- const manifests = tracked("Cargo.toml");
1289
+ manifests = tracked("Cargo.toml");
945
1290
  if (manifests.length) {
946
1291
  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; }
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; }
949
1306
  }
950
1307
  // Untracked (not ignored) manifests only: git does not descend into ignored directories, so this stays cheap. A member of a
951
1308
  // workspace that is untracked or ignored is dropped by discoverRustWorkspaces itself.
@@ -957,16 +1314,23 @@ export async function main(argv, io = {}) {
957
1314
  // consumer rows of the crates they exercise (CI treats them the same: scripts/ci/gate.mjs consumerRowInvocations), never as units.
958
1315
  let fixtures = [];
959
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 */ }
960
- 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
+ });
961
1321
  npm = discoverNpmUnits({ root, tracked, readFile: readLive, exists: o.exists });
1322
+ npmRoots = discoverPnpmRoots({ root, tracked, exists: o.exists });
962
1323
  } catch (e) { o.err(`${tag} BLOCKED: cannot discover the repository's units: ${e.message}`); return 1; }
963
1324
  for (const k of rust.skipped) o.out(`${tag} note: ${k.manifest} is skipped, not scanned: ${k.why}`);
964
1325
 
965
1326
  // Broker scans (cargo, pnpm) read the live checkout, so it must equal HEAD where they read it: an uncommitted fix could
966
1327
  // otherwise hide a committed failure. Any change or untracked file under those paths blocks the gate.
967
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];
968
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",
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"])];
1333
+ "deny.toml", "clippy.toml", "rustfmt.toml", ...npmRootFiles, ...npmRoots.filter((r) => r !== ".").flatMap((r) => npmRootFiles.map((f) => `${r}/${f}`))])];
970
1334
  const dirty = git("status", "--porcelain", "--untracked-files=all", "--", ...inputs);
971
1335
  const dirtyFiles = failed(dirty) ? [`git status failed: ${why(dirty)}`]
972
1336
  : text(dirty).split(/\r?\n/).filter(Boolean).filter((l) => !l.includes("node_modules") && !l.includes(GRANT_FILE));
@@ -1020,11 +1384,27 @@ export async function main(argv, io = {}) {
1020
1384
  // The quiet tests (port, pipe, desktop and wall-clock bound) are proven by the CI shards, one crate per runner, unless
1021
1385
  // RK_GATE_QUIET_TESTS=local asks for the serial quiet lane here (no CI, or a CI outage).
1022
1386
  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)),
1387
+ rustUnits: rust.units, npmUnits: npm, npmRoots, cargoDir: ".", rustLocks, crateFeatures,
1388
+ localCompile: deferral ? true : o.exists(path.join(root, GRANT_FILE)),
1025
1389
  exists: o.exists, readFile: o.readFile, platform: o.platform,
1026
1390
  });
1027
- 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
+ }
1028
1408
  const jobs = Math.max(1, Number(o.env.RK_GATE_JOBS ?? 4) || 4);
1029
1409
  const testJobs = Math.max(1, Number(o.env.RK_GATE_TEST_JOBS ?? jobs) || jobs);
1030
1410
 
@@ -1039,6 +1419,7 @@ export async function main(argv, io = {}) {
1039
1419
  }
1040
1420
 
1041
1421
  for (const n of plan.notes) o.out(`${tag} note: ${n}`);
1422
+ if (deferNote) o.out(`${tag} note: ${deferNote}`);
1042
1423
  const quietCount = plan.scans.filter((s) => s.quiet === true).length;
1043
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` : ""}`);
1044
1425
  let signalCode = 0;
@@ -1080,7 +1461,7 @@ export async function main(argv, io = {}) {
1080
1461
  }
1081
1462
  } catch { cache = null; }
1082
1463
  }
1083
- const scanResult = await runScans(plan, {
1464
+ const scanResult = await (o.runScans ?? runScans)(plan, {
1084
1465
  classes: ["A", "B"], jobs, testJobs, signal: ac.signal, ...(cache ? { cache } : {}),
1085
1466
  quietLane: quietLeaseLane(o, { tag, reason: `rightkit-gate-quiet-tests@${text(git("rev-parse", "--short", "HEAD")).trim()}` }),
1086
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)}` : ""}`),
@@ -1102,6 +1483,12 @@ export async function main(argv, io = {}) {
1102
1483
  o.err("Fix and push again. Never bypass with --no-verify.");
1103
1484
  return 1;
1104
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
+ }
1105
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` : ""}`);
1106
1493
  return 0;
1107
1494
  } finally {