@rightkit/release 0.6.8 → 0.6.10

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 (52) hide show
  1. package/README.md +25 -9
  2. package/admission-files.mjs +1 -1
  3. package/app-check.mjs +94 -20
  4. package/assemble-app-command.mjs +151 -0
  5. package/assembled-app.mjs +445 -0
  6. package/bin/darwin/rightkit-swap +0 -0
  7. package/bin/darwin/rightkit-swap.sign.json +6 -0
  8. package/bin/rightkit-gate.mjs +928 -82
  9. package/build-launch.mjs +88 -0
  10. package/build-ownership.mjs +1231 -0
  11. package/build-release.mjs +2 -1
  12. package/cargo-contract.mjs +3 -1
  13. package/cargo-target-shim.mjs +166 -0
  14. package/class-a-launch-guard.mjs +126 -0
  15. package/cli/right-release.mjs +3 -1
  16. package/codesign-shim.mjs +11 -1
  17. package/deterministic-scans.mjs +807 -56
  18. package/development-signing.mjs +4 -1
  19. package/development.mjs +110 -20
  20. package/door-policy.mjs +61 -6
  21. package/gate-executor.mjs +14 -2
  22. package/gate-stages.mjs +15 -5
  23. package/heavy-command.mjs +3 -2
  24. package/install-app.mjs +11 -2
  25. package/install-assembled-app.mjs +423 -0
  26. package/ios-dev.mjs +70 -3
  27. package/linux-artifacts.mjs +93 -0
  28. package/local-publish.mjs +161 -26
  29. package/managed-cargo.mjs +47 -5
  30. package/managed-root-guard.mjs +34 -0
  31. package/native-helpers.mjs +355 -0
  32. package/package-recipe-targets.mjs +52 -7
  33. package/package.json +4 -3
  34. package/pipeline-receipts.mjs +302 -302
  35. package/project-type.mjs +2 -2
  36. package/provenance-verify.mjs +115 -0
  37. package/publish-cargo.mjs +8 -6
  38. package/qa-admission-policy.json +10 -1
  39. package/recipe-build.mjs +73 -5
  40. package/release-config.mjs +1 -1
  41. package/release.mjs +15 -5
  42. package/rightkit-versions.json +13 -7
  43. package/snapshot-check.mjs +306 -0
  44. package/snapshot-git.mjs +166 -0
  45. package/snapshot-request.mjs +161 -0
  46. package/snapshot-root-config.mjs +76 -0
  47. package/snapshot.mjs +856 -0
  48. package/stage-commands.mjs +60 -0
  49. package/stage-sidecar.mjs +57 -7
  50. package/test-command.mjs +17 -4
  51. package/unit-gate.mjs +259 -0
  52. package/web-build.mjs +38 -6
@@ -23,11 +23,14 @@ import os from "node:os";
23
23
  import path from "node:path";
24
24
  import { fileURLToPath, pathToFileURL } from "node:url";
25
25
  import { spawn as spawnChild, spawnSync } from "./background-process.mjs";
26
+ import { spawnUnderOwnership } from "./build-ownership.mjs";
26
27
  import { checkAdvisoryExceptions } from "./advisory-exceptions.mjs";
27
28
  import { canonicalJson, digestScanSet, requiredScanIds, rustCratePlan, rustScanId } from "./scan-digest.mjs";
28
29
  import { reach } from "./lock-graph.mjs";
29
30
  import { cargoManifestState } from "./cargo-manifest-text.mjs";
31
+ import { LAUNCH_GUARD_IMPORT } from "./class-a-launch-guard.mjs";
30
32
  import { advisoryCacheRoot, ensureAdvisoryDb, prepareDenyOverlay, readAdvisoryPin } from "./advisory-db.mjs";
33
+ import { observedEnvOf, readRequestRef, verifySnapshotReceipt, writeSnapshotRequest } from "./snapshot-request.mjs";
31
34
 
32
35
  export const SCAN_RESULT_SCHEMA = "rightkit.deterministic-scans/1";
33
36
  /**
@@ -51,10 +54,15 @@ export const CLASS_A_SCAN_PREFIXES = Object.freeze([
51
54
  "rustfmt", "node-check", "json-parse", "toml-parse", "yaml-parse", "ownership-catalog",
52
55
  "actionlint", "shellcheck", "pnpm-lockfile", "pnpm-audit", "cargo-metadata", "cargo-deny", "cargo-audit", "cargo-package-list",
53
56
  "changelog-version", "license", "secret-scan", "path-policy", "advisory-exceptions", "suite-api",
57
+ // Everything mechanical and deterministic that does not compile is class A (Adrian, 2026-10-27): typecheck, lint, generated docs.
58
+ "tsc", "eslint", "biome", "generated-docs",
54
59
  ]);
60
+ // Class B includes compiles and tests that execute project code.
55
61
  export const CLASS_B_SCAN_PREFIXES = Object.freeze([
56
- "clippy", "check", "check-all-features", "check-features", "check-dependent", "doc", "tsc", "eslint", "biome", "swift-build", "test", "generated-docs",
62
+ "clippy", "check", "check-all-features", "check-features", "check-dependent", "doc", "swift-build",
57
63
  ]);
64
+ // Test scripts can invoke a compiler indirectly, so all tests run after Class A.
65
+ export const TEST_SCAN_CLASS = Object.freeze({ "test:rust": "B", "test:rust-serial": "B", "test:npm": "B", "test:scripts-ci": "B" });
58
66
 
59
67
  /**
60
68
  * Rollout switch for the CI integration (ci/gate.config.json: { "deterministicScans": { "enabled": true } }).
@@ -92,8 +100,14 @@ export function stripJsonComments(text) {
92
100
  }
93
101
 
94
102
  /** "A", "B" or null for an id such as "clippy:rightkit-fs". */
95
- export function scanClassOf(id) {
96
- const prefix = String(id).split(":")[0];
103
+ export function scanClassOf(id, scan = null) {
104
+ const parts = String(id).split(":");
105
+ const prefix = parts[0];
106
+ if (prefix === "test") {
107
+ const kind = TEST_SCAN_CLASS[`test:${parts[1]}`] ?? null;
108
+ if (kind === "A" && scan?.brokerCall) return SCAN_CLASS.B;
109
+ return kind === null ? null : SCAN_CLASS[kind];
110
+ }
97
111
  if (CLASS_A_SCAN_PREFIXES.includes(prefix)) return SCAN_CLASS.A;
98
112
  if (CLASS_B_SCAN_PREFIXES.includes(prefix)) return SCAN_CLASS.B;
99
113
  return null;
@@ -224,6 +238,49 @@ const PNPM_NO_HOOKS_ENV = Object.freeze({ npm_config_ignore_pnpmfile: "true", np
224
238
  // fails in the tool or the test with its own error. The `pnpm_config_` spelling is the one pnpm 11.25 honours for this setting
225
239
  // (`npm_config_verify_deps_before_run=false` still aborted, checked on a stale tree), the lowercase spelling PNPM_NO_HOOKS_ENV uses for its settings.
226
240
  const PNPM_RUN_ENV = Object.freeze({ pnpm_config_verify_deps_before_run: "false" });
241
+
242
+ // The placeholder a tsc row passes as its project when its package depends on a workspace sibling (planScans; runScans replaces it).
243
+ export const TSC_PROJECT = "{tscProject}";
244
+ const DIST_TYPES = /^\.\/dist\/(.+)\.d\.ts$/;
245
+ /**
246
+ * Writes the project a tsc row checks through: it extends the package's own tsconfig (its include, exclude and options, resolved from
247
+ * there), emits nothing, widens rootDir to the checked tree, keeps the package's own @types lookup chain (typeRoots), and maps every
248
+ * exported entry point of each workspace sibling whose types
249
+ * are built output (`types: ./dist/<x>.d.ts`) to that sibling's committed source (`src/<x>.ts`, `.tsx`, `/index.ts`, `/index.tsx`).
250
+ * `workspace` is the siblings' tree-relative directories. Returns { dir, file }; the caller removes `dir`. Throws when a sibling's
251
+ * manifest cannot be read or an exported entry point has no committed source: such a sibling could only be checked against a build.
252
+ */
253
+ export function writeTscProject({ contentRoot, packageDir, workspace, tmpdir = os.tmpdir() }) {
254
+ const paths = {};
255
+ for (const rel of workspace) {
256
+ const dir = path.join(contentRoot, rel);
257
+ let manifest;
258
+ try { manifest = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8").replace(/^/, "")); }
259
+ catch (e) { throw new Error(`${rel}/package.json: ${e.message}`); }
260
+ if (typeof manifest.name !== "string" || path.resolve(dir) === path.resolve(packageDir)) continue;
261
+ const exported = manifest.exports && typeof manifest.exports === "object" ? Object.entries(manifest.exports) : [];
262
+ for (const [sub, target] of exported) {
263
+ const types = target && typeof target === "object" ? target.types : null;
264
+ const m = typeof types === "string" ? DIST_TYPES.exec(types) : null;
265
+ if (!m) continue;
266
+ const source = [`src/${m[1]}.ts`, `src/${m[1]}.tsx`, `src/${m[1]}/index.ts`, `src/${m[1]}/index.tsx`].map((f) => path.join(dir, f)).find((f) => fs.existsSync(f));
267
+ if (!source) throw new Error(`${manifest.name} exports ${sub} with types ${types}, and ${rel}/src has no committed source for it`);
268
+ paths[sub === "." ? manifest.name : `${manifest.name}${sub.slice(1)}`] = [source];
269
+ }
270
+ }
271
+ // TypeScript looks type libraries up (`types`, and every @types package by default) in node_modules/@types of the CONFIG's directory
272
+ // and its ancestors. The project lives outside the tree, so it names the chain the package's own tsconfig would have used.
273
+ const typeRoots = [];
274
+ for (let d = path.resolve(packageDir); ; d = path.dirname(d)) {
275
+ const at = path.join(d, "node_modules", "@types");
276
+ if (fs.existsSync(at)) typeRoots.push(at);
277
+ if (path.dirname(d) === d) break;
278
+ }
279
+ const out = fs.mkdtempSync(path.join(tmpdir, "rk-tsc-"));
280
+ const file = path.join(out, "tsconfig.json");
281
+ fs.writeFileSync(file, `${JSON.stringify({ extends: path.join(packageDir, "tsconfig.json"), compilerOptions: { noEmit: true, rootDir: path.resolve(contentRoot), typeRoots, paths } }, null, 2)}\n`);
282
+ return { dir: out, file };
283
+ }
227
284
  const CONFIG_CRATE_FILE = /(^|\/)Cargo\.(toml|lock)$/;
228
285
  const JS_TOOLING_FILE = /(^|\/)(package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml)$/;
229
286
  // Root files that change how EVERY crate (or package) builds: a change to one puts every unit in scope,
@@ -278,6 +335,26 @@ const PROBES = Object.freeze({
278
335
  * provision exactly this version; scripts/ci/scan-receipt.test.mjs holds them to it.
279
336
  */
280
337
  export const PINNED_SCAN_TOOLS = Object.freeze({ shellcheck: "0.11.0" });
338
+ /** Scans whose answer depends on a remote service at run time, not only on their inputs: `pnpm-audit` asks the registry's advisory
339
+ * service. A verdict that includes them is reusable only after they are re-run (`rightkit-gate live-scans`; GATE-CONTRACT §1, E18),
340
+ * and a red caused by one is never deterministic. Ids are `<prefix>` or `<prefix>:<root>`; rustright's REMOTE_SCANS mirrors this list. */
341
+ export const LIVE_SCAN_IDS = Object.freeze(["pnpm-audit"]);
342
+ export const isLiveScan = (id) => LIVE_SCAN_IDS.some((p) => id === p || String(id).startsWith(`${p}:`));
343
+ /** Scans whose FAILURE may come from the registry rather than their inputs: `pnpm-lockfile` (`install --frozen-lockfile --lockfile-only`)
344
+ * re-verifies lockfile entries against the registry when pnpm's cache is cold, so an outage fails it. Their pass is deterministic, but a
345
+ * red they cause is never a reusable deterministic verdict (rightkit#1257; Oracle review of 79c4427b). */
346
+ export const REGISTRY_SCAN_IDS = Object.freeze(["pnpm-lockfile"]);
347
+ export const isRegistryScan = (id) => REGISTRY_SCAN_IDS.some((p) => id === p || String(id).startsWith(`${p}:`));
348
+ /** Files per rustfmt scan: 120 paths of ~100 bytes stay under Windows' 32 KB command line with the tool and flags. */
349
+ export const RUSTFMT_BATCH = 120;
350
+
351
+ /** True when this module is the checked repository's own in-tree copy (packages/release of `contentRoot`), where its scripts/ live. */
352
+ export function inTreeGate(contentRoot, exists = fs.existsSync) {
353
+ const here = path.dirname(fileURLToPath(import.meta.url));
354
+ const real = (p) => { try { return fs.realpathSync(p); } catch { return path.resolve(p); } };
355
+ const own = real(path.resolve(here, "..", ".."));
356
+ return own === real(contentRoot) && exists(path.join(own, "scripts", "gen-suite-api.mjs"));
357
+ }
281
358
  // The version a probe's output names, for a tool whose first line is not its version (`shellcheck --version` opens with a banner).
282
359
  const PROBE_VERSION = Object.freeze({
283
360
  shellcheck: (text) => { const m = /^version:\s*(\S+)\s*$/m.exec(text); return m ? `shellcheck ${m[1]}` : null; },
@@ -410,6 +487,195 @@ export function scriptBrokerCall(scripts, entry = "test", { maxDepth = 8 } = {})
410
487
  return visit(String(entry), []);
411
488
  }
412
489
 
490
+ /**
491
+ * Whether the package script `entry` builds (emits) code before or while it tests: `tsc` without --noEmit, or a package-manager
492
+ * run of a `build` / `build:*` script in this package or another (`pnpm run build`, `pnpm --filter <pkg> build`), followed through
493
+ * the scripts it starts in the same package.json like scriptBrokerCall. Such a test is not a pre-compile check: it runs after
494
+ * class A, with the compile scans (Codex reviewer 2026-10-28). Returns `{ via, command }` or null.
495
+ */
496
+ export function scriptBuildCall(scripts, entry = "test", { maxDepth = 8 } = {}) {
497
+ if (scripts === null || typeof scripts !== "object" || Array.isArray(scripts)) return null;
498
+ const textOf = (name) => (Object.hasOwn(scripts, name) && typeof scripts[name] === "string" ? scripts[name].slice(0, SCRIPT_TEXT_LIMIT) : null);
499
+ const builds = (words) => {
500
+ let i = 0;
501
+ while (i < words.length && (ENV_ASSIGNMENT.test(words[i]) || COMMAND_WRAPPERS.has(path.posix.basename(words[i])) || PACKAGE_RUNNERS.has(path.posix.basename(words[i])))) i += 1;
502
+ const word = path.posix.basename(String(words[i] ?? "").replaceAll("\\", "/")).replace(/\.(exe|cmd|bat)$/i, "");
503
+ if (word === "tsc") return !words.slice(i + 1).some((w) => /^--?noEmit(=true)?$/i.test(w));
504
+ if (!PACKAGE_MANAGERS.has(word)) return false;
505
+ // pnpm's `-w` is `--workspace-root` (a flag); npm's `-w` is `--workspace <name>` (takes a value).
506
+ const rest = words.slice(i + 1); const values = new Set(word === "pnpm" ? ["--filter", "-F", "--dir", "-C"] : word === "npm" ? ["--workspace", "-w", "--prefix"] : ["--cwd", "--filter"]);
507
+ for (let j = 0; j < rest.length; j += 1) {
508
+ if (values.has(rest[j])) { j += 1; continue; }
509
+ if (rest[j].startsWith("-") || rest[j] === "run" || rest[j] === "run-script") continue;
510
+ return /^build(:|$)/.test(rest[j]);
511
+ }
512
+ return false;
513
+ };
514
+ const seen = new Set();
515
+ const visit = (name, via) => {
516
+ if (via.length >= maxDepth || seen.has(name)) return null;
517
+ seen.add(name);
518
+ for (const part of [`pre${name}`, name, `post${name}`]) {
519
+ const text = textOf(part);
520
+ if (text === null) continue;
521
+ for (const words of shellCommands(text)) {
522
+ if (builds(words)) return { via: [...via, part], command: words.join(" ").slice(0, 200) };
523
+ const found = inspectCommand(words, scripts);
524
+ if (found.run !== undefined) { const deeper = visit(found.run, [...via, part]); if (deeper) return deeper; }
525
+ }
526
+ }
527
+ return null;
528
+ };
529
+ return visit(String(entry), []);
530
+ }
531
+
532
+ // Cargo redirections on a reachable command line or script: an explicit manifest or output directory, or a working-directory change.
533
+ const CARGO_REDIRECT = /--manifest-path|--target-dir|(^|[\s;&|(])(CARGO_TARGET_DIR|CARGO_BUILD_TARGET_DIR)=|(^|[\s;&|(])(cd|pushd)\s/;
534
+
535
+ // The forms a test's path to cargo may take for its target directory to be bound (cargoReachOf). Everything else is unprovable.
536
+ // Tools that run no repository code and start no cargo: any arguments.
537
+ const REACH_SAFE_TOOLS = new Set(["tsc", "eslint", "prettier", "biome", "oxlint", "echo", "true"]);
538
+ // cargo options that move its manifest, workspace or output, or change its configuration (a short-option cluster holding C or Z too).
539
+ const CARGO_REDIRECT_ARG = /^(--manifest-path|--target-dir|--config)(=|$)|^-[A-Za-z]*[CZ]/;
540
+ // The cargo subcommands a bound test may run; anything else (an alias, cargo make, xtask, install, a plugin) is unprovable.
541
+ const CARGO_REACH_SUBCOMMANDS = new Set(["test", "build", "check", "clippy"]);
542
+ // Package-manager built-ins: never one of the package's scripts, whatever its package.json names (npm pack runs pack, not a script).
543
+ const PM_BUILTINS = new Set(["install", "i", "add", "ci", "pack", "publish", "version", "link", "unlink", "rebuild", "prune", "update", "up", "upgrade", "uninstall", "remove", "rm", "init", "create", "exec", "dlx", "x", "workspace", "workspaces", "config", "set", "import", "patch", "deploy", "audit", "outdated", "why", "store", "cache", "env", "setup", "self-update", "fetch"]);
544
+ // Characters a shell gives meaning outside quotes beyond the separators shellCommands splits on: an escape, a glob, a home or
545
+ // redirection, a brace or history expansion, a comment, and cmd.exe's ^ and %VAR%. A script holding one unquoted is unprovable.
546
+ const UNQUOTED_SHELL_META = new Set(["\\", "*", "?", "[", "]", "~", "<", ">", "{", "}", "!", "#", "^", "%"]);
547
+ /** The first character a shell would read specially outside quotes in `text`, or null. */
548
+ function unquotedShellMeta(text) {
549
+ let quote = null;
550
+ for (const c of String(text)) {
551
+ if (quote) { if (c === quote) quote = null; continue; }
552
+ if (c === '"' || c === "'") { quote = c; continue; }
553
+ if (UNQUOTED_SHELL_META.has(c)) return c;
554
+ }
555
+ return null;
556
+ }
557
+ // Package-manager options the walk resolves; any other option is unprovable.
558
+ const PM_DIR_OPTIONS = new Set(["--dir", "-C", "--prefix", "--cwd"]);
559
+ const PM_PACKAGE_OPTIONS = new Set(["--filter", "-F", "--filter-prod", "--workspace"]);
560
+ const PM_PLAIN_FLAGS = new Set(["-s", "--silent", "--if-present", "--color", "--no-color", "--stream"]);
561
+ const PM_VALUE_FLAGS = new Set(["--reporter", "--loglevel"]);
562
+
563
+ /**
564
+ * Where a package's `test` script makes cargo run, for a commit-snapshot compile (joint plan step 4c): `{ none: true }` when it reaches
565
+ * no cargo, `{ dir }` (repository-relative) when every cargo it reaches runs from that one directory, `{ dirs }` when from several (the
566
+ * caller requires them to be one workspace), or `{ unprovable }` with the reason. One CARGO_TARGET_DIR binds the whole test.
567
+ *
568
+ * Fail-closed allowlist (root reviews of 431fbeae, f883d5ca, 15300d82): a test is bound only when every command on every path it
569
+ * reaches (each script with its pre/post hooks) parses completely into one of these forms:
570
+ * - `cargo <args>` or `rustright cargo <args>` with no option that moves the manifest, workspace or output (--manifest-path,
571
+ * --target-dir, --config, -C, -Z): cargo runs from the script's package directory;
572
+ * - `pnpm|npm|yarn` with only resolvable options in any position before the script (--dir/-C/--prefix/--cwd, --filter/-F/
573
+ * --filter-prod/--workspace naming one package of this repository, pnpm -w, npm -w <workspace>, and output flags) running
574
+ * `run <script>`, `test`, `<script>` with no further arguments, or `exec <command>` (walked the same way in that package);
575
+ * - a tool that runs no repository code (tsc, eslint, prettier, biome, oxlint, echo, true).
576
+ * Anything else is unprovable: `cd`, an environment assignment, a shell expansion, node or any test runner, an unknown command or
577
+ * option, a recursive run, an unresolvable package, arguments passed on to a script, a script past the size limit, the depth limit,
578
+ * and a requiresBroker declaration (the declared path is not in the scripts). Over-refusal is the accepted cost.
579
+ */
580
+ export function cargoReachOf({ unitDir, scripts, declared = null, readScripts = () => null, packageDirOf = () => null, maxDepth = 8 }) {
581
+ const dirs = new Set();
582
+ // Keyed on package and script alone: the walk refuses everything that could change how a script runs (cd, environment), so a
583
+ // script reached twice from one package runs the same way both times.
584
+ const seen = new Set();
585
+ const unprovable = (why) => { throw Object.assign(new Error(why), { reach: true }); };
586
+ const walkScript = (dirScripts, name, dir, via) => {
587
+ const key = `${dir}\0${name}`;
588
+ if (seen.has(key)) return;
589
+ if (via.length >= maxDepth) unprovable(`the path to ${dir}'s ${name} (${via.join(" -> ")}) is deeper than ${maxDepth} scripts`);
590
+ seen.add(key);
591
+ for (const part of [`pre${name}`, name, `post${name}`]) {
592
+ if (!Object.hasOwn(dirScripts, part)) continue;
593
+ const text = dirScripts[part];
594
+ if (typeof text !== "string") unprovable(`${dir}'s ${part} script is not text`);
595
+ if (text.length > SCRIPT_TEXT_LIMIT) unprovable(`${dir}'s ${part} script is longer than ${SCRIPT_TEXT_LIMIT} characters`);
596
+ const meta = unquotedShellMeta(text);
597
+ if (meta !== null) unprovable(`${dir}'s ${part} script has an unquoted ${JSON.stringify(meta)}, which a shell would expand or redirect`);
598
+ const step = [...via, dir === unitDir ? part : `${dir}:${part}`];
599
+ for (const words of shellCommands(text)) walkCommand(words, dirScripts, dir, step);
600
+ }
601
+ };
602
+ const walkCommand = (words, dirScripts, dir, via) => {
603
+ const where = `${via.join(" -> ")}`;
604
+ const shown = words.join(" ").slice(0, 120);
605
+ if (words.some((w) => /[`$]/.test(w))) unprovable(`${where} runs \`${shown}\`, whose shell expansion this check cannot follow`);
606
+ if (ENV_ASSIGNMENT.test(words[0])) unprovable(`${where} runs \`${shown}\` with its own environment`);
607
+ const tool = path.posix.basename(words[0].replaceAll("\\", "/")).replace(/\.(exe|cmd|bat)$/i, "");
608
+ if (tool === "cargo" || tool === "rustright") {
609
+ const args = tool === "rustright" ? (words[1] === "cargo" ? words.slice(2) : unprovable(`${where} runs \`${shown}\`, a rustright command other than cargo`)) : words.slice(1);
610
+ const end = args.indexOf("--");
611
+ const own = end === -1 ? args : args.slice(0, end);
612
+ if (own.some((w) => CARGO_REDIRECT_ARG.test(w))) unprovable(`the path to cargo (${where}) redirects it`);
613
+ const sub = own.find((w) => !w.startsWith("-") && !w.startsWith("+"));
614
+ if (!CARGO_REACH_SUBCOMMANDS.has(sub)) unprovable(`${where} runs \`${shown}\`, a cargo command (${sub ?? "none"}) other than ${[...CARGO_REACH_SUBCOMMANDS].join(", ")}`);
615
+ dirs.add(dir);
616
+ return;
617
+ }
618
+ if (PACKAGE_MANAGERS.has(tool)) return walkPackageManager(tool, words.slice(1), dirScripts, dir, via, shown);
619
+ if (REACH_SAFE_TOOLS.has(tool)) return;
620
+ unprovable(`${where} runs \`${shown}\` (${tool}), which this check cannot follow to cargo`);
621
+ };
622
+ const walkPackageManager = (tool, args, dirScripts, dir, via, shown) => {
623
+ const where = `${via.join(" -> ")}`;
624
+ let target = dir;
625
+ let i = 0;
626
+ let retargets = 0;
627
+ // Options, in any position before the script or command: each one resolved, or the command is unprovable. At most one of them
628
+ // may name the package or directory (two would leave which one wins to the package manager).
629
+ const options = () => {
630
+ while (i < args.length && args[i].startsWith("-")) {
631
+ const [name, ...inline] = args[i].split("=");
632
+ const value = inline.length ? inline.join("=") : args[i + 1];
633
+ const width = inline.length ? 1 : 2;
634
+ if (PM_DIR_OPTIONS.has(name) || PM_PACKAGE_OPTIONS.has(name) || ((name === "-w" || name === "--workspace-root") && (tool === "pnpm" || tool === "npm"))) {
635
+ retargets += 1;
636
+ if (retargets > 1) unprovable(`${where}: \`${shown}\` names its package or directory more than once`);
637
+ }
638
+ if (PM_DIR_OPTIONS.has(name)) { if (value === undefined) unprovable(`${where}: \`${shown}\` names no directory`); target = path.posix.normalize(path.posix.join(dir, String(value))); i += width; }
639
+ else if (PM_PACKAGE_OPTIONS.has(name)) { const d = packageDirOf(String(value)); if (d === null || d === undefined) unprovable(`${where}: \`${shown}\` runs a package (${value}) this repository cannot resolve`); target = d; i += width; }
640
+ else if ((name === "-w" || name === "--workspace-root") && tool === "pnpm") { target = "."; i += 1; }
641
+ else if ((name === "-w") && tool === "npm") { const d = packageDirOf(String(value)); if (d === null || d === undefined) unprovable(`${where}: \`${shown}\` runs a workspace (${value}) this repository cannot resolve`); target = d; i += width; }
642
+ else if (PM_PLAIN_FLAGS.has(name) && !inline.length) i += 1;
643
+ else if (PM_VALUE_FLAGS.has(name)) i += width;
644
+ else unprovable(`${where}: \`${shown}\` has an option (${name}) this check does not resolve`);
645
+ }
646
+ };
647
+ options();
648
+ const sub = args[i++];
649
+ if (sub === undefined) unprovable(`${where}: \`${shown}\` runs no script`);
650
+ // Options after `run`/`exec` count as well: the target is known only once every option before the script has been read.
651
+ if (sub === "run" || sub === "run-script" || (sub === "exec" && tool === "pnpm")) options();
652
+ if (target.startsWith("..")) unprovable(`${where}: \`${shown}\` runs outside this repository (${target})`);
653
+ const targetScripts = target === dir ? dirScripts : readScripts(target);
654
+ if (targetScripts === null || targetScripts === undefined) unprovable(`${where}: \`${shown}\` runs scripts of ${target}, which has no readable package.json`);
655
+ const hop = target === dir ? via : [...via, `${tool} ${args.join(" ")}`.slice(0, 120)];
656
+ if (sub === "exec" && tool === "pnpm") {
657
+ if (i >= args.length) unprovable(`${where}: \`${shown}\` executes nothing`);
658
+ return walkCommand(args.slice(i), targetScripts, target, hop);
659
+ }
660
+ let script;
661
+ if (sub === "run" || sub === "run-script") { script = args[i++]; if (script === undefined) unprovable(`${where}: \`${shown}\` names no script`); }
662
+ else if (sub === "test" || sub === "t" || sub === "tst") script = "test";
663
+ else if (!PM_BUILTINS.has(sub) && Object.hasOwn(targetScripts, sub)) script = sub;
664
+ else unprovable(`${where}: \`${shown}\` is a ${tool} command (${sub}) this check cannot follow`);
665
+ if (i < args.length) unprovable(`${where}: \`${shown}\` passes arguments to ${target}'s ${script}`);
666
+ walkScript(targetScripts, script, target, hop);
667
+ };
668
+ if (declared?.call) return { unprovable: "it is declared to reach the broker in ways its scripts do not show (requiresBroker)" };
669
+ try {
670
+ walkScript(scripts ?? {}, "test", unitDir, []);
671
+ } catch (e) {
672
+ if (e.reach) return { unprovable: e.message };
673
+ throw e;
674
+ }
675
+ const list = [...dirs];
676
+ return list.length === 0 ? { none: true } : list.length === 1 ? { dir: list[0] } : { dirs: list };
677
+ }
678
+
413
679
  // A test script can reach the broker in ways scriptBrokerCall cannot read from package.json alone: `node -e "...spawnSync('pnpm', ['run', n])..."`,
414
680
  // `node ./scripts/verify-local.mjs` (a script that spawns `rustright cargo test`), `sh -c`, `pnpm --dir <elsewhere> run verify`, `pnpm --filter`. HeardRight's
415
681
  // root `test` is a `node -e` over `verify`, which is `pnpm --dir tauri-app-next run verify`, which is `node ./scripts/verify-local.mjs`, which runs
@@ -434,12 +700,25 @@ export function declaredBrokerCall(unit, value) {
434
700
  * managed `rustright cargo` and `pnpm` run in. A scan whose files did not change is not planned.
435
701
  */
436
702
  export function planScans({
437
- changed = [], contentRoot, liveRoot = contentRoot, exists = fs.existsSync, readFile = (f) => fs.readFileSync(f, "utf8"),
703
+ changed = [], contentRoot, liveRoot = contentRoot, trustedRoot = null, exists = fs.existsSync, readFile = (f) => fs.readFileSync(f, "utf8"),
438
704
  platform = process.platform, includeTests = true, localCompile = true,
439
705
  // Where the quiet test scans (ci/gate.config.json `quietTests`) run: "local" plans them for the quiet lane; "ci" leaves them to the
440
706
  // CI shards (one crate per runner, where the port, pipe, desktop and wall-clock conflicts that force the serial lane do not exist)
441
707
  // and records a note naming them (Adrian 2026-10-22: compile locally on the warm cache, qualify the conflicting tests on VMs).
442
708
  quietTests = "local",
709
+ // Which scans this plan owes (Adrian, 2026-10-26; app-check.mjs TIERS): "compile" leaves out the scans added after @rightkit/release
710
+ // 0.6.6 (shellcheck, and actionlint's shellcheck requirement) until every app builds through the door again; "release" plans everything.
711
+ tier = "compile",
712
+ // A broker host (rustright): cargo scans run in this primary checkout instead of the content root, because the RustRight client refuses
713
+ // every cargo command but a locked metadata outside the app's primary checkout (RK_NOT_PRIMARY_CHECKOUT). The gate sets it only after
714
+ // proving the checkout's Cargo inputs are blob-identical to the snapshot's (cargoInputsDiffer); null keeps the content/live roots.
715
+ cargoLiveRoot = null,
716
+ // { [cargoDir]: absolute directory }: the checkout's resolved target directory per Cargo workspace, set as CARGO_TARGET_DIR on
717
+ // that workspace's cargo scans when the scans read a snapshot (the door's commit compile, joint plan step 4c).
718
+ targetDirs = null,
719
+ // True when the gate's runtime cargo wrapper (cargo-target-shim.mjs) is first on PATH for every scan: each cargo invocation binds its
720
+ // own workspace's checkout target directory or is refused at run time, so the static walk (cargoReachOf) is advisory, never a refusal.
721
+ cargoShim = false,
443
722
  // The command prefix every cargo scan runs through: `rustright cargo` on a managed host, plain `cargo` in CI.
444
723
  cargoCommand = ["rustright", "cargo"],
445
724
  // Physical containment: a changed path whose real path leaves the real content root (a symlink) is refused.
@@ -452,6 +731,8 @@ export function planScans({
452
731
  crateFeatures = {},
453
732
  // ci/gate.config.json `npm`: per-package gate settings (`quietTests`), read by the package's unit name (packages/<dir> → rightkit-<dir>).
454
733
  packageFeatures = {},
734
+ // True for full class A on the live checkout, which runs the repository's own code anyway (tests, typecheck, lint).
735
+ liveExecution = false,
455
736
  // Crates a workspace-wide Rust change (Cargo.lock, vendor/) actually reaches, computed by the caller from
456
737
  // the lock graph (scripts/ci/select.mjs). null keeps every crate in scope.
457
738
  rustScope = null,
@@ -503,9 +784,14 @@ export function planScans({
503
784
  const bytesDigest = (f) => { try { return sha256(readBytes(path.join(contentRoot, f))); } catch { return "unreadable"; } };
504
785
  const inputDigest = sha256(canonicalJson({ files: [...files].sort().map((f) => [f, bytesDigest(f)]), refused: refusals.map((r) => r.raw).sort() }));
505
786
  const under = (prefix) => files.filter((f) => f.startsWith(prefix));
787
+ if (!["compile", "release"].includes(tier)) throw new Error(`planScans: tier must be compile or release, not ${JSON.stringify(tier)}`);
506
788
  let scans = []; const notes = [];
789
+ if (cargoLiveRoot) notes.push(`broker host: cargo scans run in the primary checkout ${cargoLiveRoot}, whose Cargo inputs match the checked snapshot (rustright refuses cargo outside the primary checkout)`);
790
+ // A snapshot compile's scans carry its target map (item 7): the map is part of every scan's cache key, so no result cached by a
791
+ // live-checkout run (or a compile with another map) is taken for one that writes to these directories.
792
+ const snapshotTargets = targetDirs ? { RK_SNAPSHOT_TARGETS: canonicalJson(targetDirs) } : {};
507
793
  const add = (d) => {
508
- const entry = { root: "live", env: {}, args: [], ...d };
794
+ const entry = { root: "live", args: [], ...d, env: { ...snapshotTargets, ...(d.env ?? {}) } };
509
795
  entry.command = commandText(entry);
510
796
  scans.push(entry);
511
797
  };
@@ -524,7 +810,7 @@ export function planScans({
524
810
  const plainDir = (d) => !ABSOLUTE_PATH.test(d) && !d.split("/").includes("..") && SAFE_UNIT_DIR.test(d);
525
811
  const cargoWs = cleanDir(cargoDir);
526
812
  if (!plainDir(cargoWs)) throw new TypeError("planScans: cargoDir must be a plain repository-relative directory");
527
- const cargoIn = (ws, ...args) => ({ tool: cargoCommand[0], args: [...cargoCommand.slice(1), ...args], ...(ws !== "." ? { cwd: ws } : {}) });
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] } } : {}) });
528
814
  const unitMap = (units, kind) => {
529
815
  if (units === null) return null;
530
816
  if (!Array.isArray(units)) throw new TypeError(`planScans: ${kind}Units must be an array of { name, dir }`);
@@ -632,8 +918,14 @@ export function planScans({
632
918
  }
633
919
  byEdition.set(found.edition, [...(byEdition.get(found.edition) ?? []), f]);
634
920
  }
921
+ // One rustfmt scan per edition, batched: Windows refuses a command line past ~32 KB (spawn ENAMETOOLONG on a large repository,
922
+ // HeardRight on the Dell, 2026-10-27), so a batch holds at most RUSTFMT_BATCH files and the first batch keeps the plain id.
635
923
  for (const [edition, group] of [...byEdition].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
636
- add({ id: `rustfmt:${edition}`, class: A, tool: "rustfmt", args: ["--check", "--edition", edition, ...group.sort()], root: "content" });
924
+ const sorted = group.sort();
925
+ for (let i = 0; i < sorted.length; i += RUSTFMT_BATCH) {
926
+ const batch = sorted.slice(i, i + RUSTFMT_BATCH);
927
+ add({ id: `rustfmt:${edition}${i ? `:${i / RUSTFMT_BATCH + 1}` : ""}`, class: A, tool: "rustfmt", args: ["--check", "--edition", edition, ...batch], root: "content" });
928
+ }
637
929
  }
638
930
  for (const f of files.filter((f) => /\.(mjs|cjs|js)$/.test(f) && !f.includes("node_modules")).sort()) {
639
931
  add({ id: `node-check:${f}`, class: A, tool: "node", args: ["--check", f], root: "content" });
@@ -687,13 +979,14 @@ export function planScans({
687
979
  const actionlintConfig = exists(path.join(contentRoot, ".github/actionlint.yaml")) ? ["-config-file", ".github/actionlint.yaml"] : [];
688
980
  // actionlint lints every run: script with shellcheck only when shellcheck is found, and otherwise passes silently; class A
689
981
  // requires it (`requires`), so a host without it fails the scan instead of skipping the shell checks.
690
- if (workflowFiles.length) add({ id: "actionlint", class: A, tool: "actionlint", requires: ["shellcheck"], args: [...actionlintConfig, ...workflowFiles], root: "content" });
982
+ if (workflowFiles.length) add({ id: "actionlint", class: A, tool: "actionlint", requires: tier === "release" ? ["shellcheck"] : [], args: [...actionlintConfig, ...workflowFiles], root: "content", runScriptLimit: { files: workflowFiles, bytes: RUN_SCRIPT_LIMIT_BYTES } });
691
983
  // Standalone shell scripts (SellRight's deploy scripts failed SC2154, SC2030/2031 and SC2016 in CI): every tracked .sh/.bash file,
692
984
  // with ShellCheck's own defaults and the repository's .shellcheckrc (data, not code). actionlint covers only workflow run: blocks.
693
985
  // A script whose shebang names another shell (zsh, fish) is not ShellCheck's to judge, so it is left out, never failed.
694
986
  const otherShell = (f) => { try { return /^#!.*\b(zsh|fish|pwsh|powershell)\b/.test(String(readFile(path.join(contentRoot, f))).split("\n", 1)[0]); } catch { return false; } };
695
987
  const shellFiles = files.filter((f) => /\.(sh|bash)$/.test(f) && !/(^|\/)node_modules\//.test(f) && !otherShell(f)).sort();
696
- if (shellFiles.length) add({ id: "shellcheck", class: A, tool: "shellcheck", args: ["--format=gcc", "--external-sources", ...shellFiles], root: "content" });
988
+ if (shellFiles.length && tier !== "release") notes.push("compile tier: shellcheck (added after 0.6.6) is not planned; it runs in the release tier (--to qualify|release)");
989
+ if (shellFiles.length && tier === "release") add({ id: "shellcheck", class: A, tool: "shellcheck", tier: "release", args: ["--format=gcc", "--external-sources", ...shellFiles], root: "content" });
697
990
  // No right-git drift scan: the one workflow right-git renders here, ci.yml, is hand-owned (its header;
698
991
  // docs/CI.md) and held by scripts/ci/ci-scans-contract.mjs instead, so drift is always reported by design.
699
992
 
@@ -706,22 +999,36 @@ export function planScans({
706
999
  for (const ws of workspaces) {
707
1000
  if (typeof ws !== "string") continue;
708
1001
  const dir = ws === "." ? "" : ws;
709
- 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; } });
1002
+ // A non-empty list only: `[^\]\s]` is one character that is neither whitespace nor the closing bracket, so `ignore = []` followed by
1003
+ // a later `]` (the next table header) is not an ignore (Pulse, 2026-10-27: `\S` matched the `]` itself).
1004
+ 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; } });
710
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" });
711
1006
  }
712
1007
  // Like generated-docs, planned only when a changed file is one of the contract's inputs or projections. The generator runs from
713
1008
  // the trusted scanner checkout (the import below); the exported contract and projections are data it checks.
714
- if (files.some((f) => SUITE_API_INPUT.test(f)) && exists(path.join(contentRoot, "contracts/right-suite-api.v1.json"))) add({ id: "suite-api", class: A, internal: "suite-api" });
1009
+ if (files.some((f) => SUITE_API_INPUT.test(f)) && exists(path.join(contentRoot, "contracts/right-suite-api.v1.json"))) {
1010
+ // RightKit's own generator contract (scripts/gen-suite-api.mjs) is the checked repository's code: like scripts/preflight.mjs it
1011
+ // runs only when this gate is that repository's in-tree copy (and in CI), never from a pinned or installed copy checking the
1012
+ // repository (the broker's trusted gate refused RightKit's own compile on a module it could not find, 2026-10-27).
1013
+ // The trusted home is the gate's own repository (`trustedRoot`, the checkout rightkit-gate runs for: a commit check exports the
1014
+ // commit to a snapshot and hands that as contentRoot and liveRoot); the generator is code and runs only from that checkout's
1015
+ // in-tree gate, reading the snapshot as data. Without a trusted root (library callers), the live checkout or content root decides.
1016
+ const home = trustedRoot ?? (path.resolve(liveRoot) !== path.resolve(contentRoot) ? liveRoot : contentRoot);
1017
+ if (inTreeGate(home, exists)) add({ id: "suite-api", class: A, internal: "suite-api" });
1018
+ else notes.push("suite-api: RightKit's own generator contract runs from the in-tree gate and CI, not from this installed copy");
1019
+ }
715
1020
  if (rustTouched) {
716
1021
  for (const ws of touchedWs) add({ id: `cargo-metadata${wsIdSuffix(ws)}`, class: A, ...cargoIn(ws, ...metadataArgs()) });
717
- for (const name of crateNames.filter((n) => rustMap?.get(n).publish !== false)) add({ id: rustScanId.packageList(name), class: A, ...cargoIn(wsOf(name), "package", "-p", crateOf(name), "--list", "--allow-dirty", "--locked") });
1022
+ // Locked package listings are observations in RustRight (#1216). They run through the selected executor
1023
+ // from the checked Cargo inputs, just like metadata; an executor refusal remains a failed scan.
1024
+ if (tier === "release") for (const name of crateNames.filter((n) => rustMap?.get(n).publish !== false)) add({ id: rustScanId.packageList(name), class: A, tier: "release", ...cargoIn(wsOf(name), "package", "-p", crateOf(name), "--list", "--allow-dirty", "--locked") });
718
1025
  }
719
1026
  for (const ws of [...configWs].sort()) {
720
1027
  // Offline against the pinned database. The pin commit is part of the command (env), so a pin change is a different scan, and the
721
1028
  // placeholders are filled at run time from the materialised checkout (runScans).
722
1029
  const pin = advisoryPin ?? (advisoryPin = readAdvisoryPin());
723
1030
  const pinEnv = { RK_ADVISORY_DB_COMMIT: pin.commit };
724
- add({ id: `cargo-deny${wsIdSuffix(ws)}`, class: A, ...cargoIn(ws, "deny", "--offline", "--metadata-path", "{advisoryMetadata}", "--config", "{advisoryDenyConfig}", "check"), env: pinEnv, advisoryDb: "deny", advisoryPin: pin });
1031
+ add({ id: `cargo-deny${wsIdSuffix(ws)}`, class: A, ...cargoIn(ws, "deny", "--frozen", "--metadata-path", "{advisoryMetadata}", "--config", "{advisoryDenyConfig}", "check"), env: pinEnv, advisoryDb: "deny", advisoryPin: pin });
725
1032
  add({ id: `cargo-audit${wsIdSuffix(ws)}`, class: A, ...cargoIn(ws, "audit", "--db", "{advisoryDb}", "--no-fetch"), env: pinEnv, advisoryDb: "audit", advisoryPin: pin });
726
1033
  }
727
1034
  // The audit policy is the consumer's own: every package.json script that is exactly `pnpm audit <flags>` (only --prod, --dev,
@@ -770,9 +1077,11 @@ export function planScans({
770
1077
  // requiring a trusted-root run: deferred (never a pass), not run from submitted code.
771
1078
  // Planned only when a changed file is one of the generator's inputs (GENERATOR_INPUT).
772
1079
  if (exists(path.join(contentRoot, "scripts/generate-index.mjs")) && files.some((f) => GENERATOR_INPUT.test(f))) {
773
- const trusted = path.resolve(liveRoot) !== path.resolve(contentRoot) && exists(path.join(liveRoot, "scripts/generate-index.mjs"));
1080
+ // Full class A on the live checkout (`liveExecution`) already executes the repository's own code (its tests): there the
1081
+ // generator runs from that same checkout. Everywhere else the separation above holds.
1082
+ const trusted = (liveExecution || path.resolve(liveRoot) !== path.resolve(contentRoot)) && exists(path.join(liveRoot, "scripts/generate-index.mjs"));
774
1083
  add({
775
- id: "generated-docs", class: B, tool: "node", args: ["scripts/generate-index.mjs", "--check", "--root", "{contentRoot}"],
1084
+ id: "generated-docs", class: A, executes: true, tool: "node", args: ["scripts/generate-index.mjs", "--check", "--root", "{contentRoot}"],
776
1085
  ...(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" }),
777
1086
  });
778
1087
  }
@@ -809,15 +1118,43 @@ export function planScans({
809
1118
  }
810
1119
  // 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).
811
1120
  const hasConfig = (dir, names) => ancestorDirsOf(dir).some((d) => names.some((f) => exists(path.join(liveRoot, inDir(d, f)))));
1121
+ // tsc reads a workspace sibling's types where its package.json exports point: built output (dist/*.d.ts) that no commit holds, so a
1122
+ // check of the committed tree (CI, a commit snapshot) cannot find them, and a checkout with an old build checks against stale types.
1123
+ // A package that depends on another workspace package is therefore checked through a generated project that maps each sibling's
1124
+ // exported entry points to its committed sources (TSC_PROJECT, built by the runner); every other package keeps its own tsconfig.
1125
+ // `--noEmit` stays on the command line as well as in the project: the class A launch guard judges a tsc launch by its argv alone.
1126
+ const workspaceDirs = (npmMap ? [...npmMap.values()].map((u) => u.dir) : listDir(path.join(liveRoot, "packages")).map((n) => `packages/${n}`)).sort();
1127
+ const manifestOf = (dir) => { try { return JSON.parse(readFile(path.join(liveRoot, dir, "package.json")).replace(/^/, "")); } catch { return null; } };
1128
+ const workspaceNames = new Map(workspaceDirs.map((d) => [manifestOf(d)?.name, d]).filter(([n]) => typeof n === "string"));
1129
+ const usesWorkspaceSibling = (dir) => {
1130
+ const m = manifestOf(dir);
1131
+ return ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"].some((k) => Object.keys(m?.[k] ?? {}).some((n) => n !== m.name && workspaceNames.has(n)));
1132
+ };
812
1133
  for (const name of pkgNames) {
813
1134
  const dir = npmDir(name);
814
- if (exists(path.join(liveRoot, dir, "tsconfig.json"))) add({ id: `tsc:${name}`, class: B, tool: "pnpm", args: ["exec", "tsc", "--noEmit", "-p", "tsconfig.json"], env: PNPM_RUN_ENV, cwd: dir });
815
- if (hasConfig(dir, ESLINT_CONFIG_FILES)) add({ id: `eslint:${name}`, class: B, tool: "pnpm", args: ["exec", "eslint", "."], env: PNPM_RUN_ENV, cwd: dir });
816
- if (hasConfig(dir, BIOME_CONFIG_FILES)) add({ id: `biome:${name}`, class: B, tool: "pnpm", args: ["exec", "biome", "lint", "."], env: PNPM_RUN_ENV, cwd: dir });
1135
+ if (exists(path.join(liveRoot, dir, "tsconfig.json"))) {
1136
+ add(usesWorkspaceSibling(dir)
1137
+ ? { id: `tsc:${name}`, class: A, executes: true, tool: "pnpm", args: ["exec", "tsc", "--noEmit", "-p", TSC_PROJECT], tscWorkspace: [...workspaceNames.values()].sort(), env: PNPM_RUN_ENV, cwd: dir }
1138
+ : { id: `tsc:${name}`, class: A, executes: true, tool: "pnpm", args: ["exec", "tsc", "--noEmit", "-p", "tsconfig.json"], env: PNPM_RUN_ENV, cwd: dir });
1139
+ }
1140
+ if (hasConfig(dir, ESLINT_CONFIG_FILES)) add({ id: `eslint:${name}`, class: A, executes: true, tool: "pnpm", args: ["exec", "eslint", "."], env: PNPM_RUN_ENV, cwd: dir });
1141
+ if (hasConfig(dir, BIOME_CONFIG_FILES)) add({ id: `biome:${name}`, class: A, executes: true, tool: "pnpm", args: ["exec", "biome", "lint", "."], env: PNPM_RUN_ENV, cwd: dir });
817
1142
  }
818
- if (files.some((f) => f.startsWith("swift/") || f === "Package.swift")) {
819
- if (platform === "darwin") add({ id: "swift-build", class: B, tool: "swift", args: ["build"] });
820
- else notes.push("swift files changed; `swift build` is required on the Mac host before its push");
1143
+ // One `swift build` per Swift package a changed file belongs to: the nearest Package.swift at or above the file, built in its own
1144
+ // directory (rightkit#1229: Pulse's package is swift/RightKitHub, and a root `swift build` found no package and blocked every push).
1145
+ const swiftFiles = files.filter((f) => f.startsWith("swift/") || f === "Package.swift" || f.endsWith("/Package.swift"));
1146
+ if (swiftFiles.length) {
1147
+ const packages = new Set();
1148
+ for (const f of swiftFiles) {
1149
+ const owner = ancestorDirsOf(path.posix.dirname(f)).reverse().find((d) => exists(path.join(contentRoot, inDir(d, "Package.swift"))));
1150
+ if (owner !== undefined) packages.add(owner);
1151
+ else notes.push(`${f}: no Package.swift at or above it, so no Swift package builds it`);
1152
+ }
1153
+ for (const dir of [...packages].sort()) {
1154
+ const id = dir === "" || dir === "." ? "swift-build" : `swift-build:${dir}`;
1155
+ if (platform === "darwin") add({ id, class: B, tool: "swift", args: ["build"], ...(dir === "" || dir === "." ? {} : { cwd: dir }) });
1156
+ else notes.push(`swift files changed in ${dir === "" || dir === "." ? "the root package" : dir}; \`swift build\` is required on the Mac host before its push`);
1157
+ }
821
1158
  }
822
1159
  if (includeTests) {
823
1160
  const deferredToCi = [];
@@ -851,8 +1188,64 @@ export function planScans({
851
1188
  // A declaration (`npm.<unit>.requiresBroker`, declaredBrokerCall) covers what the script text does not show; a call the scripts do show wins.
852
1189
  const declared = declaredBrokerCall(name, own.requiresBroker);
853
1190
  if (declared.problem) notes.push(declared.problem);
1191
+ // `npm.<unit>.afterClassA.reason` (ci/gate.config.json): the unit's tests launch stand-ins named like compilers (stub scripts,
1192
+ // which can run anything), so they cannot run under the class A launch guard; they run after class A, with the compile scans.
1193
+ // A reason is required.
1194
+ const afterClassA = typeof own.afterClassA?.reason === "string" && own.afterClassA.reason.trim() !== "" ? own.afterClassA.reason.trim() : null;
854
1195
  const brokerCall = scriptBrokerCall(manifest.scripts, "test") ?? declared.call ?? null;
855
- add({ id: `test:npm:${name}`, class: B, ...(own.quietTests === true ? { quiet: true } : {}), ...(brokerCall ? { brokerCall } : {}), tool: "pnpm", args: ["--dir", npmDir(name), "run", "test"], env: { ...PNPM_RUN_ENV, RIGHTKIT_ISOLATED_CHECKOUT: "1" } });
1196
+ const buildCall = brokerCall ? null : scriptBuildCall(manifest.scripts, "test");
1197
+ const laterReason = brokerCall ? null : buildCall ? `its test script builds before testing (${buildCall.command}); it runs after class A, with the compile scans` : afterClassA ? `declared to run after class A (ci/gate.config.json npm.${name}.afterClassA): ${afterClassA}` : null;
1198
+ // Isolated only where the tests run on an export (contentRoot is not the live checkout): there sibling repositories are
1199
+ // absent by construction. On the live checkout (full class A, broker admission) the cross-app contract tests run for real.
1200
+ const isolatedCheckout = path.resolve(contentRoot) !== path.resolve(liveRoot);
1201
+ // `npm.<unit>.classATests.evidence` (ci/gate.config.json): the unit's tests were shown, by import inspection and launch observation
1202
+ // under the class A launch guard, to compile and build nothing (docs/GATE-CLASS-A-INVENTORY.md "Test units"); they run in class A,
1203
+ // still under the guard, which refuses any compile at run time. Never when the script reaches the broker, builds, or is declared after class A.
1204
+ const classAEvidence = typeof own.classATests?.evidence === "string" && own.classATests.evidence.trim() !== "" ? own.classATests.evidence.trim() : null;
1205
+ const inClassA = classAEvidence && !brokerCall && !buildCall && !afterClassA;
1206
+ if (classAEvidence && !inClassA) notes.push(`npm.${name}.classATests is ignored: ${brokerCall ? "its test script reaches the broker" : buildCall ? `its test script builds (${buildCall.command})` : "it is declared afterClassA"}; it runs after class A`);
1207
+ // A commit snapshot's compile (targetDirs): a test that reaches cargo writes where cargo puts the outputs of the workspace it
1208
+ // finds from the directory it runs in, which would be inside the snapshot. Bound to that workspace's checkout target directory
1209
+ // when the reachable path proves the directory (cargoReachOf); refused before launch only when it reaches Rust unprovably.
1210
+ let cargoTarget = null;
1211
+ if (targetDirs && cargoShim) {
1212
+ // Advisory only: the wrapper binds or refuses each cargo the test starts, whatever path reaches it.
1213
+ const reach = cargoReachOf({ unitDir: npmDir(name), scripts: manifest.scripts, declared });
1214
+ if (reach.unprovable && Object.keys(targetDirs).length) notes.push(`npm unit ${name}: the static walk cannot prove where its cargo writes (${reach.unprovable}); the runtime cargo wrapper binds each invocation to its workspace's checkout target directory or refuses it`);
1215
+ } else if (targetDirs) {
1216
+ const unitDir = npmDir(name);
1217
+ const scriptsOf = (dir) => { try { const m = JSON.parse(readFile(path.join(contentRoot, ...(dir === "." ? [] : dir.split("/")), "package.json"))); return m?.scripts && typeof m.scripts === "object" ? m.scripts : {}; } catch { return null; } };
1218
+ const packageDirOf = (pkg) => {
1219
+ for (const [unit, entry] of npmMap ?? []) {
1220
+ if (unit === pkg) return entry.dir;
1221
+ try { if (JSON.parse(readFile(path.join(contentRoot, ...(entry.dir === "." ? [] : entry.dir.split("/")), "package.json"))).name === pkg) return entry.dir; } catch { /* unreadable: not this one */ }
1222
+ }
1223
+ return null;
1224
+ };
1225
+ // Every call the test reaches, not the first: one CARGO_TARGET_DIR binds the whole test, so all of them must be one workspace.
1226
+ const reach = cargoReachOf({ unitDir, scripts: manifest.scripts, declared, readScripts: scriptsOf, packageDirOf });
1227
+ const hasRust = Object.keys(targetDirs).length > 0;
1228
+ if (reach.unprovable) {
1229
+ if (hasRust) { refuse(`npm unit ${name}`, `its test reaches cargo while the compile reads a commit snapshot, and which workspace's outputs it writes cannot be proven (${reach.unprovable}); outputs must be the checkout's`); continue; }
1230
+ } else if (reach.dir !== undefined || reach.dirs) {
1231
+ const workspaceOf = (from) => {
1232
+ let manifestDir = null;
1233
+ for (let d = from; ; d = path.posix.dirname(d) === "." || d === "." ? "." : path.posix.dirname(d)) {
1234
+ if (exists(path.join(contentRoot, ...(d === "." ? [] : d.split("/")), "Cargo.toml"))) { manifestDir = d; break; }
1235
+ if (d === ".") break;
1236
+ }
1237
+ const ws = manifestDir === null ? null : Object.hasOwn(targetDirs, manifestDir) ? manifestDir : ([...(rustMap?.values() ?? [])].find((u) => u.dir === manifestDir)?.cargoDir ?? null);
1238
+ return ws && targetDirs[ws] ? { ws } : { why: `its test reaches cargo from ${from}, ${manifestDir === null ? `which no Cargo.toml encloses` : `whose ${manifestDir}/Cargo.toml is not a known workspace or member`}; outputs must be the checkout's` };
1239
+ };
1240
+ const found = (reach.dirs ?? [reach.dir]).map(workspaceOf);
1241
+ const unknown = found.find((f) => f.why);
1242
+ if (unknown) { refuse(`npm unit ${name}`, unknown.why); continue; }
1243
+ const workspaces = [...new Set(found.map((f) => f.ws))];
1244
+ if (workspaces.length > 1) { refuse(`npm unit ${name}`, `its test reaches cargo in more than one workspace (${workspaces.join(", ")} from ${reach.dirs.join(", ")}); one CARGO_TARGET_DIR binds the whole test, so one of them would write into another's outputs`); continue; }
1245
+ cargoTarget = targetDirs[workspaces[0]];
1246
+ }
1247
+ }
1248
+ add({ id: `test:npm:${name}`, class: inClassA ? A : B, kind: "test", executes: true, classReason: inClassA ? `class A on recorded evidence (${classAEvidence}); run under the launch guard` : brokerCall ? "its test script reaches the broker (compiles); it runs with the compile scans" : laterReason ?? "test scripts execute project code and run after class A", ...(own.quietTests === true ? { quiet: true } : {}), ...(brokerCall ? { brokerCall } : {}), tool: "pnpm", args: ["--dir", npmDir(name), "run", "test"], env: { ...PNPM_RUN_ENV, ...(isolatedCheckout ? { RIGHTKIT_ISOLATED_CHECKOUT: "1" } : {}), ...(cargoTarget ? { CARGO_TARGET_DIR: cargoTarget } : {}) } });
856
1249
  }
857
1250
  if (deferredToCi.length) notes.push(`${deferredToCi.length} quiet test scan(s) left to the CI shards (one crate per runner), not run here: ${deferredToCi.join(", ")}`);
858
1251
  // scripts/ci tests run when scripts/, ci/ or .github/ changed, or a file the scripts/ci sources reference (imports and
@@ -883,7 +1276,7 @@ export function planScans({
883
1276
  // The suite is the exported commit's test files, named explicitly: an untracked WIP test in the live
884
1277
  // checkout is never run and can neither block nor pass a push.
885
1278
  const suite = listDir(path.join(contentRoot, "scripts", "ci")).filter((n) => n.endsWith(".test.mjs")).sort().map((n) => `scripts/ci/${n}`);
886
- add({ id: "test:scripts-ci", class: B, tool: "node", args: ["--test", ...(suite.length ? suite : ["scripts/ci/*.test.mjs"])] });
1279
+ add({ id: "test:scripts-ci", class: B, kind: "test", executes: true, classReason: "tests execute project code and run after class A", tool: "node", args: ["--test", ...(suite.length ? suite : ["scripts/ci/*.test.mjs"])] });
887
1280
  }
888
1281
  }
889
1282
 
@@ -904,7 +1297,7 @@ export function planScans({
904
1297
  if (dup) throw new Error(`planScans: duplicate scan id ${dup}`);
905
1298
  const classDigest = (cls) => digestScanSet(scans.filter((s) => s.class === cls));
906
1299
  return {
907
- schema: SCAN_RESULT_SCHEMA, roots: { contentRoot, liveRoot }, changed: files, inputDigest, scans, notes, deferred,
1300
+ schema: SCAN_RESULT_SCHEMA, roots: { contentRoot, liveRoot, ...(cargoLiveRoot ? { cargoLiveRoot } : {}) }, changed: files, inputDigest, scans, notes, deferred,
908
1301
  scanSetDigest: digestScanSet(scans), classDigests: { A: classDigest(A), B: classDigest(B) },
909
1302
  };
910
1303
  }
@@ -921,7 +1314,7 @@ const SAFE_SHIM_PATH = /^[A-Za-z0-9_.,:=@+ \/\\()-]+$/;
921
1314
  * run any other .cmd/.bat shim only when its path and every argument pass a strict allowlist.
922
1315
  * Returns { file, args, verbatim } or { error }.
923
1316
  */
924
- export function resolveInvocation(command, args, { platform = process.platform, env = process.env, exists = fs.existsSync, execPath = process.execPath } = {}) {
1317
+ export function resolveInvocation(command, args, { platform = process.platform, env = process.env, exists = fs.existsSync, execPath = process.execPath, readFile = (f) => fs.readFileSync(f, "utf8") } = {}) {
925
1318
  if (platform !== "win32") return { file: command, args, verbatim: false };
926
1319
  const hasExt = /\.[A-Za-z0-9]{1,4}$/.test(command);
927
1320
  const pathKey = Object.keys(env).find((k) => k.toLowerCase() === "path");
@@ -936,6 +1329,10 @@ export function resolveInvocation(command, args, { platform = process.platform,
936
1329
  if (!/\.(cmd|bat)$/i.test(found)) return { file: found, args, verbatim: false };
937
1330
  const cjs = path.win32.join(path.win32.dirname(found), "node_modules", "pnpm", "bin", "pnpm.cjs");
938
1331
  if (/^pnpm\.cmd$/i.test(path.win32.basename(found)) && exists(cjs)) return { file: execPath, args: [cjs, ...args], verbatim: false };
1332
+ // A Node cmd-shim (what npm, pnpm and nvm install: `"%~dp0\...\entry.js" %*`, npm's `SET "NPM_CLI_JS=%~dp0\...npm-cli.js"`) runs
1333
+ // its JS entry with node: started here as node + that entry, no shell, so any argument passes unchanged (rightkit#1261).
1334
+ const entry = cmdShimEntry(found, { readFile, exists });
1335
+ if (entry) return { file: execPath, args: [entry, ...args], verbatim: false };
939
1336
  const bad = args.find((a) => !SAFE_SHIM_ARG.test(a));
940
1337
  if (bad !== undefined) return { error: new Error(`refusing to pass ${JSON.stringify(bad)} to the shell shim ${found}`) };
941
1338
  if (!SAFE_SHIM_PATH.test(found)) return { error: new Error(`refusing to run shell shim ${found}: unsafe path`) };
@@ -943,14 +1340,83 @@ export function resolveInvocation(command, args, { platform = process.platform,
943
1340
  return { file: env.ComSpec || "cmd.exe", args: ["/d", "/s", "/c", `""${found}"${args.map((a) => ` ${a}`).join("")}"`], verbatim: true };
944
1341
  }
945
1342
 
1343
+ /**
1344
+ * The JS entry a Node cmd-shim at `shim` runs, or null: the line that passes the caller's arguments (`%*`) names it, either as a
1345
+ * `%~dp0`-relative path (`"%~dp0\\..\\pnpm.mjs" %*`) or through a variable set to one (npm's `"%NODE_EXE%" "%NPM_CLI_JS%" %*` with
1346
+ * `SET "NPM_CLI_JS=%~dp0\\node_modules\\npm\\bin\\npm-cli.js"`). Helpers the shim runs on other lines (npm's npm-prefix.js) are not it.
1347
+ */
1348
+ export function cmdShimEntry(shim, { readFile = (f) => fs.readFileSync(f, "utf8"), exists = fs.existsSync } = {}) {
1349
+ let text;
1350
+ try { text = String(readFile(shim)); } catch { return null; }
1351
+ const vars = new Map();
1352
+ // Every value a variable is set to (npm's NPM_CLI_JS is reassigned to a global-prefix copy when one exists): the %~dp0 one is used.
1353
+ for (const m of text.matchAll(/^\s*@?SET\s+"?([A-Za-z_][A-Za-z0-9_]*)=([^"\r\n]*)"?\s*$/gim)) vars.set(m[1].toUpperCase(), [...(vars.get(m[1].toUpperCase()) ?? []), m[2]]);
1354
+ const fromDp0 = (value) => {
1355
+ const m = /^%~dp0\\?(.+\.(?:mjs|cjs|js))$/i.exec(String(value ?? "").trim());
1356
+ if (!m) return null;
1357
+ const entry = path.win32.resolve(path.win32.dirname(shim), m[1]);
1358
+ return exists(entry) ? entry : null;
1359
+ };
1360
+ for (const line of text.split(/\r?\n/)) {
1361
+ if (!line.includes("%*")) continue;
1362
+ for (const q of line.matchAll(/"([^"]+)"/g)) {
1363
+ const token = q[1];
1364
+ const direct = fromDp0(token);
1365
+ if (direct) return direct;
1366
+ const v = /^%([A-Za-z_][A-Za-z0-9_]*)%$/.exec(token);
1367
+ if (v) for (const value of vars.get(v[1].toUpperCase()) ?? []) { const viaVar = fromDp0(value); if (viaVar) return viaVar; }
1368
+ }
1369
+ }
1370
+ return null;
1371
+ }
1372
+
946
1373
  /** Default spawn: synchronous, no console window, never `shell: true`. */
947
1374
  export function defaultSpawn(command, args, options = {}) {
1375
+ if (options.boundedMs) return boundedSpawn(command, args, options);
948
1376
  const inv = resolveInvocation(command, args, { env: options.env ?? process.env });
949
1377
  if (inv.error) return { status: null, stdout: "", stderr: "", error: inv.error };
950
1378
  return spawnSync(inv.file, inv.args, { encoding: "utf8", windowsHide: true, maxBuffer: 64 * 1024 * 1024, windowsVerbatimArguments: inv.verbatim, ...options, shell: false });
951
1379
  }
952
1380
 
953
1381
  const OUTPUT_KEEP = 4 * 1024 * 1024;
1382
+ /**
1383
+ * defaultSpawn with a deadline (`options.boundedMs`), for short identity probes that must never hang the caller: the command runs
1384
+ * in a helper node process through defaultSpawnAsync, which on the deadline kills the command's whole process tree
1385
+ * (killProcessTree) and reports `timedOut: true`. The helper records the command's pid first; if the helper itself overruns its
1386
+ * backstop (`backstopMs`, default deadline + 15 s), it is killed and the command's tree is killed from here by that pid, so no
1387
+ * probe outlives the call. A launch failure keeps its `error` (e.g. ENOENT); a command that ran keeps its `status`.
1388
+ */
1389
+ export function boundedSpawn(command, args, { boundedMs, backstopMs = boundedMs + 15000, cwd, env, pidFile = null } = {}) {
1390
+ const dir = pidFile ? null : fs.mkdtempSync(path.join(os.tmpdir(), "rk-probe-"));
1391
+ const pids = pidFile ?? path.join(dir, "pid");
1392
+ const script = `import(${JSON.stringify(new URL(import.meta.url).href)}).then(async (m) => {
1393
+ const [command, args, ms, pidFile] = JSON.parse(process.argv[1]); const fs = await import("node:fs");
1394
+ const ac = new AbortController(); const timer = setTimeout(() => ac.abort(), ms);
1395
+ const r = await m.defaultSpawnAsync(command, args, { signal: ac.signal, onSpawn: (pid) => fs.writeFileSync(pidFile, String(pid)) }); clearTimeout(timer);
1396
+ process.stdout.write(JSON.stringify({ status: r.status, stdout: r.stdout, stderr: r.stderr, timedOut: r.cancelled === true, error: r.cancelled ? null : (r.error ? { code: r.error.code ?? null, message: r.error.message } : null) }));
1397
+ });`;
1398
+ try {
1399
+ const r = spawnSync(process.execPath, ["--input-type=module", "-e", script, JSON.stringify([command, args, boundedMs, pids])], { cwd, env: env ?? process.env, encoding: "utf8", windowsHide: true, stdio: ["ignore", "pipe", "pipe"], timeout: backstopMs, killSignal: "SIGKILL", maxBuffer: 16 * 1024 * 1024 });
1400
+ const timeout = (why) => ({ status: null, stdout: "", stderr: "", timedOut: true, error: Object.assign(new Error(`timed out after ${boundedMs} ms${why}`), { code: "ETIMEDOUT" }) });
1401
+ if (r.error?.code === "ETIMEDOUT") {
1402
+ let pid = null; try { pid = Number(fs.readFileSync(pids, "utf8")) || null; } catch { /* never started */ }
1403
+ if (pid) killProcessTree({ pid });
1404
+ return timeout(" (backstop)");
1405
+ }
1406
+ try {
1407
+ const o = JSON.parse(r.stdout);
1408
+ if (o.timedOut) return timeout("");
1409
+ return { status: o.status, stdout: o.stdout ?? "", stderr: o.stderr ?? "", ...(o.error ? { error: Object.assign(new Error(o.error.message), { code: o.error.code }) } : {}) };
1410
+ } catch { return { status: null, stdout: "", stderr: r.stderr ?? "", error: new Error(`the bounded probe gave no result (${r.error?.message ?? `exit ${r.status}`})`) }; }
1411
+ } finally { if (dir) fs.rmSync(dir, { recursive: true, force: true }); }
1412
+ }
1413
+
1414
+ /** The environment additions that load the class A launch guard into a test row's Node processes (NODE_OPTIONS appended, not replaced). */
1415
+ export function launchGuardEnv(env = process.env) {
1416
+ const current = String(env.NODE_OPTIONS ?? "");
1417
+ return { RIGHTKIT_CLASS_A_LAUNCH_GUARD: "1", NODE_OPTIONS: current.includes(LAUNCH_GUARD_IMPORT) ? current : `${current} ${LAUNCH_GUARD_IMPORT}`.trim() };
1418
+ }
1419
+
954
1420
  /** Kill `child` and everything it started: taskkill /T /F on Windows, the whole process group elsewhere (the child leads one when spawned with a signal). */
955
1421
  export function killProcessTree(child, { platform = process.platform, run = spawnSync, kill = (pid, sig) => process.kill(pid, sig) } = {}) {
956
1422
  if (!child?.pid) return;
@@ -967,17 +1433,21 @@ export function killProcessTree(child, { platform = process.platform, run = spaw
967
1433
  * With `options.stdoutFile`, stdout is written to that file whole instead of kept in memory.
968
1434
  */
969
1435
  export function defaultSpawnAsync(command, args, options = {}) {
970
- const { signal, stdoutFile, ...rest } = options;
1436
+ const { signal, stdoutFile, onSpawn, ...rest } = options;
971
1437
  const inv = resolveInvocation(command, args, { env: rest.env ?? process.env });
972
1438
  if (inv.error) return Promise.resolve({ status: null, stdout: "", stderr: "", error: inv.error });
973
1439
  if (signal?.aborted) return Promise.resolve({ status: null, stdout: "", stderr: "", error: new Error("cancelled"), cancelled: true });
974
1440
  return new Promise((resolve) => {
975
1441
  let stdout = ""; let stderr = ""; let error = null;
976
- const keep = (buf, chunk) => { const next = buf + chunk; return next.length > OUTPUT_KEEP ? next.slice(-OUTPUT_KEEP) : next; };
1442
+ let truncated = false;
1443
+ const keep = (buf, chunk) => { const next = buf + chunk; if (next.length > OUTPUT_KEEP) { truncated = true; return next.slice(-OUTPUT_KEEP); } return next; };
977
1444
  let child;
978
1445
  try {
979
- child = spawnChild(inv.file, inv.args, { windowsHide: true, windowsVerbatimArguments: inv.verbatim, ...(signal && process.platform !== "win32" ? { detached: true } : {}), ...rest, shell: false, stdio: ["ignore", "pipe", "pipe"] });
1446
+ // stdin is closed: no scan reads it, and an open pipe nobody writes can block a tool that probes it (Windows).
1447
+ // A detached child (POSIX, its own group for the abort kill) is recorded under the held build ownership (build-ownership.mjs).
1448
+ child = spawnUnderOwnership(spawnChild, inv.file, inv.args, { windowsHide: true, windowsVerbatimArguments: inv.verbatim, stdio: ["ignore", "pipe", "pipe"], ...(signal && process.platform !== "win32" ? { detached: true } : {}), ...rest, shell: false, stdio: ["ignore", "pipe", "pipe"] });
980
1449
  } catch (e) { resolve({ status: null, stdout: "", stderr: "", error: e }); return; }
1450
+ if (onSpawn && child.pid) onSpawn(child.pid);
981
1451
  const onAbort = () => killProcessTree(child);
982
1452
  signal?.addEventListener("abort", onAbort, { once: true });
983
1453
  child.stdout.setEncoding("utf8"); child.stderr.setEncoding("utf8");
@@ -989,7 +1459,7 @@ export function defaultSpawnAsync(command, args, options = {}) {
989
1459
  if (sink) await new Promise((done) => (sink.writableFinished ? done() : sink.on("finish", done).on("error", done)));
990
1460
  signal?.removeEventListener("abort", onAbort);
991
1461
  const cancelled = signal?.aborted === true;
992
- resolve({ status, stdout, stderr, ...(error ? { error } : cancelled ? { error: new Error("cancelled") } : {}), ...(cancelled ? { cancelled: true } : {}) });
1462
+ resolve({ status, stdout, stderr, ...(truncated ? { truncated: true } : {}), ...(error ? { error } : cancelled ? { error: new Error("cancelled") } : {}), ...(cancelled ? { cancelled: true } : {}) });
993
1463
  });
994
1464
  if (signal?.aborted) onAbort();
995
1465
  });
@@ -1000,6 +1470,80 @@ export function defaultSpawnAsync(command, args, options = {}) {
1000
1470
  // time; a test scan marked `quiet` (ci/gate.config.json `rust.<crate>.quietTests`: the crate's tests share ports, devices,
1001
1471
  // wall-clock budgets or the broker's own queue, so they fail under load) runs last, one at a time, under the broker's quiet
1002
1472
  // lease when the gate can take one (runScans `quietLane`).
1473
+ /** The counts a test runner printed last: node:test's `ℹ tests N` / `ℹ pass` / `ℹ fail` / `ℹ skipped` / `ℹ todo` lines, or vitest's
1474
+ * `Tests N passed | M skipped | K failed (T)` line. Null when neither is present. Several node:test summaries (a script that runs
1475
+ * the runner more than once) are summed. */
1476
+ /** null when a test summary is evidence of a pass; otherwise { code, detail }. Used for fresh and cached test rows alike. */
1477
+ export function testResultsProblem(results) {
1478
+ if (!results) return { code: "NO_TEST_SUMMARY", detail: "no test counts recorded" };
1479
+ if (!(results.tests > 0)) return { code: "NO_TESTS", detail: "the test scan executed no test" };
1480
+ if (results.fail > 0) return { code: "SUMMARY_FAILED", detail: `the test runner exited 0 but its summary reports ${results.fail} failed test(s)` };
1481
+ // A suite that ran tests but passed none of them (every test skipped or todo) proves nothing (D17, rightkit#1236).
1482
+ if (!(results.pass > 0)) return { code: "ALL_SKIPPED", detail: `the test scan passed no test: ${results.skipped ?? 0} skipped, ${results.todo ?? 0} todo of ${results.tests}` };
1483
+ return null;
1484
+ }
1485
+
1486
+ export function parseTestSummary(text) {
1487
+ const lines = String(text ?? "").split(/\r?\n/);
1488
+ const node = { tests: 0, pass: 0, fail: 0, skipped: 0, todo: 0 }; let sawNode = false;
1489
+ for (const line of lines) {
1490
+ const m = /^[ℹ#]\s+(tests|pass|fail|skipped|todo)\s+(\d+)\s*$/.exec(line.trim());
1491
+ if (m) { node[m[1]] += Number(m[2]); sawNode = true; }
1492
+ }
1493
+ if (sawNode) return { runner: "node:test", ...node };
1494
+ const vt = lines.map((l) => l.replace(/\x1b\[[0-9;]*m/g, "")).reverse().find((l) => /^\s*Tests\s+.*\(\d+\)\s*$/.test(l));
1495
+ if (vt) {
1496
+ const n = (re) => Number((re.exec(vt) ?? [0, 0])[1]);
1497
+ const tests = Number(/\((\d+)\)\s*$/.exec(vt)[1]);
1498
+ return { runner: "vitest", tests, pass: n(/(\d+) passed/), fail: n(/(\d+) failed/), skipped: n(/(\d+) skipped/), todo: n(/(\d+) todo/) };
1499
+ }
1500
+ return null;
1501
+ }
1502
+
1503
+ /** The largest inline `run:` script a workflow may carry while the pinned actionlint is 1.7.12 (rhysd/actionlint#702). */
1504
+ export const RUN_SCRIPT_LIMIT_BYTES = 3800;
1505
+ /** Every block `run:` script (`run: |` or `run: >`) of a workflow text larger than `limit` bytes, as "<file>:<line> <bytes> bytes".
1506
+ * Line-based and conservative: the block is every following line indented deeper than the `run:` key (blank lines included),
1507
+ * with that common indentation removed, as YAML reads a literal block. */
1508
+ export function oversizedRunScripts(text, file, limit = RUN_SCRIPT_LIMIT_BYTES) {
1509
+ const lines = String(text).split(/\r?\n/);
1510
+ const out = [];
1511
+ for (let i = 0; i < lines.length; i += 1) {
1512
+ const m = /^(\s*)(?:-\s+)?run:\s*[|>][-+0-9]*\s*$/.exec(lines[i]);
1513
+ if (!m) continue;
1514
+ const keyIndent = lines[i].indexOf("run:");
1515
+ const body = [];
1516
+ let j = i + 1;
1517
+ for (; j < lines.length; j += 1) {
1518
+ const line = lines[j];
1519
+ if (line.trim() === "") { body.push(""); continue; }
1520
+ if (line.length - line.trimStart().length <= keyIndent) break;
1521
+ body.push(line);
1522
+ }
1523
+ while (body.length && body[body.length - 1] === "") body.pop();
1524
+ const indent = Math.min(...body.filter(Boolean).map((l) => l.length - l.trimStart().length));
1525
+ const script = body.map((l) => l.slice(Number.isFinite(indent) ? indent : 0)).join("\n") + "\n";
1526
+ const bytes = Buffer.byteLength(script);
1527
+ if (bytes > limit) out.push(`${file}:${i + 1} ${bytes} bytes`);
1528
+ i = j - 1;
1529
+ }
1530
+ return out;
1531
+ }
1532
+
1533
+ /** The time limit of one scan: its own `timeoutMs`, then the run's `scanTimeoutMs`, else 5 min for actionlint, 30 min for class A and
1534
+ * 60 min for class B (a cold compile of the largest crate). */
1535
+ /** The broker's quiet lease ceiling (rustright-state quiet.rs QUIET_LEASE_MAX_TTL_MS) and the quiet suite timeout under it. */
1536
+ export const QUIET_LEASE_MAX_MS = 30 * 60_000;
1537
+ export const QUIET_SCAN_MAX_MS = 26 * 60_000;
1538
+ /** A tool version probe's bound; with QUIET_SCAN_MAX_MS it keeps a whole leased quiet suite (probes + run) under the 30 min lease. */
1539
+ export const PROBE_TIMEOUT_MS = 60_000;
1540
+ export function scanTimeoutMs(scan, options = {}) {
1541
+ if (Number.isFinite(scan?.timeoutMs) && scan.timeoutMs > 0) return scan.timeoutMs;
1542
+ if (Number.isFinite(options.scanTimeoutMs) && options.scanTimeoutMs > 0) return options.scanTimeoutMs;
1543
+ if (scan?.tool === "actionlint") return 5 * 60_000;
1544
+ return scan?.class === SCAN_CLASS.B ? 60 * 60_000 : 30 * 60_000;
1545
+ }
1546
+
1003
1547
  export function laneOf(scan) {
1004
1548
  if (/^(clippy|check|check-features|check-dependent):/.test(scan.id)) return "compile";
1005
1549
  if (/^doc:/.test(scan.id)) return "doc";
@@ -1052,6 +1596,30 @@ async function defaultScanSecrets() {
1052
1596
  return (await import("./publish-cargo.mjs")).scanCrateForSecrets;
1053
1597
  }
1054
1598
 
1599
+ /**
1600
+ * For a content root that is a git worktree's toplevel: a predicate over absolute paths that is true for every git-ignored file or
1601
+ * directory there (`git ls-files --others --ignored --exclude-standard --directory`, which lists an ignored directory once, with a
1602
+ * trailing slash). Null when the root is not a git toplevel (a committed export) or git cannot answer: then nothing is skipped.
1603
+ */
1604
+ export async function ignoredPathSkip(ctx) {
1605
+ const git = async (args) => { try { return await ctx.deps.spawn("git", args, { cwd: ctx.contentRoot, encoding: "utf8", env: ctx.deps.env }); } catch { return null; } };
1606
+ const top = await git(["rev-parse", "--show-toplevel"]);
1607
+ if (!top || top.status !== 0) return null;
1608
+ const same = (a, b) => { try { return fs.realpathSync(a) === fs.realpathSync(b); } catch { return path.resolve(a) === path.resolve(b); } };
1609
+ if (!same(String(top.stdout ?? "").trim(), ctx.contentRoot)) return null;
1610
+ const listed = await git(["ls-files", "-z", "--others", "--ignored", "--exclude-standard", "--directory"]);
1611
+ if (!listed || listed.status !== 0) return null;
1612
+ const entries = String(listed.stdout ?? "").split("\0").filter(Boolean);
1613
+ const dirs = entries.filter((e) => e.endsWith("/"));
1614
+ const files = new Set(entries.filter((e) => !e.endsWith("/")));
1615
+ const root = ctx.contentRoot;
1616
+ return (absolute) => {
1617
+ const rel = path.relative(root, absolute).replaceAll("\\", "/");
1618
+ if (!rel || rel.startsWith("..")) return false;
1619
+ return files.has(rel) || dirs.some((d) => rel === d.slice(0, -1) || rel.startsWith(d));
1620
+ };
1621
+ }
1622
+
1055
1623
  // What rustfmt needs from a Cargo.toml, read by table so an `edition` under [package.metadata.x] or [dependencies.x] is not the
1056
1624
  // package's: `pkg` is the [package] edition (a literal year, or "workspace" for `edition.workspace = true` / `edition = { workspace = true }`),
1057
1625
  // `ws` the [workspace.package] edition literal, `isWorkspace` whether the manifest declares a [workspace]. An `edition` entry in one of
@@ -1196,11 +1764,15 @@ const INTERNAL = {
1196
1764
  // Each violation names its repository-relative path, so a unit that is both a crate and a package reports a file once.
1197
1765
  "secret-scan": async (s, ctx) => {
1198
1766
  const scan = await ctx.deps.scanSecrets();
1767
+ // A committed export holds only tracked files. A live tree (--to dev|test: the content root is the checkout itself) also holds
1768
+ // git-ignored build output (.right-release/, DerivedData, a vendored checkout under it), which the gate never judges: the scanner
1769
+ // skips what `git ls-files --ignored` names there. Outside a git toplevel nothing is skipped (Pulse, 2026-10-27).
1770
+ const skip = await ignoredPathSkip(ctx);
1199
1771
  const bad = new Set();
1200
1772
  for (const dir of s.dirs) {
1201
1773
  const abs = path.join(ctx.contentRoot, dir);
1202
1774
  if (!ctx.deps.exists(abs)) { bad.add(`${dir}: directory not found`); continue; }
1203
- for (const v of scan(abs, { envTemplates: true, repoRoot: ctx.contentRoot })) bad.add(v);
1775
+ for (const v of scan(abs, { envTemplates: true, repoRoot: ctx.contentRoot, skip })) bad.add(v);
1204
1776
  }
1205
1777
  return [...bad];
1206
1778
  },
@@ -1225,8 +1797,8 @@ export async function runScans(plan, options = {}) {
1225
1797
  const exclude = options.exclude ?? (() => false);
1226
1798
  // An AbortSignal: once aborted, no further scan starts, running children are killed (process tree) and every unfinished scan is "not-run" with code CANCELLED.
1227
1799
  const signal = options.signal; const sig = signal ? { signal } : {};
1228
- const { contentRoot, liveRoot } = plan.roots;
1229
- const rootDir = (r) => (r === "content" ? contentRoot : r === "parent" ? path.dirname(contentRoot) : liveRoot);
1800
+ const { contentRoot, liveRoot, cargoLiveRoot = null } = plan.roots;
1801
+ const rootDir = (r) => (r === "content" ? contentRoot : r === "parent" ? path.dirname(contentRoot) : r === "cargoLive" ? (cargoLiveRoot ?? liveRoot) : liveRoot);
1230
1802
  const selected = plan.scans.filter((s) => classes.includes(s.class) && !exclude(s));
1231
1803
  const ordered = [...selected.filter((s) => s.class === SCAN_CLASS.A), ...selected.filter((s) => s.class === SCAN_CLASS.B)];
1232
1804
  const ctx = { deps, contentRoot, read: (f) => deps.readFile(path.join(contentRoot, f)) };
@@ -1256,7 +1828,7 @@ export async function runScans(plan, options = {}) {
1256
1828
  const classBBlocked = !inCallA && !priorClassAValid;
1257
1829
 
1258
1830
  const toolCommand = (tool) => (tool === "node" ? deps.execPath : tool === "rustfmt" ? deps.findRustfmt({ env: deps.env, exists: deps.exists, readdir: deps.readdir, platform: deps.platform, spawn: deps.lookupSpawn }) : tool);
1259
- async function probe(tool) {
1831
+ async function probe(tool, scanDir = null) {
1260
1832
  if (probed.has(tool)) return probed.get(tool);
1261
1833
  let outcome;
1262
1834
  const command = toolCommand(tool);
@@ -1264,7 +1836,15 @@ export async function runScans(plan, options = {}) {
1264
1836
  else {
1265
1837
  // A version probe never runs inside the repository: a tool started there reads the repository's own configuration first
1266
1838
  // (pnpm's .npmrc and pnpmfile, corepack's packageManager pin, cargo's .cargo/config), before any scan's protections apply.
1267
- const r = await deps.spawn(command, PROBES[tool] ?? ["--version"], { cwd: deps.probeDir ?? probeDir(), env: { ...deps.env, ...PNPM_NO_HOOKS_ENV, COREPACK_ENABLE_AUTO_PIN: "0" }, ...sig });
1839
+ // rustright is the one exception: the RustRight client refuses to run anywhere that is not a Cargo workspace inside a repository
1840
+ // (RK_NOT_PRIMARY_CHECKOUT, "workspace discovery failed"), so from a neutral directory every cargo scan read as TOOL_MISSING on
1841
+ // every broker host (rightkit#1215). Its probe runs where the scan itself will run; `cargo --version` reads no repository config.
1842
+ const cwd = (tool === "rustright" || (tool === "cargo" && cargoLiveRoot)) && scanDir ? scanDir : (deps.probeDir ?? probeDir());
1843
+ // A version probe is bounded (PROBE_TIMEOUT_MS): a hung tool reads as unavailable instead of holding a lane, or a quiet
1844
+ // lease, open.
1845
+ const probeMs = Number.isFinite(options.probeTimeoutMs) && options.probeTimeoutMs > 0 ? options.probeTimeoutMs : PROBE_TIMEOUT_MS;
1846
+ 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 });
1268
1848
  if (!r || r.error || r.status !== 0) outcome = { ok: false, why: r?.error?.message ?? `exit ${r?.status}` };
1269
1849
  else {
1270
1850
  const text = String(r.stdout || r.stderr || "");
@@ -1284,7 +1864,14 @@ export async function runScans(plan, options = {}) {
1284
1864
  const quietLane = options.quietLane && typeof options.quietLane.acquire === "function" ? options.quietLane : null;
1285
1865
  const onResult = typeof options.onResult === "function" ? options.onResult : () => {};
1286
1866
  const total = ordered.length; let done = 0;
1867
+ // Every class A failure latches, whichever branch produced it (Astra, 2026-10-26: two early returns bypassed the latch below, so a
1868
+ // missing advisory database or a failed deny metadata could be followed by a class B compile). Class B reads the latch before it starts.
1287
1869
  async function runOne(s, extraEnv = null) {
1870
+ const row = await runOneBody(s, extraEnv);
1871
+ if (row.status === "fail" && s.class === SCAN_CLASS.A) classAFailed = true;
1872
+ return row;
1873
+ }
1874
+ async function runOneBody(s, extraEnv = null) {
1288
1875
  if (signal?.aborted) return { id: s.id, class: s.class, command: s.command, tool: s.tool ?? null, toolVersion: null, status: "not-run", code: "CANCELLED", detail: "cancelled before it started" };
1289
1876
  const started = Date.now();
1290
1877
  const row = await scanRow(s, extraEnv);
@@ -1292,18 +1879,49 @@ export async function runScans(plan, options = {}) {
1292
1879
  try { onResult(row, { done, total, seconds: Math.round((Date.now() - started) / 1000) }); } catch { /* reporting never fails a scan */ }
1293
1880
  return row;
1294
1881
  }
1295
- // Class A in order (a failure stops class B); then class B by lane: compile lanes (`jobs`) beside the doc lane, then the test
1296
- // lane (`testJobs`), then the quiet lane one at a time under the broker's quiet lease, so the tests that fail under load run on
1297
- // an otherwise quiet machine, as CI's shards run tests after their build. With jobs = 1 and testJobs = 1 the order is the same.
1882
+ // Class A may run concurrently. Every class B lane runs serially: tests and docs can compile too, and one checkout gets one build.
1298
1883
  const aScans = ordered.filter((s) => s.class === SCAN_CLASS.A);
1299
1884
  const bScans = ordered.filter((s) => s.class === SCAN_CLASS.B);
1300
1885
  // Class A scans are independent content checks (and short broker calls: metadata, package list, deny, audit): they run
1301
1886
  // `classAJobs` at a time (default: `jobs`); the rows keep plan order. Class B starts only once every class A row is in.
1302
1887
  const aJobs = Math.max(1, Number(options.classAJobs ?? jobs) || jobs);
1303
1888
  {
1304
- const slots = new Array(aScans.length); const queue = aScans.map((_, i) => i);
1305
- const drain = async () => { while (queue.length) { const i = queue.shift(); slots[i] = await runOne(aScans[i]); } };
1306
- await Promise.all(Array.from({ length: aJobs }, drain));
1889
+ // Class A is everything mechanical and deterministic that does not compile (Adrian, 2026-10-27): the content checks first,
1890
+ // `classAJobs` at a time, then the class A tests (kind "test"), `testJobs` at a time, then the quiet ones alone under the
1891
+ // broker's quiet lease. A failed content check stops the tests: they run on content class A already judged.
1892
+ const slots = new Array(aScans.length);
1893
+ const content = []; const tests = []; const quietTests = [];
1894
+ aScans.forEach((s, i) => (s.kind === "test" ? (s.quiet === true ? quietTests : tests) : content).push(i));
1895
+ const drain = (queue, extraEnv = null) => async () => {
1896
+ while (queue.length) {
1897
+ const i = queue.shift();
1898
+ slots[i] = aScans[i].kind === "test" && classAFailed
1899
+ ? { id: aScans[i].id, class: aScans[i].class, command: aScans[i].command, tool: aScans[i].tool ?? null, toolVersion: null, status: "not-run", detail: "a class A content check failed; the class A tests start only after it passes" }
1900
+ : await runOne(aScans[i], extraEnv);
1901
+ }
1902
+ };
1903
+ await Promise.all(Array.from({ length: aJobs }, drain(content)));
1904
+ await Promise.all(Array.from({ length: testJobs }, drain(tests)));
1905
+ if (quietTests.length) {
1906
+ // One broker quiet lease per quiet suite (the broker caps a lease at 30 min and has no renewal): each suite runs alone,
1907
+ // under its own lease, with a timeout shorter than the lease, so a lease never expires while its suite still runs.
1908
+ for (const i of quietTests) {
1909
+ if (classAFailed || signal?.aborted) {
1910
+ slots[i] = { id: aScans[i].id, class: aScans[i].class, command: aScans[i].command, tool: aScans[i].tool ?? null, toolVersion: null, status: "not-run", detail: signal?.aborted ? "cancelled" : "a class A check failed; the quiet suites start only on a green class A" };
1911
+ continue;
1912
+ }
1913
+ let lease = null;
1914
+ if (quietLane) { try { lease = (await quietLane.acquire({ ttlMs: QUIET_LEASE_MAX_MS })) ?? null; } catch { lease = null; } }
1915
+ if (!lease && options.requireQuietLease) {
1916
+ slots[i] = { id: aScans[i].id, class: aScans[i].class, command: aScans[i].command, tool: aScans[i].tool ?? null, toolVersion: null, status: "fail", code: "QUIET_LEASE_UNAVAILABLE", detail: "the broker's quiet lease could not be held; this conflict-prone suite does not run alongside other jobs on a managed host. Run again when the broker can grant the lease." };
1917
+ classAFailed = true; // a refused class A suite is a red class A: no class B scan starts after it
1918
+ continue;
1919
+ }
1920
+ try { slots[i] = await runOne({ ...aScans[i], timeoutMs: Math.min(scanTimeoutMs(aScans[i], options), QUIET_SCAN_MAX_MS) }, lease?.env ?? null); } finally {
1921
+ if (lease && typeof quietLane.release === "function") { try { await quietLane.release(lease.id); } catch { /* the lease expires on its own */ } }
1922
+ }
1923
+ }
1924
+ }
1307
1925
  results.push(...slots);
1308
1926
  }
1309
1927
  {
@@ -1311,14 +1929,25 @@ export async function runScans(plan, options = {}) {
1311
1929
  const lanes = { compile: [], doc: [], tests: [], quiet: [] };
1312
1930
  bScans.forEach((s, i) => lanes[laneOf(s)].push(i));
1313
1931
  const drain = async (queue, extraEnv = null) => { while (queue.length) { const i = queue.shift(); slots[i] = await runOne(bScans[i], extraEnv); } };
1314
- await Promise.all([...Array.from({ length: jobs }, () => drain(lanes.compile)), drain(lanes.doc)]);
1315
- await Promise.all(Array.from({ length: testJobs }, () => drain(lanes.tests)));
1932
+ await drain(lanes.compile);
1933
+ await drain(lanes.doc);
1934
+ await drain(lanes.tests);
1316
1935
  if (lanes.quiet.length) {
1317
- let lease = null;
1318
- // No lease when the lane cannot run anyway (class A failed or was not supplied): its rows are not-run without one.
1319
- if (quietLane && !signal?.aborted && !classAFailed && !classBBlocked) { try { lease = (await quietLane.acquire()) ?? null; } catch { lease = null; } }
1320
- try { await drain(lanes.quiet, lease?.env ?? null); } finally {
1321
- if (lease && typeof quietLane.release === "function") { try { await quietLane.release(lease.id); } catch { /* the lease expires on its own */ } }
1936
+ // One lease per quiet suite, as in class A above.
1937
+ for (const i of lanes.quiet) {
1938
+ if (classAFailed || classBBlocked || signal?.aborted) {
1939
+ slots[i] = { id: bScans[i].id, class: bScans[i].class, command: bScans[i].command, tool: bScans[i].tool ?? null, toolVersion: null, status: "not-run", detail: signal?.aborted ? "cancelled" : classAFailed ? "class A is red; nothing in class B starts" : "class B is blocked" };
1940
+ continue;
1941
+ }
1942
+ let lease = null;
1943
+ if (quietLane) { try { lease = (await quietLane.acquire({ ttlMs: QUIET_LEASE_MAX_MS })) ?? null; } catch { lease = null; } }
1944
+ if (!lease && options.requireQuietLease) {
1945
+ slots[i] = { id: bScans[i].id, class: bScans[i].class, command: bScans[i].command, tool: bScans[i].tool ?? null, toolVersion: null, status: "fail", code: "QUIET_LEASE_UNAVAILABLE", detail: "the broker's quiet lease could not be held; this conflict-prone suite does not run alongside other jobs on a managed host. Run again when the broker can grant the lease." };
1946
+ continue;
1947
+ }
1948
+ try { slots[i] = await runOne({ ...bScans[i], timeoutMs: Math.min(scanTimeoutMs(bScans[i], options), QUIET_SCAN_MAX_MS) }, lease?.env ?? null); } finally {
1949
+ if (lease && typeof quietLane.release === "function") { try { await quietLane.release(lease.id); } catch { /* the lease expires on its own */ } }
1950
+ }
1322
1951
  }
1323
1952
  }
1324
1953
  results.push(...slots);
@@ -1368,11 +1997,12 @@ export async function runScans(plan, options = {}) {
1368
1997
  if (out && out.missing) { status = "fail"; code = "TOOL_MISSING"; detail = out.missing; }
1369
1998
  else if (out.length) { status = "fail"; detail = out.join("; "); }
1370
1999
  } else {
1371
- const p = await probe(s.tool);
2000
+ const scanDir = path.join(rootDir(s.root), s.cwd ?? "");
2001
+ const p = await probe(s.tool, scanDir);
1372
2002
  row.toolVersion = p.ok ? p.version : null;
1373
2003
  let missingDep = null; let depVersionProblem = null; const depVersions = [];
1374
2004
  for (const dep of s.requires ?? []) {
1375
- const d = await probe(dep);
2005
+ const d = await probe(dep, scanDir);
1376
2006
  if (!d.ok) { missingDep = `${dep} (${d.why})`; break; }
1377
2007
  if (d.pinProblem) { depVersionProblem = d.pinProblem; break; }
1378
2008
  depVersions.push(d.version);
@@ -1383,7 +2013,10 @@ export async function runScans(plan, options = {}) {
1383
2013
  else if (depVersionProblem) { status = "fail"; code = "TOOL_VERSION"; detail = `TOOL_VERSION: ${s.tool} runs ${depVersionProblem}`; }
1384
2014
  else {
1385
2015
  let cacheKey = null;
1386
- if (cache) {
2016
+ // A cargo request that builds the commit snapshot through the broker (options.snapshotBroker) is never served from or written
2017
+ // to the scan cache: each run must carry its own request and verify the broker's own signed evidence.
2018
+ const viaSnapshotBroker = Boolean(deps.snapshotBroker) && s.root === "cargoLive" && (s.tool === "rustright" || s.tool === "cargo");
2019
+ if (cache && !viaSnapshotBroker) {
1387
2020
  try {
1388
2021
  const digest = cache.digest(s);
1389
2022
  // The analyzers a scan runs through (actionlint's shellcheck) judge it too, so their versions key it as well.
@@ -1391,25 +2024,44 @@ export async function runScans(plan, options = {}) {
1391
2024
  if (digest) cacheKey = scanCacheKey({ id: s.id, command: s.command, toolVersion, env: s.env ?? {}, inheritedEnv: inheritedEnvDigest({ ...deps.env, ...s.env }), digest });
1392
2025
  } catch { cacheKey = null; }
1393
2026
  const hit = cacheKey ? cache.read(cacheKey) : null;
1394
- if (hit && hit.key === cacheKey && hit.row?.status === "pass" && hit.row.id === s.id && hit.row.command === s.command) {
2027
+ // A cached test pass counts only with the counts it recorded: without them its coverage is unknown, so it runs again.
2028
+ // ...and only if it passed at least one test: a cached all-skipped "pass" (written before D17) runs again (rightkit#1236).
2029
+ const testEvidence = s.kind !== "test" || testResultsProblem(hit?.row?.results) === null;
2030
+ if (hit && hit.key === cacheKey && hit.row?.status === "pass" && hit.row.id === s.id && hit.row.command === s.command && testEvidence) {
1395
2031
  cacheHits.push(s.id);
1396
- return { ...row, status: "pass", cached: true, detail: `cached ${String(hit.at ?? "").slice(0, 10) || "earlier"}` };
2032
+ return { ...row, status: "pass", cached: true, detail: `cached ${String(hit.at ?? "").slice(0, 10) || "earlier"}`, ...(s.kind === "test" ? { results: hit.row.results } : {}) };
1397
2033
  }
1398
2034
  }
1399
2035
  let advisory = null; let args = s.args;
1400
2036
  if (s.advisoryDb) {
1401
2037
  try { advisory = prepareAdvisory(s); } catch (e) { return { ...row, status: "fail", code: "ADVISORY_DB", detail: e.message }; }
1402
- // cargo-deny --offline must not fetch the advisory database, but its own `cargo metadata` would then be offline too and
2038
+ // cargo-deny --frozen (--offline + --locked: the nested `cargo fetch` it runs may not rewrite Cargo.lock, so the broker admits it as analysis) must not fetch the advisory database, but its own `cargo metadata` would then be offline too and
1403
2039
  // fail on a runner whose cargo cache is cold. The workspace's metadata is resolved first, online and --locked (the
1404
2040
  // lockfile decides every version), and handed to cargo-deny as a file.
1405
2041
  if (s.advisoryDb === "deny") {
1406
2042
  const at = args.indexOf("deny");
1407
2043
  // The whole document is needed (the concurrent spawn keeps only the tail of stdout), so it streams to the file; a
1408
2044
  // spawn that does not know `stdoutFile` returns stdout, which is written below.
2045
+ // cargo-deny --frozen reads crate sources only from the local cargo cache, and `cargo metadata` does not download them
2046
+ // (fullA-19: rightkit-qa 0.9.1 was missing). They are fetched first, online and --locked (bound to the lockfile, so the
2047
+ // broker admits it as analysis). A fetch that fails, is refused or times out fails the row before metadata or deny run:
2048
+ // an offline or unreachable registry is red, never green.
2049
+ const fetchLimit = scanTimeoutMs(s, options);
2050
+ const fetchTimer = new AbortController();
2051
+ const fetchClock = setTimeout(() => fetchTimer.abort(), fetchLimit);
2052
+ let fetched;
2053
+ try { fetched = await deps.spawn(p.command, [...args.slice(0, at), "fetch", "--locked"], { cwd: path.join(rootDir(s.root), s.cwd ?? ""), env: { ...deps.env, ...s.env }, signal: signal ? AbortSignal.any([signal, fetchTimer.signal]) : fetchTimer.signal, timeout: fetchLimit }); } finally { clearTimeout(fetchClock); }
2054
+ if (!fetched || fetched.error || fetched.status !== 0) {
2055
+ advisory.cleanup?.();
2056
+ if (signal?.aborted) return { ...row, status: "not-run", code: "CANCELLED", detail: "cancelled while running" };
2057
+ if (fetchTimer.signal.aborted || fetched?.error?.code === "ETIMEDOUT") return { ...row, status: "fail", code: "TIMEOUT", detail: `cargo fetch --locked for cargo-deny ran longer than ${Math.round(fetchLimit / 60000)} min and was killed with its process tree` };
2058
+ return { ...row, status: "fail", code: "FETCH", detail: `cargo fetch --locked for cargo-deny failed (crate sources must be local before deny): ${fetched?.error?.message ?? (tail(fetched) || `exit ${fetched?.status}`)}` };
2059
+ }
1409
2060
  const metadataDir = fs.mkdtempSync(path.join(os.tmpdir(), "rk-deny-metadata-"));
1410
2061
  const metadataFile = path.join(metadataDir, "metadata.json");
1411
2062
  const metadata = await deps.spawn(p.command, [...args.slice(0, at), ...metadataArgs({ noDeps: false })], { cwd: path.join(rootDir(s.root), s.cwd ?? ""), env: { ...deps.env, ...s.env }, stdoutFile: metadataFile, ...sig });
1412
- if (!metadata || metadata.error || metadata.status !== 0) { fs.rmSync(metadataDir, { recursive: true, force: true }); advisory.cleanup?.(); return { ...row, status: "fail", code: "EXIT", detail: `cargo metadata for cargo-deny failed: ${metadata?.error?.message ?? (tail(metadata) || `exit ${metadata?.status}`)}` }; }
2063
+ // AUX_METADATA: the auxiliary metadata that feeds deny could not be produced; not a deny finding, so never a deterministic red (rightkit#1257).
2064
+ if (!metadata || metadata.error || metadata.status !== 0) { fs.rmSync(metadataDir, { recursive: true, force: true }); advisory.cleanup?.(); return { ...row, status: "fail", code: "AUX_METADATA", detail: `cargo metadata for cargo-deny failed: ${metadata?.error?.message ?? (tail(metadata) || `exit ${metadata?.status}`)}` }; }
1413
2065
  if (!fs.existsSync(metadataFile)) fs.writeFileSync(metadataFile, String(metadata.stdout ?? ""));
1414
2066
  advisory.metadataPath = metadataFile;
1415
2067
  const overlayCleanup = advisory.cleanup;
@@ -1418,11 +2070,110 @@ export async function runScans(plan, options = {}) {
1418
2070
  args = args.map((a) => (a === "{advisoryDb}" ? advisory.dbDir : a === "{advisoryDenyConfig}" ? advisory.configPath : a === "{advisoryMetadata}" ? advisory.metadataPath : a));
1419
2071
  }
1420
2072
  let r;
1421
- try { r = await deps.spawn(p.command, args.map((a) => (a === "{contentRoot}" ? contentRoot : a)), { cwd: path.join(rootDir(s.root), s.cwd ?? ""), env: { ...deps.env, ...s.env, ...(extraEnv ?? {}) }, ...sig }); } finally { advisory?.cleanup?.(); }
2073
+ // actionlint 1.7.12 writes each run: script to shellcheck's stdin before starting it (rhysd/actionlint#702): a script over the
2074
+ // pipe buffer (~4 KB on Windows) deadlocks. Oversized scripts are refused here, before actionlint starts, so it cannot hang.
2075
+ if (s.runScriptLimit) {
2076
+ const over = s.runScriptLimit.files.flatMap((file) => { try { return oversizedRunScripts(ctx.read(file), file, s.runScriptLimit.bytes); } catch { return [`${file}: unreadable`]; } });
2077
+ if (over.length) return { ...row, status: "fail", code: "RUN_SCRIPT_TOO_LARGE", detail: `run: scripts over ${s.runScriptLimit.bytes} bytes (actionlint 1.7.12 deadlocks on them, rhysd/actionlint#702; split the step): ${over.join("; ")}` };
2078
+ }
2079
+ // Every scan has a time limit (fails closed, names the scan, kills the process tree): a hung tool is a check that never
2080
+ // fails, i.e. a run that never ends (actionlint on the Dell, 2026-10-27).
2081
+ // A tsc row that names TSC_PROJECT checks its package against the committed sources of its workspace siblings: the project is
2082
+ // generated outside the checked tree and removed after the run, so the check reads nothing a commit lacks and writes nothing.
2083
+ let tscProject = null;
2084
+ if (args.includes(TSC_PROJECT)) {
2085
+ try { tscProject = writeTscProject({ contentRoot, packageDir: path.join(rootDir(s.root), s.cwd ?? ""), workspace: s.tscWorkspace ?? [] }); }
2086
+ catch (e) { advisory?.cleanup?.(); return { ...row, status: "fail", code: "TSC_PROJECT", detail: `${s.id}: the check project could not be built from the committed workspace: ${e.message}` }; }
2087
+ args = args.map((a) => (a === TSC_PROJECT ? tscProject.file : a));
2088
+ }
2089
+ const limitMs = scanTimeoutMs(s, options);
2090
+ const timer = new AbortController();
2091
+ const clock = setTimeout(() => timer.abort(), limitMs);
2092
+ const scanSignal = signal ? AbortSignal.any([signal, timer.signal]) : timer.signal;
2093
+ // A class A test row runs under the launch guard (class-a-launch-guard.mjs): its Node processes may not launch a compile.
2094
+ // Every class A row that executes repository code (tests, lint/typecheck with repository config, generators) runs under the
2095
+ // launch guard: class A runs before any compile, so none of them may start one (joint plan step 4; inventory gap 3).
2096
+ const guardEnv = s.class === SCAN_CLASS.A && (s.kind === "test" || s.executes === true) ? launchGuardEnv({ ...deps.env, ...s.env, ...(extraEnv ?? {}) }) : {};
2097
+ // The broker snapshot carrier (joint plan step 4d): this one cargo request names the commit, the snapshot, the ownership
2098
+ // handle and its own argv and working directory, under a fresh nonce; the ownership records the submit first.
2099
+ const finalArgs = args.map((a) => (a === "{contentRoot}" ? contentRoot : a));
2100
+ let carrier = null;
2101
+ if (viaSnapshotBroker) {
2102
+ const sb = deps.snapshotBroker;
2103
+ const cwd = path.join(rootDir(s.root), s.cwd ?? "");
2104
+ const refDir = fs.mkdtempSync(path.join(os.tmpdir(), "rk-snapreq-"));
2105
+ try {
2106
+ const cargoArgv = s.tool === "rustright" ? finalArgs.slice(1) : finalArgs;
2107
+ // The ownership must record the submit, under this gate's handle, before anything is launched: a missing hook or a
2108
+ // mark that did not land refuses the request (the reclaim proof reads broker.submitted).
2109
+ if (typeof sb.markBrokerSubmit !== "function") throw new Error("no build-ownership submit hook: the broker submit cannot be recorded");
2110
+ const marked = sb.markBrokerSubmit();
2111
+ if (marked?.broker?.submitted !== true || marked.handle !== sb.handle) throw new Error(`the build ownership did not record the broker submit under ${sb.handle}`);
2112
+ const made = writeSnapshotRequest({ controlRoot: sb.controlRoot, commit: sb.commit, snapshot: sb.snapshot, declaredInputs: sb.declaredInputs ?? [], handle: sb.handle, primaryCwd: fs.realpathSync.native(cwd), argv: cargoArgv, requestIdFile: path.join(refDir, "request.json") });
2113
+ carrier = { made, refDir, cargoArgv };
2114
+ } catch (e) { fs.rmSync(refDir, { recursive: true, force: true }); clearTimeout(clock); advisory?.cleanup?.(); return { ...row, status: "fail", code: "SNAPSHOT_REQUEST", detail: `the snapshot request for ${s.id} could not be written: ${e.message}` }; }
2115
+ }
2116
+ // From here to the end of its verification the carrier and the request reference are this request's: every exit (cancel,
2117
+ // timeout, a thrown spawn, a refused receipt) removes them.
2118
+ // A wrapped snapshot compile with several Cargo workspaces (options.cargoSentinel, item 7): a scan whose own env binds no
2119
+ // CARGO_TARGET_DIR gets an empty directory of its own as one. Every cargo reached through the wrapper is bound elsewhere; only
2120
+ // a cargo that bypassed it (an absolute path, `rustup run`, a reset PATH) can write here, and the scan then fails below.
2121
+ let sentinel = null;
2122
+ if (options.cargoSentinel && !Object.hasOwn(s.env ?? {}, "CARGO_TARGET_DIR")) {
2123
+ sentinel = path.join(options.cargoSentinel, sha256(s.id).slice(0, 16));
2124
+ fs.rmSync(sentinel, { recursive: true, force: true });
2125
+ fs.mkdirSync(sentinel, { recursive: true });
2126
+ }
2127
+ const sentinelEnv = sentinel ? { CARGO_TARGET_DIR: sentinel, CARGO_BUILD_TARGET_DIR: sentinel } : {};
2128
+ 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?.(); }
1422
2130
  if (signal?.aborted && (!r || r.error || r.status !== 0)) return { ...row, status: "not-run", code: "CANCELLED", detail: "cancelled while running" };
2131
+ 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` };
1423
2132
  if (!r || r.error) { status = "fail"; code = "SPAWN_ERROR"; detail = r?.error?.message ?? "no result"; }
1424
2133
  else if (r.status !== 0) { status = "fail"; code = "EXIT"; detail = tail(r) || `exit ${r.status}`; }
1425
- else if (cacheKey && !signal?.aborted) cache.write(cacheKey, { id: s.id, class: s.class, command: s.command, tool: s.tool ?? null, toolVersion: row.toolVersion, status: "pass" });
2134
+ // cargo-deny reports some failures only as an [ERROR] line and still exits 0 (under the broker, its nested offline
2135
+ // `cargo fetch` of crate sources is refused: "failed to fetch crates"). A deny row passes only with no [ERROR] line in
2136
+ // its complete output; output cut to the kept tail cannot prove that, so it fails too (RR, reviewer 2026-10-28).
2137
+ if (sentinel) {
2138
+ let written = [];
2139
+ try { written = fs.readdirSync(sentinel); } catch (e) { written = [`(unreadable: ${e.message})`]; }
2140
+ 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
+ }
2142
+ if (s.advisoryDb === "deny" && code !== "RK_CARGO_UNBOUND") {
2143
+ const denyError = `${r.stdout ?? ""}\n${r.stderr ?? ""}`.split(/\r?\n/).find((line) => /\[ERROR\]/.test(line));
2144
+ 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"; }
2145
+ else if (denyError) { status = "fail"; code = "DENY_ERROR"; detail = `cargo-deny exited 0 but reported an error: ${denyError.trim().slice(0, 300)}`; }
2146
+ }
2147
+ // The broker's signed account of that request: the receipt verifies against this host's pins and its snapshotEvidence names
2148
+ // this commit, this snapshot unchanged from START to exit, this ownership and the output directory. Anything less fails the row.
2149
+ if (carrier) {
2150
+ try {
2151
+ const ref = readRequestRef(path.join(carrier.refDir, "request.json"));
2152
+ 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) });
2154
+ row.snapshotEvidence = { requestId: ref.requestId, outputDir: evidence.outputDir, treeAtStart: evidence.snapshot.treeAtStart, treeAtExit: evidence.snapshot.treeAtExit };
2155
+ } catch (e) {
2156
+ status = "fail"; code = "SNAPSHOT_EVIDENCE"; detail = `${s.id}: the broker's snapshot evidence did not verify (${e.code ?? "error"}: ${e.message})`;
2157
+ }
2158
+ }
2159
+ } finally {
2160
+ if (carrier) { fs.rmSync(carrier.made.file, { force: true }); fs.rmSync(carrier.refDir, { recursive: true, force: true }); }
2161
+ if (tscProject) fs.rmSync(tscProject.dir, { recursive: true, force: true });
2162
+ }
2163
+ if (status === "fail" && options.scanLogDir && r) {
2164
+ try { fs.mkdirSync(options.scanLogDir, { recursive: true }); const file = path.join(options.scanLogDir, `${s.id.replace(/[^A-Za-z0-9._-]+/g, "_")}.log`); fs.writeFileSync(file, `$ ${s.command ?? s.id}\nexit ${r.status}\n--- stdout\n${r.stdout ?? ""}\n--- stderr\n${r.stderr ?? ""}\n`); row.log = file; detail = `${detail} (full output: ${file})`; } catch { /* the tail in the row remains */ }
2165
+ }
2166
+ if (s.kind === "test" && r && !r.error && r.status === 0) {
2167
+ // What the suite executed and skipped is evidence, not decoration: a later stage reuses this row only with these counts.
2168
+ const counts = parseTestSummary(`${r.stdout ?? ""}\n${r.stderr ?? ""}`);
2169
+ if (counts) row.results = counts;
2170
+ else if (status === "pass") { status = "fail"; code = "NO_TEST_SUMMARY"; detail = "the test scan passed but printed no recognisable summary (node:test or vitest); its coverage cannot be recorded"; }
2171
+ else row.results = null;
2172
+ // One rule for fresh and cached rows (testResultsProblem): tests ran, none failed, at least one passed.
2173
+ const problem = status === "pass" && row.results ? testResultsProblem(row.results) : null;
2174
+ if (problem) { status = "fail"; code = problem.code; detail = problem.detail; }
2175
+ }
2176
+ if (status === "pass" && cacheKey && !signal?.aborted) cache.write(cacheKey, { id: s.id, class: s.class, command: s.command, tool: s.tool ?? null, toolVersion: row.toolVersion, status: "pass", ...(row.results ? { results: row.results } : {}) });
1426
2177
  }
1427
2178
  }
1428
2179
  } catch (e) { status = "fail"; code = "SCAN_ERROR"; detail = e.message; }