@rightkit/release 0.6.10 → 0.6.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +7 -5
  2. package/admission-files.mjs +3 -0
  3. package/admission.mjs +2 -2
  4. package/advisory-exceptions.mjs +41 -5
  5. package/android-play-upload.mjs +16 -15
  6. package/app-check.mjs +13 -9
  7. package/bin/rightkit-gate.mjs +762 -345
  8. package/broker-pins.mjs +152 -0
  9. package/build-release.mjs +80 -27
  10. package/cargo-ledger.mjs +62 -0
  11. package/cargo-target-shim.mjs +98 -26
  12. package/cli/right-release.mjs +28 -4
  13. package/clippy-equivalence.mjs +202 -0
  14. package/clippy-route.mjs +543 -0
  15. package/committed-source.mjs +39 -5
  16. package/crate-workspaces.mjs +87 -0
  17. package/create-mac-updater.mjs +14 -2
  18. package/deterministic-scans.mjs +196 -21
  19. package/development.mjs +13 -4
  20. package/direct-receipt.mjs +135 -0
  21. package/door-policy.mjs +10 -0
  22. package/door.mjs +6 -6
  23. package/freshness-census.mjs +257 -0
  24. package/gate-stages.mjs +76 -10
  25. package/hook-retirement.mjs +65 -0
  26. package/init-command.mjs +1 -1
  27. package/inventory-check.mjs +11 -8
  28. package/ios-upload.mjs +20 -15
  29. package/local-publish.mjs +77 -41
  30. package/local-qualify.mjs +5 -3
  31. package/local-runtime.mjs +5 -4
  32. package/mac-package-recipe.mjs +4 -1
  33. package/mirror-root-artifact.mjs +18 -5
  34. package/ownership-class-rules.mjs +1 -1
  35. package/ownership-evidence.mjs +37 -13
  36. package/ownership-hooks.mjs +20 -59
  37. package/ownership-lockfiles.mjs +14 -1
  38. package/ownership-policy-bundle.mjs +3 -2
  39. package/ownership-staged.mjs +2 -1
  40. package/ownership.mjs +24 -0
  41. package/package-recipe-targets.mjs +35 -6
  42. package/package.json +1 -1
  43. package/pipeline-admission.mjs +4 -4
  44. package/preflight.mjs +27 -4
  45. package/publish-cargo.mjs +4 -3
  46. package/recipe-build.mjs +38 -14
  47. package/release-artifact-path.mjs +341 -0
  48. package/release-invocation.mjs +8 -1
  49. package/release-qa-command.mjs +25 -9
  50. package/release-source-brand.mjs +17 -0
  51. package/release-source-identity.mjs +171 -0
  52. package/release.mjs +44 -17
  53. package/rightkit-versions.json +62 -42
  54. package/runtime-budgets.mjs +6 -2
  55. package/scan-digest.mjs +71 -5
  56. package/sign-updater.mjs +6 -3
  57. package/snapshot-broker.mjs +172 -0
  58. package/snapshot-check.mjs +12 -6
  59. package/snapshot-request.mjs +207 -5
  60. package/snapshot.mjs +123 -10
  61. package/source-split.mjs +73 -0
  62. package/stage-commands.mjs +2 -2
  63. package/stage-ledger.mjs +204 -0
  64. package/stage-sidecar-command.mjs +5 -1
  65. package/stage-sidecar.mjs +100 -20
  66. package/swap-helper-bundle.mjs +5 -2
  67. package/unit-gate.mjs +45 -3
  68. package/web-build.mjs +6 -4
  69. package/win-package-recipe.mjs +20 -7
@@ -0,0 +1,87 @@
1
+ // One Cargo workspace and lock per RightKit crate (Adrian, 2026-10-29: "I want you to have 1 cargo dot lock per component";
2
+ // rightkit#1285). Every `crates/<x>` is its own workspace; crates that use each other link by path dependency across
3
+ // workspaces. This module reads manifests only (no cargo) and answers which crates exist, which sibling crates each one
4
+ // path-depends on, and which crates a change reaches: the changed crate plus every crate that path-depends on it, transitively.
5
+ // It imports nothing outside node: CI's plan job runs it before any package install, so it reads the few manifest keys it needs
6
+ // (the package name and dependency `path` values) line by line instead of through a TOML library.
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+
10
+ const posix = (p) => p.split(path.sep).join("/");
11
+ const defaults = { readFile: (f) => fs.readFileSync(f, "utf8"), readdir: (d) => fs.readdirSync(d), exists: fs.existsSync };
12
+
13
+ /** [{ name, dir: "crates/<x>", manifest, lock }] for every crates/<x>/Cargo.toml, sorted by dir. */
14
+ export function crateWorkspaces(root, io = {}) {
15
+ const { readFile, readdir, exists } = { ...defaults, ...io };
16
+ const cratesDir = path.join(root, "crates");
17
+ if (!exists(cratesDir)) return [];
18
+ const out = [];
19
+ for (const entry of [...readdir(cratesDir)].map(String).sort()) {
20
+ if (entry.startsWith(".")) continue;
21
+ const manifest = `crates/${entry}/Cargo.toml`;
22
+ if (!exists(path.join(root, manifest))) continue;
23
+ const name = manifestFacts(readFile(path.join(root, manifest))).name;
24
+ if (typeof name !== "string") continue;
25
+ out.push({ name, dir: `crates/${entry}`, manifest, lock: `crates/${entry}/Cargo.lock` });
26
+ }
27
+ return out;
28
+ }
29
+
30
+ const DEPENDENCY_TABLE = /^(?:target\.(?:'[^']*'|"[^"]*"|[^.\]]+)\.)?(?:dev-|build-)?dependencies$/;
31
+ const unquote = (v) => v.replace(/^(["'])(.*)\1$/, "$2");
32
+
33
+ /** { name, paths }: the [package] name and every dependency `path` value (inline tables and `[dependencies.<x>]` sections). */
34
+ export function manifestFacts(text) {
35
+ let section = ""; let name; const paths = [];
36
+ for (const raw of String(text).split(/\r?\n/)) {
37
+ const line = raw.replace(/\s+#.*$/, "").trim();
38
+ if (!line || line.startsWith("#")) continue;
39
+ const header = line.match(/^\[\[?([^\[\]]+)\]\]?$/);
40
+ if (header) { section = header[1].trim(); continue; }
41
+ if (section === "package") { const m = line.match(/^name\s*=\s*("[^"]*"|'[^']*')/); if (m) name = unquote(m[1]); continue; }
42
+ if (DEPENDENCY_TABLE.test(section)) {
43
+ const m = line.match(/^[A-Za-z0-9_.-]+\s*=\s*\{(.*)\}$/);
44
+ const p = m && m[1].match(/(?:^|,)\s*path\s*=\s*("[^"]*"|'[^']*')/);
45
+ if (p) paths.push(unquote(p[1]));
46
+ continue;
47
+ }
48
+ const dotted = section.match(/^(.*)\.([^.]+)$/);
49
+ if (dotted && DEPENDENCY_TABLE.test(dotted[1])) { const m = line.match(/^path\s*=\s*("[^"]*"|'[^']*')/); if (m) paths.push(unquote(m[1])); }
50
+ }
51
+ return { name, paths };
52
+ }
53
+
54
+ /** Map<name, Set<name>>: each crate's direct path dependencies on sibling crates (normal, dev, build, any target). */
55
+ export function pathDependencies(root, io = {}, workspaces = crateWorkspaces(root, io)) {
56
+ const { readFile } = { ...defaults, ...io };
57
+ const byDir = new Map(workspaces.map((w) => [w.dir, w.name]));
58
+ const deps = new Map(workspaces.map((w) => [w.name, new Set()]));
59
+ for (const w of workspaces) {
60
+ for (const dep of manifestFacts(readFile(path.join(root, w.manifest))).paths) {
61
+ const target = byDir.get(posix(path.posix.normalize(path.posix.join(w.dir, posix(dep)))));
62
+ if (target && target !== w.name) deps.get(w.name).add(target);
63
+ }
64
+ }
65
+ return deps;
66
+ }
67
+
68
+ /** Set<name>: every crate that path-depends on any of `names`, transitively; `names` themselves are excluded. */
69
+ export function reverseDependents(names, deps) {
70
+ const reverse = new Map();
71
+ for (const [from, tos] of deps) for (const to of tos) (reverse.get(to) ?? reverse.set(to, new Set()).get(to)).add(from);
72
+ const seen = new Set(); const queue = [...names];
73
+ while (queue.length) {
74
+ for (const dependent of reverse.get(queue.shift()) ?? []) {
75
+ if (!seen.has(dependent)) { seen.add(dependent); queue.push(dependent); }
76
+ }
77
+ }
78
+ for (const n of names) seen.delete(n);
79
+ return seen;
80
+ }
81
+
82
+ /** The crate whose workspace holds `file` (repository-relative, posix), or null. */
83
+ export function crateOfFile(file, workspaces) {
84
+ let best = null;
85
+ for (const w of workspaces) if (file.startsWith(`${w.dir}/`) && (!best || w.dir.length > best.dir.length)) best = w;
86
+ return best?.name ?? null;
87
+ }
@@ -3,6 +3,7 @@ import { spawnSync } from "./background-process.mjs";
3
3
  import { existsSync, rmSync } from "node:fs";
4
4
  import path from "node:path";
5
5
  import { signMacUpdaterFiles } from "./macos-signing-session.mjs";
6
+ import { ArtifactPathError, loadSplitArtifactContext, resolveArtifactPath, resolveArtifactWritePath } from "./release-artifact-path.mjs";
6
7
 
7
8
  const args = process.argv.slice(2);
8
9
  let app;
@@ -18,8 +19,19 @@ for (let i = 0; i < args.length; i++) {
18
19
  }
19
20
  if (!app || !output) fail("--app and --output are required");
20
21
 
21
- const appPath = path.resolve(cwd, app);
22
- const outputPath = path.resolve(cwd, output);
22
+ // A door split (row 7.04c, R-T2): a target-prefixed `--app` is the bound Cargo target's, an absolute one must lie inside it, and the tarball is
23
+ // written under the snapshot app root (a generated, ignored ledger entry). Outside a split both are resolved against the cwd as always.
24
+ let appPath;
25
+ let outputPath;
26
+ try {
27
+ const artifactPaths = await loadSplitArtifactContext({ cwd });
28
+ const split = artifactPaths.mode === "split";
29
+ appPath = split ? resolveArtifactPath(app, artifactPaths) : path.resolve(cwd, app);
30
+ outputPath = split ? resolveArtifactWritePath(output, artifactPaths) : path.resolve(cwd, output);
31
+ } catch (error) {
32
+ if (error instanceof ArtifactPathError) fail(error.message);
33
+ throw error;
34
+ }
23
35
  if (!dryRun && !existsSync(appPath)) fail(`signed app not found: ${appPath}`);
24
36
 
25
37
  const env = { ...process.env };
@@ -25,12 +25,14 @@ import { fileURLToPath, pathToFileURL } from "node:url";
25
25
  import { spawn as spawnChild, spawnSync } from "./background-process.mjs";
26
26
  import { spawnUnderOwnership } from "./build-ownership.mjs";
27
27
  import { checkAdvisoryExceptions } from "./advisory-exceptions.mjs";
28
- import { canonicalJson, digestScanSet, requiredScanIds, rustCratePlan, rustScanId } from "./scan-digest.mjs";
28
+ import { CLIPPY_UNIVERSAL_FEATURE_FLAGS, canonicalJson, clippyBatchPlan, clippySelectionOf, digestScanSet, requiredScanIds, rustCratePlan, rustScanId } from "./scan-digest.mjs";
29
29
  import { reach } from "./lock-graph.mjs";
30
30
  import { cargoManifestState } from "./cargo-manifest-text.mjs";
31
31
  import { LAUNCH_GUARD_IMPORT } from "./class-a-launch-guard.mjs";
32
32
  import { advisoryCacheRoot, ensureAdvisoryDb, prepareDenyOverlay, readAdvisoryPin } from "./advisory-db.mjs";
33
33
  import { observedEnvOf, readRequestRef, verifySnapshotReceipt, writeSnapshotRequest } from "./snapshot-request.mjs";
34
+ import { SCAN_TOKEN_ENV, readLedger, scanTokenOf, unclaimedLedgers } from "./cargo-ledger.mjs";
35
+ import { verifyBrokerLedger } from "./snapshot-broker.mjs";
34
36
 
35
37
  export const SCAN_RESULT_SCHEMA = "rightkit.deterministic-scans/1";
36
38
  /**
@@ -48,6 +50,14 @@ export const CHECKER_IDENTITY = (() => {
48
50
  })();
49
51
  export const CHECKER_VERSION = CHECKER_IDENTITY;
50
52
  export const SCAN_CLASS = Object.freeze({ A: "A", B: "B" });
53
+ /**
54
+ * Recorded in the result of every wrapped snapshot compile (item 7 row 7.10, lead re-decision F7): cargo hands a build script its own
55
+ * path in CARGO (measured, cargo 1.98.0), so a cargo a build script runs through $CARGO never reaches the wrapper's ledger.
56
+ */
57
+ export const UNOBSERVED_NESTED_CARGO_NOTE = "cargo calls that a build script makes through $CARGO are unobserved: cargo hands build scripts its own path, not the gate's wrapper, so they are not in the cargo ledger; they inherit the parent's bound CARGO_TARGET_DIR, and the per-scan sentinel and the after-compile snapshot verify are the backstops";
58
+
59
+ /** The ledger token of the gate's own tool version probes (they reach the cargo wrapper like any scan does). */
60
+ const PROBE_LEDGER_TOKEN = scanTokenOf("tool-probe");
51
61
 
52
62
  /** Scan id prefixes (the text before the first ":") that belong to each class. */
53
63
  export const CLASS_A_SCAN_PREFIXES = Object.freeze([
@@ -197,6 +207,19 @@ export function checkArgs(unit, { features = [], messageFormat, locked = false }
197
207
  }
198
208
 
199
209
  /** `cargo doc -p <unit> --no-deps --all-features --locked`; run it with RUSTDOCFLAGS=DOC_RUSTDOCFLAGS (CI). */
210
+ // Every compile reports every failure (Adrian, 2026-10-29: "even to ship, why isn't it better to get all errors and fix them all
211
+ // wholesale?"; rightkit#1293): a compile keeps going past a failed crate (`--keep-going`) and a test run past a failed test binary
212
+ // (`--no-fail-fast`). The run still fails if anything failed; the crates that compiled stay warm for the next run.
213
+ // Cargo 1.98: build, check, clippy (through its cargo check) and doc take `--keep-going`; test and bench reject it and take
214
+ // `--no-fail-fast` instead (the broker's BR29 adds the same split).
215
+ const KEEP_GOING_SUBCOMMANDS = new Set(["build", "check", "clippy", "doc"]);
216
+ const NO_FAIL_FAST_SUBCOMMANDS = new Set(["test", "bench"]);
217
+ export function everyFailure(args) {
218
+ const [sub, ...rest] = args;
219
+ const until = rest.indexOf("--"); const own = until === -1 ? rest : rest.slice(0, until);
220
+ const flag = KEEP_GOING_SUBCOMMANDS.has(sub) ? "--keep-going" : NO_FAIL_FAST_SUBCOMMANDS.has(sub) ? "--no-fail-fast" : null;
221
+ return flag && !own.includes(flag) ? [sub, flag, ...rest] : args;
222
+ }
200
223
  export function docArgs(unit) { return ["doc", "-p", unit, "--no-deps", "--all-features", "--locked"]; }
201
224
  export const DOC_RUSTDOCFLAGS = "-D warnings";
202
225
  /** The same check as rustdoc arguments (`cargo rustdoc ... -- -D warnings`), for the broker path. */
@@ -208,6 +231,35 @@ export function fmtCheckArgs(unit = null) { return unit ? ["fmt", "-p", unit, "-
208
231
  /** `cargo metadata` that validates manifests and the lock without resolving anything new. */
209
232
  export function metadataArgs({ noDeps = true } = {}) { return ["metadata", ...(noDeps ? ["--no-deps"] : []), "--format-version", "1", "--locked"]; }
210
233
 
234
+ // ---- Clippy batching (GATE-CONTRACT §1 "Clippy route & batching"; item 8 rows 8.6, 8.8, 8.15) -----------------------------------------
235
+
236
+ // The batch planner itself (clippyBatchPlan, clippySelectionOf, CLIPPY_UNIVERSAL_FEATURE_FLAGS) lives in scan-digest.mjs, beside the scan
237
+ // ids, and is re-exported here.
238
+ export { CLIPPY_UNIVERSAL_FEATURE_FLAGS, clippyBatchPlan, clippySelectionOf };
239
+
240
+ /**
241
+ * Does this crate's directory declare sidecars its build needs staged first? A Tauri crate with `bundle.externalBin` refuses to build
242
+ * until its sidecars are in place (bin/rightkit-gate.mjs, the compile stage), so it is scheduled after the independent crates. The
243
+ * declaration is read from the crate's own Tauri configuration (JSON, JSON5 or TOML), in its directory or its `src-tauri`.
244
+ */
245
+ export function sidecarBoundCrate({ dir, exists = fs.existsSync, readFile = (f) => fs.readFileSync(f, "utf8") }) {
246
+ for (const base of [dir, path.join(dir, "src-tauri")]) {
247
+ for (const name of ["tauri.conf.json", "tauri.conf.json5", "Tauri.toml"]) {
248
+ const file = path.join(base, name);
249
+ let body;
250
+ try { if (!exists(file)) continue; body = readFile(file); } catch { continue; }
251
+ try {
252
+ const conf = JSON.parse(body);
253
+ const list = conf?.bundle?.externalBin ?? conf?.tauri?.bundle?.externalBin;
254
+ if (Array.isArray(list) && list.length) return { bound: true, file, externalBin: list };
255
+ continue;
256
+ } catch { /* not strict JSON: read as text */ }
257
+ if (/(^|[\s{,"'])externalBin["']?\s*[:=]\s*\[\s*["']/.test(body)) return { bound: true, file, externalBin: null };
258
+ }
259
+ }
260
+ return { bound: false };
261
+ }
262
+
211
263
  // ---- plan ---------------------------------------------------------------------------------------------
212
264
 
213
265
  const posix = (p) => p.replaceAll("\\", "/");
@@ -305,6 +357,11 @@ export const ancestorConfigChanged = (ws, changed, pattern = ANCESTOR_RUST_CONFI
305
357
  const rel = dir === "." ? f : f.startsWith(`${dir}/`) ? f.slice(dir.length + 1) : null;
306
358
  return rel !== null && pattern.test(rel);
307
359
  }));
360
+ // Genuinely shared inputs of a repository whose every crate is its own Cargo workspace (rightkit#1285): no crate directory holds them,
361
+ // so a change to one reaches every unit. Anything else outside a crate directory reaches no crate (a vendored source maps to the crates
362
+ // that patch or path-depend on it through `externalInputs`; a repository-level toolchain, .cargo, clippy or rustfmt file is an
363
+ // ANCESTOR_RUST_CONFIG_FILE of every workspace). The gate configuration, the pipeline's toolchain pins and the gate itself are named here.
364
+ export const SHARED_RUST_SCOPE_FILE = /^(ci\/gate\.config\.json|ci\/rk-pipeline\.json|packages\/release\/(bin\/rightkit-gate|deterministic-scans|scan-digest|advisory-exceptions|crate-workspaces|lock-graph|cargo-target-shim)\.mjs)$/;
308
365
  const WORKSPACE_JS_FILE = /^(package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml|\.npmrc)$/;
309
366
  // The linter a package has is the one it configures. ESLint reads a flat `eslint.config.*` or a legacy `.eslintrc*` from the package directory or
310
367
  // any ancestor of it; Biome reads `biome.json` / `biome.jsonc` the same way. A Biome-only app (ViewRight, ScrapeRight: `"lint": "biome lint ."`)
@@ -739,7 +796,7 @@ export function planScans({
739
796
  // Repository-specific units for any layout: [{ name, dir, publish? }], dir repo-relative ("." for a root unit). When given they
740
797
  // replace the crates/<name> and packages/<name> layout (rightkit-gate). `cargoDir` is the Cargo workspace root the cargo scans run in.
741
798
  // A rust unit may name its own workspace: { name, dir, cargoDir, crate, publish? } runs its scans with cwd cargoDir and `-p <crate>`;
742
- // `name` is then the unit's identity in scan ids and feature config (rightkit-gate names it `<cargoDir>:<crate>` below the root), and
799
+ // `name` is then the unit's identity in scan ids and feature config (rightkit-gate names it `<cargoDir>:<crate>` below the root, and the plain crate for a `crates/<crate>` workspace of its own), and
743
800
  // `crateFeatures` is read by `name` first, then by `crate`. Units of different workspaces never share a lock, a deny/audit run or a
744
801
  // `cargo metadata`; a unit that sets no cargoDir belongs to the global `cargoDir` above.
745
802
  rustUnits = null, npmUnits = null, cargoDir = ".",
@@ -757,6 +814,19 @@ export function planScans({
757
814
  rustLocks = null,
758
815
  // The pinned RustSec advisory database ({ url, commit }, advisory-db.json): cargo-deny and cargo-audit read exactly that commit and never fetch.
759
816
  advisoryPin = null,
817
+ // Cross-workspace reach of a crate-per-workspace repository (rightkit#1285): `crateDependents(unitNames: Set) -> Set` answers the
818
+ // units that path-depend on any of `unitNames`, transitively, across Cargo workspaces (the gate builds it from packages/release/
819
+ // crate-workspaces.mjs; manifests only). Every such unit that is not itself in scope gets a `check-dependent` scan. null plans none beyond
820
+ // the per-workspace lock graph (rustLocks).
821
+ crateDependents = null,
822
+ // `{ "<repo-relative directory>": [unit names] }`: sources outside every unit that a unit compiles (vendor/citationberg is patched into
823
+ // rightkit-typeset). A changed file under the directory puts those units in scope. null maps none.
824
+ externalInputs = null,
825
+ // Clippy batching (item 8; clippyBatchPlan). false keeps the production path: one Clippy scan per crate and lint feature set. It is
826
+ // switched on only after the batch-equivalence run (clippy-equivalence.mjs, row 8.7) has passed on the hosts the map names; then the
827
+ // planner emits one scan per compatible batch and no per-crate Clippy scan for a crate that belongs to one (row 8.15).
828
+ // `shardSolo` is ci/shards.json `solo`; `clippySeparate` maps a unit name to the reason equivalence found batching changed it.
829
+ clippyBatching = false, shardSolo = [], clippySeparate = new Map(),
760
830
  } = {}) {
761
831
  if (!contentRoot) throw new TypeError("planScans: contentRoot is required");
762
832
  if (!Array.isArray(cargoCommand) || cargoCommand.length === 0 || !["rustright", "cargo"].includes(cargoCommand[0]) || cargoCommand.slice(1).some((a) => a !== "cargo")) {
@@ -810,7 +880,7 @@ export function planScans({
810
880
  const plainDir = (d) => !ABSOLUTE_PATH.test(d) && !d.split("/").includes("..") && SAFE_UNIT_DIR.test(d);
811
881
  const cargoWs = cleanDir(cargoDir);
812
882
  if (!plainDir(cargoWs)) throw new TypeError("planScans: cargoDir must be a plain repository-relative directory");
813
- const cargoIn = (ws, ...args) => ({ tool: cargoCommand[0], args: [...cargoCommand.slice(1), ...args], ...(ws !== "." ? { cwd: ws } : {}), ...(cargoLiveRoot ? { root: "cargoLive" } : {}), ...(targetDirs?.[ws] ? { env: { CARGO_TARGET_DIR: targetDirs[ws] } } : {}) });
883
+ const cargoIn = (ws, ...args) => ({ tool: cargoCommand[0], args: [...cargoCommand.slice(1), ...everyFailure(args)], ...(ws !== "." ? { cwd: ws } : {}), ...(cargoLiveRoot ? { root: "cargoLive" } : {}), ...(targetDirs?.[ws] ? { env: { CARGO_TARGET_DIR: targetDirs[ws] } } : {}) });
814
884
  const unitMap = (units, kind) => {
815
885
  if (units === null) return null;
816
886
  if (!Array.isArray(units)) throw new TypeError(`planScans: ${kind}Units must be an array of { name, dir }`);
@@ -865,7 +935,13 @@ export function planScans({
865
935
  };
866
936
  // Units of an explicit-workspace layout (some unit names its cargoDir) get workspace-suffixed ids for the per-workspace scans.
867
937
  const perWorkspaceIds = Array.isArray(rustUnits) && rustUnits.some((u) => u?.cargoDir !== undefined);
868
- const wsIdSuffix = (ws) => (perWorkspaceIds && ws !== "." ? `:${ws}` : "");
938
+ // The label a per-workspace scan id carries: the workspace directory, except for a workspace that is exactly one crate-named unit
939
+ // (`crates/rightkit-fs` holding rightkit-fs, rightkit#1285 D2), which is labelled by the crate (`cargo-deny:rightkit-fs`).
940
+ const wsLabel = (ws) => {
941
+ if (rustMap) for (const [name, u] of rustMap) if (u.cargoDir === ws && name === u.crate && ws === `crates/${u.crate}`) return name;
942
+ return ws;
943
+ };
944
+ const wsIdSuffix = (ws) => (perWorkspaceIds && ws !== "." ? `:${wsLabel(ws)}` : "");
869
945
  const wsOf = (n) => (rustMap ? rustMap.get(n).cargoDir : cargoWs);
870
946
  const crateOf = (n) => (rustMap?.get(n)?.crate ?? n);
871
947
  const featuresOf = (n) => crateFeatures?.[n] ?? crateFeatures?.[crateOf(n)] ?? {};
@@ -899,13 +975,32 @@ export function planScans({
899
975
  const verbatimUpstream = (f) => f.startsWith("vendor/") && typeof upstream[f] === "string" && bytesDigest(f) === upstream[f];
900
976
  const recordedPatch = (f) => f.startsWith("vendor/") && DIGEST.test(patched[f]?.base ?? "") && typeof patched[f]?.note === "string"
901
977
  && patched[f].note.startsWith("vendor/") && DIGEST.test(patched[f]?.sha256 ?? "") && bytesDigest(f) === patched[f].sha256;
978
+ // Declared fixture files (Adrian, 2026-10-30: "there should be a .rkignore list that somehow allows for those to be passed";
979
+ // rightkit#1291): `.rightkit/fixture-files.json` { schema: 1, files: { "<path>": { sha256, note } } } lists files that are
980
+ // deliberately malformed (an audit's known-answer inputs). A listed file whose bytes still match its sha256 skips the per-file
981
+ // format and parse scans only (rustfmt, node --check, json-parse, toml-parse); every other scan still reads it. Any edit changes
982
+ // the digest, so the file is checked again until re-recorded. A malformed manifest, a bad digest or an empty note refuses.
983
+ let fixtureFiles = {};
984
+ if (exists(path.join(contentRoot, ".rightkit/fixture-files.json"))) {
985
+ try {
986
+ const manifest = JSON.parse(readFile(path.join(contentRoot, ".rightkit/fixture-files.json")));
987
+ if (manifest?.schema !== 1 || !manifest.files || typeof manifest.files !== "object" || Array.isArray(manifest.files)) throw new Error("schema 1 with a files object is required");
988
+ for (const [f, row] of Object.entries(manifest.files)) {
989
+ if (!/^[0-9a-f]{64}$/.test(row?.sha256 ?? "")) throw new Error(`${f}: sha256 must be 64 lowercase hex characters`);
990
+ if (typeof row?.note !== "string" || !row.note.trim()) throw new Error(`${f}: a note saying why the file is malformed is required`);
991
+ if (f.startsWith("/") || f.split("/").includes("..")) throw new Error(`${f}: paths are repository-relative`);
992
+ }
993
+ fixtureFiles = manifest.files;
994
+ } catch (error) { refuse(".rightkit/fixture-files.json", error.message); }
995
+ }
996
+ const declaredFixture = (f) => Object.hasOwn(fixtureFiles, f) && bytesDigest(f) === fixtureFiles[f].sha256;
902
997
  // Generated Rust is not hand-formatted: bindgen output, or rustfmt's own `@generated` marker in the first five lines.
903
998
  const generatedRust = (f) => {
904
999
  let head = "";
905
1000
  try { head = readFile(path.join(contentRoot, f)).split("\n", 5).join("\n"); } catch { return false; }
906
1001
  return /@generated\b|automatically generated by rust-bindgen\b/.test(head);
907
1002
  };
908
- const rustFiles = files.filter((f) => f.endsWith(".rs") && !verbatimUpstream(f) && !recordedPatch(f) && !generatedRust(f));
1003
+ const rustFiles = files.filter((f) => f.endsWith(".rs") && !verbatimUpstream(f) && !recordedPatch(f) && !generatedRust(f) && !declaredFixture(f));
909
1004
  const byEdition = new Map();
910
1005
  const editionOf = rustEditionResolver({ contentRoot, exists, readFile });
911
1006
  const refusedEditions = new Set();
@@ -927,12 +1022,12 @@ export function planScans({
927
1022
  add({ id: `rustfmt:${edition}${i ? `:${i / RUSTFMT_BATCH + 1}` : ""}`, class: A, tool: "rustfmt", args: ["--check", "--edition", edition, ...batch], root: "content" });
928
1023
  }
929
1024
  }
930
- for (const f of files.filter((f) => /\.(mjs|cjs|js)$/.test(f) && !f.includes("node_modules")).sort()) {
1025
+ for (const f of files.filter((f) => /\.(mjs|cjs|js)$/.test(f) && !f.includes("node_modules") && !declaredFixture(f)).sort()) {
931
1026
  add({ id: `node-check:${f}`, class: A, tool: "node", args: ["--check", f], root: "content" });
932
1027
  }
933
- const jsonFiles = files.filter((f) => f.endsWith(".json") && !f.includes("node_modules")).sort();
1028
+ const jsonFiles = files.filter((f) => f.endsWith(".json") && !f.includes("node_modules") && !declaredFixture(f)).sort();
934
1029
  if (jsonFiles.length) add({ id: "json-parse", class: A, internal: "json-parse", files: jsonFiles });
935
- const tomlFiles = files.filter((f) => f.endsWith(".toml") && !f.includes("node_modules")).sort();
1030
+ const tomlFiles = files.filter((f) => f.endsWith(".toml") && !f.includes("node_modules") && !declaredFixture(f)).sort();
936
1031
  if (tomlFiles.length) add({ id: "toml-parse", class: A, internal: "toml-parse", files: tomlFiles });
937
1032
  // The strict workflow YAML parser covers the subset GitHub workflows and actions use; other YAML is not parsed with it.
938
1033
  const yamlFiles = files.filter((f) => /^\.github\/.+\.ya?ml$/.test(f)).sort();
@@ -950,7 +1045,12 @@ export function planScans({
950
1045
  const wideJsRoots = new Set(pnpmRoots.filter((r) => files.some((f) => { const rel = wsRel(r, f); return rel !== null && WORKSPACE_JS_FILE.test(rel); })));
951
1046
  const wideCrates = wideWs.size ? (rustMap ? [...rustMap.keys()].filter((n) => wideWs.has(wsOf(n))) : listDir(path.join(contentRoot, "crates"))).filter((n) => !rustScope || rustScope.has(n)) : [];
952
1047
  if (wideWs.size && rustScope) notes.push(`workspace Rust change reaches ${wideCrates.length} crate(s) through the lock graph`);
953
- const crateNames = safeUnits([...new Set([...(rustMap ? files.map((f) => unitOf(rustMap, f)).filter(Boolean) : under("crates/").map((f) => f.split("/")[1])), ...wideCrates])].filter((n) => exists(path.join(contentRoot, rustDir(n), "Cargo.toml"))).sort(), "crate");
1048
+ // A source outside every unit that a unit compiles (externalInputs), and a shared input, reach their units; nothing else outside a
1049
+ // unit's directory does (a crate-per-workspace repository has no root manifest or lock to widen the scope).
1050
+ const externalUnits = rustMap && externalInputs ? Object.entries(externalInputs).flatMap(([dir, names]) => (files.some((f) => f === dir || f.startsWith(`${dir.replace(/\/+$/, "")}/`)) ? names : [])).filter((n) => rustMap.has(n)) : [];
1051
+ const sharedUnits = rustMap && files.some((f) => SHARED_RUST_SCOPE_FILE.test(f)) ? [...rustMap.keys()] : [];
1052
+ if (sharedUnits.length) notes.push(`a shared build input changed (${files.filter((f) => SHARED_RUST_SCOPE_FILE.test(f)).join(", ")}); every Rust unit is in scope`);
1053
+ const crateNames = safeUnits([...new Set([...(rustMap ? files.map((f) => unitOf(rustMap, f)).filter(Boolean) : under("crates/").map((f) => f.split("/")[1])), ...wideCrates, ...externalUnits, ...sharedUnits])].filter((n) => exists(path.join(contentRoot, rustDir(n), "Cargo.toml"))).sort(), "crate");
954
1054
  // The workspace a changed file belongs to is the deepest one that holds it (a root workspace holds only what no nested one does).
955
1055
  const wsOfFile = (f) => { let best = null; for (const ws of workspaces) if ((ws === "." || f.startsWith(`${ws}/`)) && (best === null || ws.length > best.length)) best = ws; return best; };
956
1056
  const configWs = new Set();
@@ -1002,7 +1102,7 @@ export function planScans({
1002
1102
  // A non-empty list only: `[^\]\s]` is one character that is neither whitespace nor the closing bracket, so `ignore = []` followed by
1003
1103
  // a later `]` (the next table header) is not an ignore (Pulse, 2026-10-27: `\S` matched the `]` itself).
1004
1104
  const ignores = ["deny.toml", ".cargo/audit.toml"].some((f) => { try { return /\bignore\s*=\s*\[[^\]]*[^\]\s][^\]]*\]/s.test(readFile(path.join(contentRoot, dir, f))); } catch { return false; } });
1005
- if (ignores && !(dir === "" && exists(path.join(contentRoot, "ci/advisory-exceptions.json")))) add({ id: `advisory-exceptions${dir ? `:${dir}` : ""}`, class: A, internal: "advisory-exceptions", dir, exceptionsFile: ".rightkit/advisory-exceptions.json" });
1105
+ if (ignores && !(dir === "" && exists(path.join(contentRoot, "ci/advisory-exceptions.json")))) add({ id: `advisory-exceptions${dir ? `:${wsLabel(ws)}` : ""}`, class: A, internal: "advisory-exceptions", dir, exceptionsFile: ".rightkit/advisory-exceptions.json" });
1006
1106
  }
1007
1107
  // Like generated-docs, planned only when a changed file is one of the contract's inputs or projections. The generator runs from
1008
1108
  // the trusted scanner checkout (the import below); the exported contract and projections are data it checks.
@@ -1085,6 +1185,7 @@ export function planScans({
1085
1185
  ...(trusted ? {} : { deferred: "requires a trusted-root run: the generator is repository code and no separate trusted checkout provides it; the content root's copy is never executed" }),
1086
1186
  });
1087
1187
  }
1188
+ const clippyEntries = [];
1088
1189
  for (const name of crateNames) {
1089
1190
  // Feature sets exactly as CI's gate runs them (scripts/ci/gate.mjs, ci/gate.config.json rust.<crate>):
1090
1191
  // clippy per `featureSets` (default features when absent); check per `checkFeatureSets` (default
@@ -1097,16 +1198,25 @@ export function planScans({
1097
1198
  // check runs only when no clippy set covers default features.
1098
1199
  if (defaultCheck) add({ id: rustScanId.check(name), class: B, ...cargoIn(ws, ...checkArgs(crate, { locked: true })) });
1099
1200
  checkSets.forEach((set, i) => add({ id: rustScanId.checkFeatures(name, i), class: B, ...cargoIn(ws, ...checkArgs(crate, { features: set, locked: true })) }));
1100
- lintSets.forEach((set, i) => add({ id: rustScanId.clippy(name, lintSets.length, i), class: B, ...cargoIn(ws, ...clippyArgs(crate, { features: set, locked: true })) }));
1201
+ if (clippyBatching) lintSets.forEach((set) => clippyEntries.push({ name, crate, ws, set, count: lintSets.length, env: targetDirs?.[ws] ?? null }));
1202
+ else lintSets.forEach((set, i) => add({ id: rustScanId.clippy(name, lintSets.length, i), class: B, ...cargoIn(ws, ...clippyArgs(crate, { features: set, locked: true })) }));
1101
1203
  // rustdoc flags go after `--` for this crate's library only: the broker owns RUSTDOCFLAGS and refuses
1102
1204
  // a request that sets it. A crate with no library has no API docs to check.
1103
1205
  if (exists(path.join(contentRoot, rustDir(name), "src", "lib.rs"))) {
1104
1206
  add({ id: rustScanId.doc(name), class: B, ...cargoIn(ws, "rustdoc", "-p", crate, "--lib", ...lintSets[0], "--locked", "--", ...DOC_RUSTDOC_ARGS) });
1105
1207
  }
1106
1208
  }
1209
+ if (clippyBatching && clippyEntries.length) {
1210
+ // One Clippy scan per batch (row 8.6): the independent batches first, the sidecar-bound ones after them (row 8.8; the compile lane
1211
+ // drains in plan order). The reason a configuration stays separate is recorded in the plan notes.
1212
+ const sidecarBound = new Set(clippyEntries.filter((e) => sidecarBoundCrate({ dir: path.join(contentRoot, rustDir(e.name)), exists, readFile }).bound).map((e) => e.name));
1213
+ const plan = clippyBatchPlan(clippyEntries, { solo: shardSolo, separate: clippySeparate, sidecarBound, toolchain: cargoCommand.join(" ") });
1214
+ for (const b of plan.batches) add({ id: b.id, class: B, batch: { units: b.units, order: b.order, reason: b.reason }, ...cargoIn(b.ws, ...clippyArgs(b.crates, { features: b.set, locked: true })) });
1215
+ notes.push(...plan.notes);
1216
+ }
1107
1217
  // Reverse dependents: a workspace crate that is not in scope but whose lock closure reaches one that is must still compile.
1108
1218
  // A unit only depends on units of its own Cargo workspace (its own lock), so each workspace is judged by its own lock.
1109
- const inScope = new Set(crateNames);
1219
+ const inScope = new Set(crateNames); const planned = new Set();
1110
1220
  for (const ws of [...new Set(crateNames.map(wsOf))].sort()) {
1111
1221
  const lock = lockOf(ws);
1112
1222
  if (!lock) continue;
@@ -1114,7 +1224,13 @@ export function planScans({
1114
1224
  const scoped = crateNames.filter((n) => wsOf(n) === ws).map(crateOf);
1115
1225
  const workspace = (rustMap ? [...rustMap.keys()] : listDir(path.join(contentRoot, "crates"))).filter((n) => wsOf(n) === ws);
1116
1226
  const dependents = safeUnits(workspace.filter((n) => !inScope.has(n) && exists(path.join(contentRoot, rustDir(n), "Cargo.toml")) && scoped.some((t) => reach(lock, crateOf(n), memo).has(t))).sort(), "crate");
1117
- for (const name of dependents) add({ id: `check-dependent:${name}`, class: B, ...cargoIn(ws, ...checkArgs(crateOf(name), { locked: true })) });
1227
+ for (const name of dependents) { planned.add(name); add({ id: `check-dependent:${name}`, class: B, ...cargoIn(ws, ...checkArgs(crateOf(name), { locked: true })) }); }
1228
+ }
1229
+ // Across Cargo workspaces (one workspace and lock per crate): a unit that path-depends on a unit in scope, transitively, is judged by
1230
+ // the manifests' path dependencies, since no lock holds both.
1231
+ if (typeof crateDependents === "function" && rustMap && crateNames.length) {
1232
+ const across = [...crateDependents(new Set(crateNames))].filter((n) => !inScope.has(n) && !planned.has(n) && rustMap.has(n) && exists(path.join(contentRoot, rustDir(n), "Cargo.toml"))).sort();
1233
+ for (const name of safeUnits(across, "crate")) add({ id: `check-dependent:${name}`, class: B, ...cargoIn(wsOf(name), ...checkArgs(crateOf(name), { locked: true })) });
1118
1234
  }
1119
1235
  // A linter config in the unit's directory or any ancestor of it (a repository-level config covers every unit, as the tools look it up).
1120
1236
  const hasConfig = (dir, names) => ancestorDirsOf(dir).some((d) => names.some((f) => exists(path.join(liveRoot, inDir(d, f)))));
@@ -1804,7 +1920,11 @@ export async function runScans(plan, options = {}) {
1804
1920
  const ctx = { deps, contentRoot, read: (f) => deps.readFile(path.join(contentRoot, f)) };
1805
1921
  const toolVersions = {}; const probed = new Map(); const advisoryDbs = new Map();
1806
1922
  const results = [];
1807
- // options.cache = { read(key), write(key, row), digest(scan) => string | null } (null: not cacheable, which is how the live class A checks opt out).
1923
+ // The gate-owned cargo ledger of a wrapped snapshot compile (options.cargoLedger = { dir }): every call the wrapper decided, filed
1924
+ // under its scan's token. `ledgerSeen` is the run's one-use record of carriers and request ids across every scan.
1925
+ const cargoLedger = options.cargoLedger?.dir ? options.cargoLedger : null;
1926
+ const claimedTokens = new Set(); const ledgerSeen = { nonces: new Set(), ids: new Set() };
1927
+ // options.cache ={ read(key), write(key, row), digest(scan) => string | null } (null: not cacheable, which is how the live class A checks opt out).
1808
1928
  const cache = options.cache && typeof options.cache.read === "function" && typeof options.cache.write === "function" && typeof options.cache.digest === "function" ? options.cache : null;
1809
1929
  const cacheHits = [];
1810
1930
  let classAFailed = false;
@@ -1844,7 +1964,11 @@ export async function runScans(plan, options = {}) {
1844
1964
  // lease, open.
1845
1965
  const probeMs = Number.isFinite(options.probeTimeoutMs) && options.probeTimeoutMs > 0 ? options.probeTimeoutMs : PROBE_TIMEOUT_MS;
1846
1966
  const probeSignal = signal ? AbortSignal.any([signal, AbortSignal.timeout(probeMs)]) : AbortSignal.timeout(probeMs);
1847
- const r = await deps.spawn(command, PROBES[tool] ?? ["--version"], { cwd, env: { ...deps.env, ...PNPM_NO_HOOKS_ENV, COREPACK_ENABLE_AUTO_PIN: "0" }, signal: probeSignal });
1967
+ // The gate's own version probes reach the wrapper too (cargo is first on PATH): they are filed under the probe token, which the
1968
+ // gate claims and checks like a scan's.
1969
+ const probeLedgerEnv = cargoLedger ? { [SCAN_TOKEN_ENV]: PROBE_LEDGER_TOKEN } : {};
1970
+ if (cargoLedger) claimedTokens.add(PROBE_LEDGER_TOKEN);
1971
+ const r = await deps.spawn(command, PROBES[tool] ?? ["--version"], { cwd, env: { ...deps.env, ...PNPM_NO_HOOKS_ENV, COREPACK_ENABLE_AUTO_PIN: "0", ...probeLedgerEnv }, signal: probeSignal });
1848
1972
  if (!r || r.error || r.status !== 0) outcome = { ok: false, why: r?.error?.message ?? `exit ${r?.status}` };
1849
1973
  else {
1850
1974
  const text = String(r.stdout || r.stderr || "");
@@ -1879,7 +2003,7 @@ export async function runScans(plan, options = {}) {
1879
2003
  try { onResult(row, { done, total, seconds: Math.round((Date.now() - started) / 1000) }); } catch { /* reporting never fails a scan */ }
1880
2004
  return row;
1881
2005
  }
1882
- // Class A may run concurrently. Every class B lane runs serially: tests and docs can compile too, and one checkout gets one build.
2006
+ // Class A may run concurrently. Class B runs one scan per component at a time, `classBJobs` components at once (below).
1883
2007
  const aScans = ordered.filter((s) => s.class === SCAN_CLASS.A);
1884
2008
  const bScans = ordered.filter((s) => s.class === SCAN_CLASS.B);
1885
2009
  // Class A scans are independent content checks (and short broker calls: metadata, package list, deny, audit): they run
@@ -1928,7 +2052,25 @@ export async function runScans(plan, options = {}) {
1928
2052
  const slots = new Array(bScans.length);
1929
2053
  const lanes = { compile: [], doc: [], tests: [], quiet: [] };
1930
2054
  bScans.forEach((s, i) => lanes[laneOf(s)].push(i));
1931
- const drain = async (queue, extraEnv = null) => { while (queue.length) { const i = queue.shift(); slots[i] = await runOne(bScans[i], extraEnv); } };
2055
+ // RightKit is many components, never one build (Adrian, 2026-10-29; rightkit#1285): class B scans of different components run
2056
+ // concurrently, `classBJobs` at a time (the broker queues beyond its build capacity). A component is the directory a scan runs in
2057
+ // (`cwd`, its own Cargo workspace and output directory); scans of one component stay in plan order, because they share its
2058
+ // output directory and cargo locks it.
2059
+ const bJobs = Math.max(1, Number(options.classBJobs ?? 1) || 1);
2060
+ const drain = async (queue, extraEnv = null) => {
2061
+ const busy = new Set(); const running = new Set();
2062
+ const keyOf = (i) => bScans[i].cwd ?? ".";
2063
+ while (queue.length || running.size) {
2064
+ const at = running.size < bJobs ? queue.findIndex((i) => !busy.has(keyOf(i))) : -1;
2065
+ if (at === -1) { await Promise.race(running); continue; }
2066
+ const [i] = queue.splice(at, 1); const key = keyOf(i); busy.add(key);
2067
+ // A scan that throws becomes a failed row: with several components in flight, a rejection must not abandon the others.
2068
+ const job = runOne(bScans[i], extraEnv)
2069
+ .catch((error) => ({ id: bScans[i].id, class: bScans[i].class, command: bScans[i].command, tool: bScans[i].tool ?? null, toolVersion: null, status: "fail", code: "SCAN_ERROR", detail: String(error?.message ?? error) }))
2070
+ .then((row) => { slots[i] = row; }).finally(() => { busy.delete(key); running.delete(job); });
2071
+ running.add(job);
2072
+ }
2073
+ };
1932
2074
  await drain(lanes.compile);
1933
2075
  await drain(lanes.doc);
1934
2076
  await drain(lanes.tests);
@@ -2119,14 +2261,19 @@ export async function runScans(plan, options = {}) {
2119
2261
  // CARGO_TARGET_DIR gets an empty directory of its own as one. Every cargo reached through the wrapper is bound elsewhere; only
2120
2262
  // a cargo that bypassed it (an absolute path, `rustup run`, a reset PATH) can write here, and the scan then fails below.
2121
2263
  let sentinel = null;
2122
- if (options.cargoSentinel && !Object.hasOwn(s.env ?? {}, "CARGO_TARGET_DIR")) {
2264
+ // (A scan that is itself the carried broker request is bound by the broker: no sentinel, so the client's observed environment is the scan's own.)
2265
+ if (options.cargoSentinel && !viaSnapshotBroker && !Object.hasOwn(s.env ?? {}, "CARGO_TARGET_DIR")) {
2123
2266
  sentinel = path.join(options.cargoSentinel, sha256(s.id).slice(0, 16));
2124
2267
  fs.rmSync(sentinel, { recursive: true, force: true });
2125
2268
  fs.mkdirSync(sentinel, { recursive: true });
2126
2269
  }
2127
2270
  const sentinelEnv = sentinel ? { CARGO_TARGET_DIR: sentinel, CARGO_BUILD_TARGET_DIR: sentinel } : {};
2271
+ // Every cargo call the wrapper decides during this scan is filed under this token in the gate's ledger.
2272
+ const ledgerToken = cargoLedger ? scanTokenOf(s.id) : null;
2273
+ if (ledgerToken) claimedTokens.add(ledgerToken);
2274
+ const ledgerEnv = ledgerToken ? { [SCAN_TOKEN_ENV]: ledgerToken } : {};
2128
2275
  try {
2129
- try { r = await deps.spawn(p.command, finalArgs, { cwd: path.join(rootDir(s.root), s.cwd ?? ""), env: { ...deps.env, ...sentinelEnv, ...s.env, ...(extraEnv ?? {}), ...guardEnv, ...(carrier ? carrier.made.env : {}) }, signal: scanSignal, timeout: limitMs }); } finally { clearTimeout(clock); advisory?.cleanup?.(); }
2276
+ try { r = await deps.spawn(p.command, finalArgs, { cwd: path.join(rootDir(s.root), s.cwd ?? ""), env: { ...deps.env, ...sentinelEnv, ...s.env, ...(extraEnv ?? {}), ...guardEnv, ...ledgerEnv, ...(carrier ? carrier.made.env : {}) }, signal: scanSignal, timeout: limitMs }); } finally { clearTimeout(clock); advisory?.cleanup?.(); }
2130
2277
  if (signal?.aborted && (!r || r.error || r.status !== 0)) return { ...row, status: "not-run", code: "CANCELLED", detail: "cancelled while running" };
2131
2278
  if ((timer.signal.aborted || r?.error?.code === "ETIMEDOUT") && (!r || r.error || r.status !== 0)) return { ...row, status: "fail", code: "TIMEOUT", detail: `${s.id} ran longer than ${Math.round(limitMs / 60000)} min and was killed with its process tree; a scan that does not finish fails` };
2132
2279
  if (!r || r.error) { status = "fail"; code = "SPAWN_ERROR"; detail = r?.error?.message ?? "no result"; }
@@ -2139,6 +2286,19 @@ export async function runScans(plan, options = {}) {
2139
2286
  try { written = fs.readdirSync(sentinel); } catch (e) { written = [`(unreadable: ${e.message})`]; }
2140
2287
  if (written.length) { status = "fail"; code = "RK_CARGO_UNBOUND"; detail = `${s.id}: a cargo that bypassed the gate's wrapper wrote ${written.slice(0, 5).join(", ")} into the unbound sentinel ${sentinel}; with several Cargo workspaces no output directory can be inferred for it, so the scan proves nothing about where its outputs went`; }
2141
2288
  }
2289
+ // The wrapper's ledger (rows 7.10 and 7.24): a refused, unverified or unfinished cargo call fails this scan even when the code
2290
+ // under test ignored cargo's exit status; on a broker host each call's signed receipt is verified again here, by the gate.
2291
+ if (ledgerToken) {
2292
+ let problems;
2293
+ try {
2294
+ const ledger = readLedger(cargoLedger.dir, ledgerToken);
2295
+ problems = [...ledger.problems, ...(deps.snapshotBroker ? await verifyBrokerLedger(ledger.entries, deps.snapshotBroker, { seen: ledgerSeen }) : [])];
2296
+ } catch (e) { problems = [`the cargo ledger cannot be read (${e.message})`]; }
2297
+ if (problems.length) {
2298
+ const text = `${s.id}: the gate's cargo ledger records ${problems.length} cargo call(s) that did not complete as a verified success: ${problems.slice(0, 5).join("; ")}`;
2299
+ if (status === "pass") { status = "fail"; code = "RK_CARGO_LEDGER"; detail = text; } else detail = `${detail ? `${detail}; ` : ""}${text}`;
2300
+ }
2301
+ }
2142
2302
  if (s.advisoryDb === "deny" && code !== "RK_CARGO_UNBOUND") {
2143
2303
  const denyError = `${r.stdout ?? ""}\n${r.stderr ?? ""}`.split(/\r?\n/).find((line) => /\[ERROR\]/.test(line));
2144
2304
  if (r.truncated) { status = "fail"; code = "DENY_ERROR"; detail = "cargo-deny output exceeded the kept tail, so the absence of an [ERROR] line cannot be shown"; }
@@ -2150,7 +2310,7 @@ export async function runScans(plan, options = {}) {
2150
2310
  try {
2151
2311
  const ref = readRequestRef(path.join(carrier.refDir, "request.json"));
2152
2312
  const receipt = await deps.snapshotBroker.receiptOf(ref.requestId);
2153
- const { evidence } = verifySnapshotReceipt(receipt, { pins: deps.snapshotBroker.pins, jobId: ref.requestId, carrier: carrier.made.carrier, argv: carrier.cargoArgv, workspace: ref.workspace, observedEnv: observedEnvOf({ ...deps.env, ...s.env, ...(extraEnv ?? {}) }, deps.platform ?? process.platform) });
2313
+ const { evidence } = verifySnapshotReceipt(receipt, { pins: deps.snapshotBroker.pins, jobId: ref.requestId, carrier: carrier.made.carrier, argv: carrier.cargoArgv, workspace: ref.workspace, observedEnv: observedEnvOf({ ...deps.env, ...s.env, ...(extraEnv ?? {}) }, deps.platform ?? process.platform), checkout: deps.snapshotBroker.checkout, outputRoots: deps.snapshotBroker.outputRoots, platform: deps.snapshotBroker.platform ?? deps.platform ?? process.platform });
2154
2314
  row.snapshotEvidence = { requestId: ref.requestId, outputDir: evidence.outputDir, treeAtStart: evidence.snapshot.treeAtStart, treeAtExit: evidence.snapshot.treeAtExit };
2155
2315
  } catch (e) {
2156
2316
  status = "fail"; code = "SNAPSHOT_EVIDENCE"; detail = `${s.id}: the broker's snapshot evidence did not verify (${e.code ?? "error"}: ${e.message})`;
@@ -2181,12 +2341,27 @@ export async function runScans(plan, options = {}) {
2181
2341
  return { ...row, status, ...(code ? { code } : {}), ...(detail ? { detail } : {}) };
2182
2342
  }
2183
2343
 
2344
+ // Cargo calls the gate cannot attribute to a scan (the code under test cleared or replaced the scan token): never skipped.
2345
+ if (cargoLedger) {
2346
+ // The gate's own version probes: anything but a completed call (a refused probe) fails the run too.
2347
+ try {
2348
+ const probeProblems = readLedger(cargoLedger.dir, PROBE_LEDGER_TOKEN).problems;
2349
+ if (probeProblems.length) results.push({ id: "cargo-ledger:tool-probe", class: SCAN_CLASS.B, command: "cargo ledger", tool: null, toolVersion: null, status: "fail", code: "RK_CARGO_LEDGER", detail: `the gate's tool version probes reached the wrapper and did not complete: ${probeProblems.slice(0, 5).join("; ")}` });
2350
+ } catch { /* an unreadable probe ledger is named by the unclaimed check only when it holds records */ }
2351
+ let unclaimed;
2352
+ try { unclaimed = unclaimedLedgers(cargoLedger.dir, claimedTokens); } catch (e) { unclaimed = [`unreadable (${e.message})`]; }
2353
+ if (unclaimed.length) {
2354
+ const calls = [];
2355
+ for (const token of unclaimed) { try { for (const e of readLedger(cargoLedger.dir, token).entries) calls.push(`cargo ${(e.argv ?? []).join(" ")} (${e.status}, from ${e.cwd})`.trim()); } catch { /* named by its token below */ } }
2356
+ results.push({ id: "cargo-ledger", class: SCAN_CLASS.B, command: "cargo ledger", tool: null, toolVersion: null, status: "fail", code: "RK_CARGO_LEDGER", detail: `cargo calls were filed under ${unclaimed.length} scan token(s) no scan owns (${unclaimed.slice(0, 3).join(", ")}), so they cannot be tied to a scan (${SCAN_TOKEN_ENV} was cleared, or the call came from outside every scan): ${calls.slice(0, 5).join("; ") || "unreadable"}` });
2357
+ }
2358
+ }
2184
2359
  const cancelled = signal?.aborted === true;
2185
2360
  return {
2186
2361
  schema: SCAN_RESULT_SCHEMA, ok: !cancelled && results.every((r) => r.status === "pass"), complete: !cancelled && results.every((r) => r.status === "pass"), classes, checker, ...(cancelled ? { cancelled: true } : {}),
2187
2362
  inputDigest: plan.inputDigest ?? null,
2188
2363
  deferred: results.filter((r) => r.status === "deferred").map((r) => r.id),
2189
- scans: results, toolVersions, cacheHits: [...cacheHits].sort(), notes: plan.notes ?? [],
2364
+ scans: results, toolVersions, cacheHits: [...cacheHits].sort(), notes: [...(plan.notes ?? []), ...(cargoLedger ? [UNOBSERVED_NESTED_CARGO_NOTE] : [])],
2190
2365
  scanSetDigest: digestScanSet(results),
2191
2366
  classDigests: { A: digestScanSet(results.filter((r) => r.class === "A")), B: digestScanSet(results.filter((r) => r.class === "B")) },
2192
2367
  };
package/development.mjs CHANGED
@@ -7,6 +7,7 @@ import path from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { defaultReleaseConfigFile, importReleaseConfig } from "./release-config.mjs";
9
9
  import { assertPrimaryReleaseCheckout } from "./release-invocation.mjs";
10
+ import { readSourceSplit } from "./source-split.mjs";
10
11
  import { assertReleaseExecutionAllowed } from "./execution-policy.mjs";
11
12
  import { acquireManagedRunLease, directCargoEnv, directPlatform, managedRuntimeClient, managedRuntimeCommand, runCargoSync } from "./managed-cargo.mjs";
12
13
  import { BUNDLE_MARKERS, bundleMarkerBuffer, readTauriBundleMarkers } from "./tauri-bundle-marker.mjs";
@@ -107,7 +108,10 @@ export function runDevelopment({ root, platform, config, buildOnly = false, dryR
107
108
  // iOS: --dry-run validates the config and prints the plan on any host; everything else needs macOS with Xcode.
108
109
  if (!(platform === "ios" && dryRun)) assertNativeDevelopmentHost(platform);
109
110
  const target = validateDevelopmentConfig(config, platform, { root });
110
- const repoRoot = git(root, ["rev-parse", "--show-toplevel"]);
111
+ // Inside the door's source split `root` is the snapshot of a commit (no git directory): the repository is the checkout the door
112
+ // ran in, whose primary-checkout rule and execution policy still apply, and the commit is the split's.
113
+ const split = readSourceSplit({ env, cwd: root });
114
+ const repoRoot = split ? split.checkout : git(root, ["rev-parse", "--show-toplevel"]);
111
115
  assertPrimaryReleaseCheckout(repoRoot, env);
112
116
  if (dryRun && uiTests) {
113
117
  if (!target.uiTests) throw new Error("development.targets.ios.uiTests is not declared: list the journeys in uiTests.onlyTesting");
@@ -147,10 +151,11 @@ export function runDevelopment({ root, platform, config, buildOnly = false, dryR
147
151
  // Linux: the declared files become one tarball per architecture plus the digest manifest a guest link pins (rightkit#1230).
148
152
  // Only from a clean checkout: an artifact others install must name the exact commit it was built from.
149
153
  if (platform === "linux" && target.archive && !dryRun) {
150
- if (git(repoRoot, ["status", "--porcelain", "--untracked-files=normal"])) throw new Error("linux artifacts are packed only from a clean checkout: commit first, so the manifest names the exact source");
154
+ // A split's snapshot is exactly its commit (the door verifies it at the start and end of the stage); the checkout may hold anything.
155
+ if (!split && git(repoRoot, ["status", "--porcelain", "--untracked-files=normal"])) throw new Error("linux artifacts are packed only from a clean checkout: commit first, so the manifest names the exact source");
151
156
  let version;
152
157
  try { version = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")).version; } catch { version = undefined; }
153
- packLinuxArtifacts({ root, archive: validateLinuxArchive(target.archive), app: target.archive.name, version, commit: git(repoRoot, ["rev-parse", "HEAD"]), run });
158
+ packLinuxArtifacts({ root, archive: validateLinuxArchive(target.archive), app: target.archive.name, version, commit: split ? split.commit : git(repoRoot, ["rev-parse", "HEAD"]), run });
154
159
  }
155
160
  if (buildOnly || !target.install) return { built: true, installed: false, ...(staged.length ? { staged } : {}) };
156
161
  if (iosPrepared) return { built: true, installed: true, ios: installIosDevelopment(iosPrepared.install, { root, env: childEnv, run }) };
@@ -361,7 +366,11 @@ function execute(command, { root, env, dryRun, run, label, door = null }) {
361
366
  if (!inside(root, cwd)) throw new Error(`development ${label} cwd must stay inside app root`);
362
367
  const args = (command.args ?? []).map(String);
363
368
  if (dryRun) return { cmd: command.cmd, args, cwd };
364
- const resolved = resolveCommand(command.cmd, args, { root, env });
369
+ // A pnpm step through the door is the broker's rightkit-pnpm job (or plain pnpm on a direct host), launched by name: the door never uses
370
+ // the node entry resolveCommand would find, so resolving pnpm's JavaScript CLI here would only fail a host that does not need it.
371
+ // The door's own `pnpm exec right-release` forms still resolve to this package's right-release.
372
+ const ownReleaseStep = (args[0] === "exec" && args[1] === "right-release") || args[0] === "right-release";
373
+ const resolved = door && command.cmd === "pnpm" && !ownReleaseStep ? { command: "pnpm", args } : resolveCommand(command.cmd, args, { root, env });
365
374
  // Every dev build step goes through the one door (build-launch.mjs): the door's own right-release stays in process order, a pnpm step
366
375
  // is the rightkit-pnpm broker job (by its own name, not the node entry resolveCommand finds), anything else as resolved.
367
376
  // A configured command can run code regardless of its label, so installation commands use the door too.