@rightkit/release 0.6.15 → 0.6.20

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 (65) hide show
  1. package/README.md +7 -3
  2. package/admission-host.mjs +9 -5
  3. package/app-closure.mjs +9 -3
  4. package/bin/darwin/rightkit-swap +0 -0
  5. package/bin/rightkit-gate.mjs +469 -102
  6. package/bin/rightkit-linux-setup.mjs +15 -0
  7. package/broker-admission.mjs +184 -0
  8. package/broker-wire.mjs +15 -3
  9. package/build-launch.mjs +4 -1
  10. package/build-ownership.mjs +125 -36
  11. package/build-release.mjs +39 -7
  12. package/cargo-contract.mjs +4 -1
  13. package/cargo-guard.mjs +25 -5
  14. package/cargo-target-shim.mjs +56 -7
  15. package/clippy-route.mjs +8 -7
  16. package/crate-graph.mjs +5 -1
  17. package/credential-provider.mjs +174 -0
  18. package/deterministic-scans.mjs +256 -41
  19. package/development.mjs +8 -0
  20. package/disk-policy.mjs +2 -2
  21. package/gate-executor.mjs +5 -3
  22. package/gate-stages.mjs +34 -3
  23. package/gradle-lock.mjs +206 -0
  24. package/heavy-command.mjs +33 -2
  25. package/linux-artifacts.mjs +2 -1
  26. package/linux-setup.mjs +106 -0
  27. package/local-publish.mjs +21 -10
  28. package/local-qualify.mjs +9 -3
  29. package/managed-cargo.mjs +54 -18
  30. package/npm-license-notices.mjs +49 -1
  31. package/ownership-lockfiles.mjs +27 -4
  32. package/ownership.mjs +5 -1
  33. package/package.json +17 -11
  34. package/protected-credentials.mjs +13 -5
  35. package/publication-gate.mjs +9 -4
  36. package/python-lock.mjs +209 -0
  37. package/qa-admission-policy.json +32 -0
  38. package/qa-layout.mjs +229 -0
  39. package/qa-runner.mjs +156 -0
  40. package/release-artifact-path.mjs +29 -3
  41. package/release-config.mjs +1 -1
  42. package/release-qa-command.mjs +58 -8
  43. package/release-qa-policy.mjs +5 -3
  44. package/release-source-identity.mjs +1 -0
  45. package/release.mjs +10 -2
  46. package/rightkit-ownership.json +1465 -79
  47. package/rightkit-registry-resolver.mjs +7 -3
  48. package/rightkit-versions.json +173 -36
  49. package/runtime-budget-classes.json +4 -0
  50. package/runtime-budgets.mjs +5 -1
  51. package/shell-contract.mjs +1 -1
  52. package/snapshot-broker.mjs +107 -9
  53. package/snapshot-check.mjs +2 -0
  54. package/snapshot-request.mjs +20 -3
  55. package/snapshot.mjs +11 -4
  56. package/stage-commands.mjs +2 -1
  57. package/stage-ledger.mjs +15 -0
  58. package/stage-sidecar.mjs +17 -3
  59. package/suite-signing.mjs +40 -4
  60. package/swift-lock.mjs +334 -0
  61. package/test-command.mjs +13 -3
  62. package/tool-build.mjs +156 -0
  63. package/unit-gate.mjs +9 -2
  64. package/windows-identity-plan.mjs +119 -0
  65. package/windows-identity.mjs +136 -0
@@ -28,11 +28,15 @@ import { checkAdvisoryExceptions } from "./advisory-exceptions.mjs";
28
28
  import { CLIPPY_UNIVERSAL_FEATURE_FLAGS, canonicalJson, clippyBatchPlan, clippySelectionOf, digestScanSet, requiredScanIds, rustCratePlan, rustScanId } from "./scan-digest.mjs";
29
29
  import { reach } from "./lock-graph.mjs";
30
30
  import { cargoManifestState } from "./cargo-manifest-text.mjs";
31
+ import { GRADLE_BUILD_FILE, gradleLockProblems, gradleOwnerOf } from "./gradle-lock.mjs";
32
+ import { PYTHON_LOCK_FILE, pythonLockProblems } from "./python-lock.mjs";
33
+ import { lockOwnerOf, packageLockProblems, xcodeProjectLockProblems } from "./swift-lock.mjs";
31
34
  import { LAUNCH_GUARD_IMPORT } from "./class-a-launch-guard.mjs";
32
35
  import { advisoryCacheRoot, ensureAdvisoryDb, prepareDenyOverlay, readAdvisoryPin } from "./advisory-db.mjs";
33
36
  import { observedEnvOf, readRequestRef, verifySnapshotReceipt, writeSnapshotRequest } from "./snapshot-request.mjs";
34
37
  import { SCAN_TOKEN_ENV, appendLedger, readLedger, scanTokenOf, unclaimedLedgers } from "./cargo-ledger.mjs";
35
- import { brokerSection, carryBrokerAdapter, verifyBrokerLedger } from "./snapshot-broker.mjs";
38
+ import { brokerSection, carryBrokerAdapterAsync, verifyBrokerLedger } from "./snapshot-broker.mjs";
39
+ import { ADMISSION_POLL_MS, CARRIED_CANCEL_GRACE_MS, QUEUED_CEILING_FACTOR, createBrokerLive, createRunClock, createStatusPoller, watchBrokerAdmission } from "./broker-admission.mjs";
36
40
  import { CANCEL_WAIT_ENV, operationDeadlineEnv } from "./broker-wire.mjs";
37
41
  import { removeCarrier } from "./carrier-census.mjs";
38
42
 
@@ -46,7 +50,7 @@ export const CHECKER_IDENTITY = (() => {
46
50
  try {
47
51
  const p = JSON.parse(fs.readFileSync(new URL("./package.json", import.meta.url), "utf8"));
48
52
  const h = crypto.createHash("sha256");
49
- for (const f of ["./deterministic-scans.mjs", "./scan-digest.mjs"]) { h.update(f); h.update("\0"); h.update(fs.readFileSync(new URL(f, import.meta.url))); h.update("\0"); }
53
+ for (const f of ["./deterministic-scans.mjs", "./scan-digest.mjs", "./swift-lock.mjs", "./gradle-lock.mjs", "./python-lock.mjs"]) { h.update(f); h.update("\0"); h.update(fs.readFileSync(new URL(f, import.meta.url))); h.update("\0"); }
50
54
  return `${p.name}@${p.version}#${h.digest("hex").slice(0, 16)}`;
51
55
  } catch { return null; }
52
56
  })();
@@ -64,7 +68,7 @@ const PROBE_LEDGER_TOKEN = scanTokenOf("tool-probe");
64
68
  /** Scan id prefixes (the text before the first ":") that belong to each class. */
65
69
  export const CLASS_A_SCAN_PREFIXES = Object.freeze([
66
70
  "rustfmt", "node-check", "json-parse", "toml-parse", "yaml-parse", "ownership-catalog",
67
- "actionlint", "shellcheck", "pnpm-lockfile", "pnpm-audit", "cargo-metadata", "cargo-deny", "cargo-audit", "cargo-package-list",
71
+ "actionlint", "shellcheck", "pnpm-lockfile", "swift-lock", "gradle-lock", "python-lock", "pnpm-audit", "cargo-metadata", "cargo-deny", "cargo-audit", "cargo-package-list",
68
72
  "changelog-version", "license", "secret-scan", "path-policy", "advisory-exceptions", "suite-api",
69
73
  // Everything mechanical and deterministic that does not compile is class A (Adrian, 2026-10-27): typecheck, lint, generated docs.
70
74
  "tsc", "eslint", "biome", "generated-docs",
@@ -314,9 +318,18 @@ export function writeTscProject({ contentRoot, packageDir, workspace, tmpdir = o
314
318
  if (typeof manifest.name !== "string" || path.resolve(dir) === path.resolve(packageDir)) continue;
315
319
  const exported = manifest.exports && typeof manifest.exports === "object" ? Object.entries(manifest.exports) : [];
316
320
  for (const [sub, target] of exported) {
317
- const types = target && typeof target === "object" ? target.types : null;
321
+ // Types sit on the target, or under one export condition (`development` for a test-only subpath, then `default`).
322
+ const types = target && typeof target === "object" ? (target.types ?? target.development?.types ?? target.default?.types ?? null) : null;
318
323
  const m = typeof types === "string" ? DIST_TYPES.exec(types) : null;
319
324
  if (!m) continue;
325
+ // A subpath pattern (`./*` -> `./dist/*.d.ts`) maps as a TypeScript paths pattern onto the same pattern in src; the sibling
326
+ // must commit a src directory for it to resolve against.
327
+ if (sub.includes("*")) {
328
+ if (!sub.endsWith("*") || (sub.match(/\*/g) ?? []).length !== 1 || (m[1].match(/\*/g) ?? []).length !== 1) throw new Error(`${manifest.name} exports ${sub} with types ${types}: only one trailing * pattern maps to src`);
329
+ if (!fs.existsSync(path.join(dir, "src"))) throw new Error(`${manifest.name} exports ${sub} with types ${types}, and ${rel}/src does not exist`);
330
+ paths[`${manifest.name}${sub.slice(1)}`] = [`src/${m[1]}.ts`, `src/${m[1]}.tsx`, `src/${m[1]}/index.ts`, `src/${m[1]}/index.tsx`].map((f) => path.join(dir, f));
331
+ continue;
332
+ }
320
333
  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));
321
334
  if (!source) throw new Error(`${manifest.name} exports ${sub} with types ${types}, and ${rel}/src has no committed source for it`);
322
335
  paths[sub === "." ? manifest.name : `${manifest.name}${sub.slice(1)}`] = [source];
@@ -867,6 +880,14 @@ export function planScans({
867
880
  crateFeatures = {},
868
881
  // ci/gate.config.json `npm`: per-package gate settings (`quietTests`), read by the package's unit name (packages/<dir> → rightkit-<dir>).
869
882
  packageFeatures = {},
883
+ // rightkit#1333 (PIPELINE-ARCHITECTURE P7: no e2e through the cargo test runner): the repository-relative directories of the end-to-end crates
884
+ // (app-check.mjs e2eCrates: a non-optional rightkit-qa dependency and a driver module), computed by the caller from the commit's files. Such a
885
+ // crate's test scan keeps its id and its class B compile but runs with --no-run: its tests need a built app and run only in the qualify stage
886
+ // (`qa.journeys` of the release config, outside the broker), where the gate owes them (result.qualifyOwed).
887
+ e2eCrateDirs = null,
888
+ // rightkit#1332: ci/gate.config.json `swift`: { "<package dir>": { qualifyOnly: { reason } } }. A Swift package that cannot build from the bare
889
+ // snapshot (a prep step, a prebuilt native library) is not built by class B; it stays in result.qualifyOwed with its reason. Never silent.
890
+ swiftPackages = {},
870
891
  // True for full class A on the live checkout, which runs the repository's own code anyway (tests, typecheck, lint).
871
892
  liveExecution = false,
872
893
  // Crates a workspace-wide Rust change (Cargo.lock, vendor/) actually reaches, computed by the caller from
@@ -934,7 +955,7 @@ export function planScans({
934
955
  const inputDigest = sha256(canonicalJson({ files: [...files].sort().map((f) => [f, bytesDigest(f)]), refused: refusals.map((r) => r.raw).sort() }));
935
956
  const under = (prefix) => files.filter((f) => f.startsWith(prefix));
936
957
  if (!["compile", "release"].includes(tier)) throw new Error(`planScans: tier must be compile or release, not ${JSON.stringify(tier)}`);
937
- let scans = []; const notes = [];
958
+ let scans = []; const notes = []; const qualifyOwed = [];
938
959
  if (cargoLiveRoot) notes.push(`broker host: class A cargo scans run in the snapshot as carried read-only requests, class B compiles are carried requests started from the primary checkout ${cargoLiveRoot} (uncommitted edits there never reach a scan)`);
939
960
  // 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
940
961
  // live-checkout run (or a compile with another map) is taken for one that writes to these directories.
@@ -945,7 +966,8 @@ export function planScans({
945
966
  scans.push(entry);
946
967
  };
947
968
  const A = SCAN_CLASS.A; const B = SCAN_CLASS.B;
948
- const refuse = (what, why) => add({ id: `path-policy:${sha256(`${what}\0${why}`).slice(0, 12)}`, class: A, internal: "refuse", detail: `${what}: ${why}; refused, never run` });
969
+ // `unit` names the npm or Rust unit a refusal belongs to, so a unit-scoped check drops it with that unit (app-closure.mjs scopeScans).
970
+ const refuse = (what, why, unit = null) => add({ id: `path-policy:${sha256(`${what}\0${why}`).slice(0, 12)}`, class: A, internal: "refuse", detail: `${what}: ${why}; refused, never run`, ...(unit ? { unit } : {}) });
949
971
  for (const r of refusals) refuse(`changed path ${JSON.stringify(r.raw)}`, r.why);
950
972
  // A rust unit's name may be `<cargoDir>:<crate>`; the part that reaches argv (`-p`) is the crate, and the workspace directory is
951
973
  // checked as a plain path where the unit is registered.
@@ -1355,10 +1377,43 @@ export function planScans({
1355
1377
  }
1356
1378
  for (const dir of [...packages].sort()) {
1357
1379
  const id = dir === "" || dir === "." ? "swift-build" : `swift-build:${dir}`;
1358
- if (platform === "darwin") add({ id, class: B, tool: "swift", args: ["build"], ...(dir === "" || dir === "." ? {} : { cwd: dir }) });
1380
+ const declared = swiftPackages?.[dir === "" ? "." : dir]?.qualifyOnly;
1381
+ const reason = typeof declared?.reason === "string" && declared.reason.trim() !== "" ? declared.reason.replace(/\s+/g, " ").trim() : null;
1382
+ if (declared && !reason) notes.push(`ci/gate.config.json swift.${dir}.qualifyOnly is ignored: it must be { "reason": "<why this package cannot build from the bare snapshot>" } with a non-empty reason; ${id} is planned`);
1383
+ if (reason) { qualifyOwed.push({ id, kind: "swift-build", dir: dir === "" ? "." : dir, reason }); notes.push(`${id} is not built by class B (ci/gate.config.json swift.${dir}.qualifyOnly: ${reason}); it is owed to the qualify stage`); }
1384
+ // The committed Package.resolved is the lockfile: like cargo --locked and pnpm --frozen-lockfile, a stale one fails here with
1385
+ // SwiftPM's own message instead of SwiftPM rewriting it inside the snapshot (seen only after the compile as a changed tree).
1386
+ else if (platform === "darwin") add({ id, class: B, tool: "swift", args: ["build", "--disable-automatic-resolution"], ...(dir === "" || dir === "." ? {} : { cwd: dir }) });
1359
1387
  else notes.push(`swift files changed in ${dir === "" || dir === "." ? "the root package" : dir}; \`swift build\` is required on the Mac host before its push`);
1360
1388
  }
1361
1389
  }
1390
+ // Class A, file-only (rightkit swift-lock.mjs): the committed Package.resolved (or an Xcode project's) must match the declared dependencies, like
1391
+ // cargo --locked and pnpm --frozen-lockfile (Adrian, 2026-11-01: a stale lockfile must never reach the compile). One scan per Swift package or
1392
+ // .xcodeproj a changed file belongs to, planned on every platform and for qualify-only packages too: it reads files and builds nothing. It sets no
1393
+ // `executes`, so a scoped class A run keeps it (app-closure.mjs scopeScans keeps file-only checks), and it never needs a live checkout.
1394
+ const lockOwners = new Map();
1395
+ for (const f of files) {
1396
+ if (!(f.startsWith("swift/") || /(^|\/)(Package\.swift|Package\.resolved)$/.test(f) || /(^|\/)[^/]+\.xcodeproj\//.test(f))) continue;
1397
+ const o = lockOwnerOf(f, { exists: (d) => exists(path.join(contentRoot, d)), ancestorDirs: ancestorDirsOf });
1398
+ if (o) lockOwners.set(`${o.kind}:${o.dir}`, o);
1399
+ }
1400
+ for (const o of [...lockOwners.values()].sort((a, b) => (a.dir < b.dir ? -1 : a.dir > b.dir ? 1 : 0))) {
1401
+ const resolved = o.kind === "xcode" ? `${o.dir}/project.xcworkspace/xcshareddata/swiftpm/Package.resolved` : inDir(o.dir, "Package.resolved");
1402
+ add({ id: o.dir === "." ? "swift-lock" : `swift-lock:${o.dir}`, class: A, internal: "swift-lock", lock: o, files: [o.kind === "xcode" ? `${o.dir}/project.pbxproj` : inDir(o.dir, "Package.swift"), resolved] });
1403
+ }
1404
+ // The same file-only lock check for Gradle (`gradle-lock`: committed dependency locking must cover every direct dependency) and Python
1405
+ // (`python-lock`: pins and uv/poetry locks must agree with their requirements), one scan per build root or directory a changed file belongs to.
1406
+ const gradleRoots = new Set();
1407
+ for (const f of files) {
1408
+ if (!(GRADLE_BUILD_FILE.test(f) || /(^|\/)(gradle\.lockfile|libs\.versions\.toml)$/.test(f) || /(^|\/)gradle\/dependency-locks\//.test(f))) continue;
1409
+ const g = f.includes("/gradle/") && !GRADLE_BUILD_FILE.test(f) ? f.slice(0, f.indexOf("/gradle/") + 1) + "x" : f;
1410
+ const root = gradleOwnerOf(g, { exists: (d) => exists(path.join(contentRoot, d)), ancestorDirs: ancestorDirsOf });
1411
+ if (root !== null) gradleRoots.add(root);
1412
+ }
1413
+ for (const dir of [...gradleRoots].sort()) add({ id: dir === "." ? "gradle-lock" : `gradle-lock:${dir}`, class: A, internal: "gradle-lock", dir, files: [] });
1414
+ const pythonDirs = new Set();
1415
+ for (const f of files) if (PYTHON_LOCK_FILE.test(f)) pythonDirs.add(path.posix.dirname(f));
1416
+ for (const dir of [...pythonDirs].sort()) add({ id: dir === "." ? "python-lock" : `python-lock:${dir}`, class: A, internal: "python-lock", dir, files: [] });
1362
1417
  if (includeTests) {
1363
1418
  const deferredToCi = [];
1364
1419
  // Tests per declared feature set, serially too where the crate asks for it, as CI's gate runs them. `quietTests: true` marks
@@ -1371,9 +1426,12 @@ export function planScans({
1371
1426
  deferredToCi.push(...sets.map((set, i) => rustScanId.testRust(name, sets.length, i)), ...(own.alsoSerial ? sets.map((set, i) => rustScanId.testRustSerial(name, sets.length, i)) : []));
1372
1427
  continue;
1373
1428
  }
1429
+ // An end-to-end crate compiles here and runs in qualify (P7): --no-run, the same id, the same feature sets, the same class B compile.
1430
+ const e2e = e2eCrateDirs instanceof Set && e2eCrateDirs.has(rustDir(name));
1431
+ if (e2e) { qualifyOwed.push({ id: rustScanId.testRust(name, sets.length, 0), kind: "e2e-crate", dir: rustDir(name), crate: crateOf(name), reason: "end-to-end crate: its tests drive a built app and never run as class B unit tests (P7)" }); notes.push(`${crateOf(name)} is an end-to-end crate: its test scan compiles with --no-run here; its tests run in the qualify stage against the built app (qa.journeys), never as class B unit tests`); }
1374
1432
  sets.forEach((set, i) => {
1375
- add({ id: rustScanId.testRust(name, sets.length, i), class: B, ...quiet, ...cargoIn(wsOf(name), "test", "-p", crateOf(name), ...set, "--locked", "--no-fail-fast") });
1376
- if (own.alsoSerial) add({ id: rustScanId.testRustSerial(name, sets.length, i), class: B, ...quiet, ...cargoIn(wsOf(name), "test", "-p", crateOf(name), ...set, "--locked", "--no-fail-fast", "--", "--test-threads=1") });
1433
+ add({ id: rustScanId.testRust(name, sets.length, i), class: B, ...quiet, ...(e2e ? { e2e: true } : {}), ...cargoIn(wsOf(name), "test", "-p", crateOf(name), ...set, "--locked", ...(e2e ? ["--no-run"] : ["--no-fail-fast"])) });
1434
+ if (own.alsoSerial) add({ id: rustScanId.testRustSerial(name, sets.length, i), class: B, ...quiet, ...(e2e ? { e2e: true } : {}), ...cargoIn(wsOf(name), "test", "-p", crateOf(name), ...set, "--locked", ...(e2e ? ["--no-run"] : ["--no-fail-fast", "--", "--test-threads=1"])) });
1377
1435
  });
1378
1436
  }
1379
1437
  for (const name of pkgNames) {
@@ -1429,7 +1487,7 @@ export function planScans({
1429
1487
  const reach = cargoReachOf({ unitDir, scripts: manifest.scripts, declared, readScripts: scriptsOf, packageDirOf });
1430
1488
  const hasRust = Object.keys(targetDirs).length > 0;
1431
1489
  if (reach.unprovable) {
1432
- 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; }
1490
+ 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`, { npm: name }); continue; }
1433
1491
  } else if (reach.dir !== undefined || reach.dirs) {
1434
1492
  const workspaceOf = (from) => {
1435
1493
  let manifestDir = null;
@@ -1442,9 +1500,9 @@ export function planScans({
1442
1500
  };
1443
1501
  const found = (reach.dirs ?? [reach.dir]).map(workspaceOf);
1444
1502
  const unknown = found.find((f) => f.why);
1445
- if (unknown) { refuse(`npm unit ${name}`, unknown.why); continue; }
1503
+ if (unknown) { refuse(`npm unit ${name}`, unknown.why, { npm: name }); continue; }
1446
1504
  const workspaces = [...new Set(found.map((f) => f.ws))];
1447
- 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; }
1505
+ 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`, { npm: name }); continue; }
1448
1506
  cargoTarget = targetDirs[workspaces[0]];
1449
1507
  }
1450
1508
  }
@@ -1500,7 +1558,7 @@ export function planScans({
1500
1558
  if (dup) throw new Error(`planScans: duplicate scan id ${dup}`);
1501
1559
  const classDigest = (cls) => digestScanSet(scans.filter((s) => s.class === cls));
1502
1560
  return {
1503
- schema: SCAN_RESULT_SCHEMA, roots: { contentRoot, liveRoot, ...(cargoLiveRoot ? { cargoLiveRoot } : {}) }, changed: files, inputDigest, scans, notes, deferred,
1561
+ schema: SCAN_RESULT_SCHEMA, roots: { contentRoot, liveRoot, ...(cargoLiveRoot ? { cargoLiveRoot } : {}) }, changed: files, inputDigest, scans, notes, deferred, qualifyOwed,
1504
1562
  scanSetDigest: digestScanSet(scans), classDigests: { A: classDigest(A), B: classDigest(B) },
1505
1563
  };
1506
1564
  }
@@ -1747,6 +1805,69 @@ export function scanTimeoutMs(scan, options = {}) {
1747
1805
  return scan?.class === SCAN_CLASS.B ? 60 * 60_000 : 30 * 60_000;
1748
1806
  }
1749
1807
 
1808
+ /**
1809
+ * A scan the broker carries (rightkit#1367 step 2): on a broker host (`brokerHost`; a snapshot carrier implies one) a cargo or rustright scan, a carried Swift package build
1810
+ * (only with the snapshot carrier: a Swift scan with no carrier is a local process), and an npm or node suite that declares a broker call (`brokerCall`: its compiles are
1811
+ * carried requests). The broker queues and orders these; the gate neither counts them nor puts a clock on their queue time. This is the ONE test: the scheduler, the
1812
+ * run clock, the snapshot carrier (`viaSnapshotBroker`) and the carried Swift route (`swiftBrokered`) all derive from it.
1813
+ */
1814
+ export function isBrokerCarriedScan(scan, { brokerHost = false, snapshotBroker = null } = {}) {
1815
+ if (brokerHost !== true && !snapshotBroker) return false;
1816
+ if (scan?.tool === "rustright" || scan?.tool === "cargo") return true;
1817
+ if (scan?.tool === "swift") return Boolean(snapshotBroker);
1818
+ return Boolean(scan?.brokerCall);
1819
+ }
1820
+
1821
+ /** Class B processes the broker never sees (npm or node test suites run on this host) are bounded here, one slot per suite; the broker bounds everything it carries (rightkit#1367 step 2). */
1822
+ export const LOCAL_CLASS_B_JOBS = 3;
1823
+
1824
+ /**
1825
+ * Whether a class B scan is the BROKER's to queue for scheduling: a cargo or rustright scan and a carried Swift build (isBrokerCarriedScan). An npm or node suite that merely makes broker
1826
+ * calls (`brokerCall`) is still a process of this host, so it holds one of the local slots; only its broker calls are carried and watched.
1827
+ */
1828
+ export function isBrokerScheduledScan(scan, ctx) {
1829
+ return isBrokerCarriedScan(scan, ctx) && (scan?.tool === "rustright" || scan?.tool === "cargo" || scan?.tool === "swift");
1830
+ }
1831
+
1832
+ /** The directory a class B scan works in: its `cwd`, else the package an `--dir <package>` argument names (an npm suite); null when it names none. */
1833
+ function scanDirectory(scan) {
1834
+ if (typeof scan?.cwd === "string") return scan.cwd;
1835
+ const at = Array.isArray(scan?.args) ? scan.args.indexOf("--dir") : -1;
1836
+ return at >= 0 && typeof scan.args[at + 1] === "string" ? scan.args[at + 1] : null;
1837
+ }
1838
+ /**
1839
+ * The chain a class B scan belongs to on a broker host. Scans of one component's directory share its output directory and run in plan order. A carried scan chains by its
1840
+ * directory (the repository root, `.`, when it names none). A scan the broker never sees chains by ITS OWN package directory (else its id), never the shared `.` chain, so the
1841
+ * local limit counts real suites and a root build never waits behind them.
1842
+ */
1843
+ export function classBChainKey(scan, carried) {
1844
+ const dir = scanDirectory(scan);
1845
+ return carried ? (dir ?? ".") : `local:${dir ?? scan?.id ?? "."}`;
1846
+ }
1847
+
1848
+ /**
1849
+ * Class B on a broker host (rightkit#1367 step 2): one chain per component, its scans in plan order (compile, doc, tests: they share the component's output directory),
1850
+ * every chain started at once. A broker-carried scan starts without a count; the broker queues and orders it. A scan the broker never sees (an npm or node suite) holds one
1851
+ * of `localJobs` local slots while it runs. `run(index)` runs one scan and never rejects.
1852
+ */
1853
+ export async function scheduleBrokerHostClassB({ scans, lanes, carried, localJobs, run }) {
1854
+ const chains = new Map();
1855
+ for (const lane of ["compile", "doc", "tests"]) {
1856
+ for (const i of lanes[lane] ?? []) { const key = classBChainKey(scans[i], carried(scans[i])); if (!chains.has(key)) chains.set(key, []); chains.get(key).push(i); }
1857
+ }
1858
+ const limit = Math.max(1, Number(localJobs) || 1);
1859
+ let held = 0; const waiters = [];
1860
+ const take = async () => { while (held >= limit) await new Promise((resolve) => waiters.push(resolve)); held += 1; };
1861
+ const free = () => { held -= 1; waiters.shift()?.(); };
1862
+ await Promise.all([...chains.values()].map(async (chain) => {
1863
+ for (const i of chain) {
1864
+ const local = !carried(scans[i]);
1865
+ if (local) await take();
1866
+ try { await run(i); } finally { if (local) free(); }
1867
+ }
1868
+ }));
1869
+ }
1870
+
1750
1871
  export function laneOf(scan) {
1751
1872
  if (/^(clippy|check|check-features|check-dependent):/.test(scan.id)) return "compile";
1752
1873
  if (/^doc:/.test(scan.id)) return "doc";
@@ -1940,6 +2061,22 @@ const INTERNAL = {
1940
2061
  for (const f of s.files) { try { parse(ctx.read(f)); } catch (e) { bad.push(`${f}: ${String(e.message).split("\n")[0]}`); } }
1941
2062
  return bad;
1942
2063
  },
2064
+ // File-only SwiftPM lockfile check (swift-lock.mjs): runs no SwiftPM, reads Package.swift / project.pbxproj and Package.resolved as committed.
2065
+ "swift-lock": (s, ctx) => {
2066
+ const exists = (f) => ctx.deps.exists(path.join(ctx.contentRoot, f));
2067
+ return s.lock.kind === "xcode" ? xcodeProjectLockProblems({ project: s.lock.dir, read: ctx.read, exists }) : packageLockProblems({ dir: s.lock.dir, read: ctx.read, exists });
2068
+ },
2069
+ // File-only Gradle dependency-locking check (gradle-lock.mjs): no Gradle is run.
2070
+ "gradle-lock": (s, ctx) => gradleLockProblems({
2071
+ dir: s.dir, read: ctx.read, exists: (f) => ctx.deps.exists(path.join(ctx.contentRoot, f)),
2072
+ list: (d) => ctx.deps.readdir(path.join(ctx.contentRoot, d), { withFileTypes: true }).map((e) => ({ name: e.name, isDir: e.isDirectory() })),
2073
+ }),
2074
+ // File-only Python lock check (python-lock.mjs): no pip, uv or poetry is run.
2075
+ "python-lock": async (s, ctx) => {
2076
+ let parseToml;
2077
+ try { parseToml = await ctx.deps.loadToml(); } catch (e) { return { missing: `TOML parser unavailable: ${e.message}` }; }
2078
+ return pythonLockProblems({ dir: s.dir, names: ctx.deps.readdir(path.join(ctx.contentRoot, s.dir)).map(String), read: ctx.read, parseToml });
2079
+ },
1943
2080
  "yaml-parse": async (s, ctx) => {
1944
2081
  let parse;
1945
2082
  try { parse = await ctx.deps.loadYaml(); } catch (e) { return { missing: `YAML parser unavailable: ${e.message}` }; }
@@ -2005,8 +2142,18 @@ const INTERNAL = {
2005
2142
  * GB-52: a carried scan's client gets an operation deadline BEFORE the scan's own limit, so the client cancels the job by id and watches its terminal state (within a 15 s cancel wait)
2006
2143
  * instead of being killed with the job still running; a user-set RUSTRIGHT_OPERATION_DEADLINE_MS or cancel wait is never replaced.
2007
2144
  */
2145
+ /** A span for a message: whole seconds under a minute, minutes from then on. */
2146
+ export const spanText = (ms) => (ms < 60_000 ? `${Math.max(0, Math.round(ms / 1000))} s` : `${Math.round(ms / 60_000)} min`);
2147
+ export const scanOperationDeadlineMs = (limitMs) => Math.max(Math.floor(limitMs / 2), limitMs - 20_000);
2148
+ /**
2149
+ * What a carried Swift scan gives carryBrokerAdapterAsync: an UNWATCHED one passes its own limit as the client's operation deadline (the env, and `deadlineMs`, which also bounds the
2150
+ * retry wait); a watched one passes neither (the stopwatch and the ceiling bound it, and the client's deadline is absolute from its start).
2151
+ */
2152
+ export function carriedCallBudget({ env, limitMs, watched }) {
2153
+ return watched ? { env: {} } : { env: scanOperationEnv(env, limitMs), deadlineMs: scanOperationDeadlineMs(limitMs) };
2154
+ }
2008
2155
  export function scanOperationEnv(env, limitMs) {
2009
- const deadline = operationDeadlineEnv({ env, deadlineMs: Math.max(Math.floor(limitMs / 2), limitMs - 20_000) });
2156
+ const deadline = operationDeadlineEnv({ env, deadlineMs: scanOperationDeadlineMs(limitMs) });
2010
2157
  if (Object.keys(deadline).length === 0) return {};
2011
2158
  return { ...deadline, ...(Object.keys(env ?? {}).some((k) => k.toUpperCase() === CANCEL_WAIT_ENV) ? {} : { [CANCEL_WAIT_ENV]: "15000" }) };
2012
2159
  }
@@ -2070,6 +2217,14 @@ export async function runScans(plan, options = {}) {
2070
2217
  const ordered = [...selected.filter((s) => s.class === SCAN_CLASS.A), ...selected.filter((s) => s.class === SCAN_CLASS.B)];
2071
2218
  const ctx = { deps, contentRoot, read: (f) => deps.readFile(path.join(contentRoot, f)) };
2072
2219
  const cargoReadsAreCarried = Boolean(deps.snapshotBroker) || options.cargoReadsCarried === true;
2220
+ // A broker host: the gate says so (options.brokerHost, from its executor) and a snapshot carrier implies it. One predicate (isBrokerCarriedScan) names the scans the broker carries.
2221
+ const brokerHost = options.brokerHost === true || Boolean(deps.snapshotBroker);
2222
+ const carriedCtx = { brokerHost, snapshotBroker: deps.snapshotBroker ?? null };
2223
+ // The broker's live state of a request (queued or running), through an ASYNCHRONOUS client call: the gate's own reader when it hands one, else the carrier's client.
2224
+ const brokerLive = options.brokerLive ?? (brokerHost && typeof deps.snapshotBroker?.client === "string" ? createBrokerLive({ client: deps.snapshotBroker.client, env: deps.env, spawn: defaultSpawnAsync, platform: deps.platform }) : null);
2225
+ // One reader for the whole run: every watched scan registers with it (first read after 1 s, then every 5 s, two reads in flight at most), instead of a client spawn per scan.
2226
+ const pollMs = options.brokerPollMs ?? ADMISSION_POLL_MS;
2227
+ const statusPoller = brokerLive ? createStatusPoller({ live: brokerLive, intervalMs: pollMs }) : null;
2073
2228
  const toolVersions = {}; const toolIdentities = {}; const probed = new Map(); const advisoryDbs = new Map();
2074
2229
  const results = [];
2075
2230
  // The gate-owned cargo ledger of a wrapped snapshot compile (options.cargoLedger = { dir }): every call the wrapper decided, filed
@@ -2097,7 +2252,8 @@ export async function runScans(plan, options = {}) {
2097
2252
  // excluding every class A scan (or a plan with none) never unlocks class B; explicit prior evidence is needed.
2098
2253
  const planA = plan.scans.filter((x) => x.class === SCAN_CLASS.A);
2099
2254
  const inCallA = classes.includes(SCAN_CLASS.A) && planA.length > 0 && selected.filter((x) => x.class === SCAN_CLASS.A).length === planA.length;
2100
- const classBBlocked = !inCallA && !priorClassAValid;
2255
+ // `classAEarlier`: the caller ran full class A of these same bytes once, before this call (the door's check stage before its compile pass, rightkit#1328).
2256
+ const classBBlocked = !inCallA && !priorClassAValid && options.classAEarlier !== true;
2101
2257
 
2102
2258
  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);
2103
2259
  async function probe(tool, scanDir = null) {
@@ -2118,13 +2274,20 @@ export async function runScans(plan, options = {}) {
2118
2274
  // A version probe is bounded (PROBE_TIMEOUT_MS): a hung tool reads as unavailable instead of holding a lane, or a quiet
2119
2275
  // lease, open.
2120
2276
  const probeMs = Number.isFinite(options.probeTimeoutMs) && options.probeTimeoutMs > 0 ? options.probeTimeoutMs : PROBE_TIMEOUT_MS;
2121
- const probeSignal = signal ? AbortSignal.any([signal, AbortSignal.timeout(probeMs)]) : AbortSignal.timeout(probeMs);
2277
+ const signalFor = (ms) => (signal ? AbortSignal.any([signal, AbortSignal.timeout(ms)]) : AbortSignal.timeout(ms));
2122
2278
  // The gate's own version probes reach the wrapper too (cargo is first on PATH): they are filed under the probe token, which the
2123
2279
  // gate claims and checks like a scan's.
2124
2280
  const probeLedgerEnv = cargoLedger ? { [SCAN_TOKEN_ENV]: PROBE_LEDGER_TOKEN } : {};
2125
2281
  if (cargoLedger) claimedTokens.add(PROBE_LEDGER_TOKEN);
2126
- const r = await deps.spawn(command, PROBES[tool] ?? ["--version"], { cwd, env: { ...deps.env, ...PNPM_NO_HOOKS_ENV, COREPACK_ENABLE_AUTO_PIN: "0", ...probeLedgerEnv }, signal: probeSignal });
2127
- if (!r || r.error || r.status !== 0) outcome = { ok: false, why: r?.error?.message ?? `exit ${r?.status}` };
2282
+ const probeOnce = (ms) => deps.spawn(command, PROBES[tool] ?? ["--version"], { cwd, env: { ...deps.env, ...PNPM_NO_HOOKS_ENV, COREPACK_ENABLE_AUTO_PIN: "0", ...probeLedgerEnv }, signal: signalFor(ms) });
2283
+ // One retry, with three times the bound, when only the bound stopped it (the run itself was not aborted).
2284
+ let r = await probeOnce(probeMs);
2285
+ if (r?.cancelled && !signal?.aborted) r = await probeOnce(probeMs * 3);
2286
+ // A probe stopped by its own timeout (not by the run being aborted) says the host was busy, not that the tool is missing: it is
2287
+ // named as a timeout and not remembered, so the next scan probes again (on a loaded Dell `cargo --version` outlived the 60 s
2288
+ // bound while three compiles ran, and the cached failure then failed every later class B row at 0 s, hrwin 4ccadec2).
2289
+ const timedOut = Boolean(r?.cancelled) && !signal?.aborted;
2290
+ if (!r || r.error || r.status !== 0) outcome = { ok: false, transient: timedOut, why: timedOut ? `${tool} --version did not answer within ${Math.round(probeMs / 1000)} s and again within ${Math.round((probeMs * 3) / 1000)} s (host busy)` : r?.error?.message ?? `exit ${r?.status}` };
2128
2291
  else {
2129
2292
  const text = String(r.stdout || r.stderr || "");
2130
2293
  const version = PROBE_VERSION[tool] ? PROBE_VERSION[tool](text) : text.split(/\r?\n/).find((l) => l.trim())?.trim() ?? "unknown";
@@ -2134,7 +2297,7 @@ export async function runScans(plan, options = {}) {
2134
2297
  }
2135
2298
  }
2136
2299
  toolVersions[tool] = outcome.ok ? outcome.version : null;
2137
- probed.set(tool, outcome);
2300
+ if (!outcome.transient) probed.set(tool, outcome);
2138
2301
  return outcome;
2139
2302
  }
2140
2303
 
@@ -2209,11 +2372,15 @@ export async function runScans(plan, options = {}) {
2209
2372
  const slots = new Array(bScans.length);
2210
2373
  const lanes = { compile: [], doc: [], tests: [], quiet: [] };
2211
2374
  bScans.forEach((s, i) => lanes[laneOf(s)].push(i));
2212
- // RightKit is many components, never one build (Adrian, 2026-10-29; rightkit#1285): class B scans of different components run
2213
- // concurrently, `classBJobs` at a time (the broker queues beyond its build capacity). A component is the directory a scan runs in
2214
- // (`cwd`, its own Cargo workspace and output directory); scans of one component stay in plan order, because they share its
2215
- // output directory and cargo locks it.
2216
- const bJobs = Math.max(1, Number(options.classBJobs ?? 1) || 1);
2375
+ // RightKit is many components, never one build (Adrian, 2026-10-29; rightkit#1285). A component is the directory a scan runs in (`cwd`, its
2376
+ // own Cargo workspace and output directory); scans of one component stay in plan order, because they share its output directory and cargo
2377
+ // locks it.
2378
+ // Direct host: class B scans of different components run `classBJobs` at a time (nothing else bounds work there), compile, doc, then tests.
2379
+ // Broker host (options.brokerHost; rightkit#1367 step 2, Adrian 2026-11-01: "the broker manages the how many builds happen. That is not the
2380
+ // job of the gate"): the scans the broker carries start with no count and no lane-wide wait, each component running its own chain while the
2381
+ // broker queues and orders them. A scan the broker never sees (an npm or node test suite) is a process of this host: those keep the local limit.
2382
+ const bJobs = Math.max(1, Number(options.classBJobs ?? (brokerHost ? LOCAL_CLASS_B_JOBS : 1)) || 1);
2383
+ const failedRow = (i, error) => ({ id: bScans[i].id, class: bScans[i].class, command: bScans[i].command, tool: bScans[i].tool ?? null, toolVersion: null, status: "fail", code: "SCAN_ERROR", detail: String(error?.message ?? error) });
2217
2384
  const drain = async (queue, extraEnv = null) => {
2218
2385
  const busy = new Set(); const running = new Set();
2219
2386
  const keyOf = (i) => bScans[i].cwd ?? ".";
@@ -2223,14 +2390,21 @@ export async function runScans(plan, options = {}) {
2223
2390
  const [i] = queue.splice(at, 1); const key = keyOf(i); busy.add(key);
2224
2391
  // A scan that throws becomes a failed row: with several components in flight, a rejection must not abandon the others.
2225
2392
  const job = runOne(bScans[i], extraEnv)
2226
- .catch((error) => ({ id: bScans[i].id, class: bScans[i].class, command: bScans[i].command, tool: bScans[i].tool ?? null, toolVersion: null, status: "fail", code: "SCAN_ERROR", detail: String(error?.message ?? error) }))
2393
+ .catch((error) => failedRow(i, error))
2227
2394
  .then((row) => { slots[i] = row; }).finally(() => { busy.delete(key); running.delete(job); });
2228
2395
  running.add(job);
2229
2396
  }
2230
2397
  };
2231
- await drain(lanes.compile);
2232
- await drain(lanes.doc);
2233
- await drain(lanes.tests);
2398
+ if (brokerHost) {
2399
+ await scheduleBrokerHostClassB({
2400
+ scans: bScans, lanes, localJobs: bJobs, carried: (scan) => isBrokerScheduledScan(scan, carriedCtx),
2401
+ run: async (i) => { try { slots[i] = await runOne(bScans[i]); } catch (error) { slots[i] = failedRow(i, error); } },
2402
+ });
2403
+ } else {
2404
+ await drain(lanes.compile);
2405
+ await drain(lanes.doc);
2406
+ await drain(lanes.tests);
2407
+ }
2234
2408
  if (lanes.quiet.length) {
2235
2409
  // One lease per quiet suite, as in class A above.
2236
2410
  for (const i of lanes.quiet) {
@@ -2337,7 +2511,8 @@ export async function runScans(plan, options = {}) {
2337
2511
  let cacheKey = null;
2338
2512
  // A cargo request that builds the commit snapshot through the broker (options.snapshotBroker) is never served from or written
2339
2513
  // to the scan cache: each run must carry its own request and verify the broker's own signed evidence.
2340
- const viaSnapshotBroker = Boolean(deps.snapshotBroker) && s.root === "cargoLive" && (s.tool === "rustright" || s.tool === "cargo");
2514
+ const carriedScan = isBrokerCarriedScan(s, carriedCtx);
2515
+ const viaSnapshotBroker = carriedScan && Boolean(deps.snapshotBroker) && s.root === "cargoLive" && (s.tool === "rustright" || s.tool === "cargo");
2341
2516
  const carriedRead = cargoReadsAreCarried && s.class === SCAN_CLASS.A && (s.tool === "rustright" || s.tool === "cargo"); // a snapshot compile never reuses a cargo read either
2342
2517
  if (cache && !viaSnapshotBroker && !carriedRead) {
2343
2518
  try {
@@ -2427,7 +2602,20 @@ export async function runScans(plan, options = {}) {
2427
2602
  }
2428
2603
  const limitMs = scanTimeoutMs(s, options);
2429
2604
  const timer = new AbortController();
2430
- const clock = setTimeout(() => timer.abort(), limitMs);
2605
+ // The scan's run limit is a stopwatch. A scan the broker carries (class B, not a quiet suite under its lease) is watched: the stopwatch does not tick while
2606
+ // every request of the scan is QUEUED at the broker (broker-admission.mjs), so queue time is neither charged to the scan nor bounded by a gate allowance.
2607
+ // Without a live reader (no client to ask) the stopwatch simply runs, and every unwatched carried scan gives its client the scan's own limit as the operation deadline
2608
+ // (RUSTRIGHT_OPERATION_DEADLINE_MS: the snapshot carrier, a rustright or cargo call, a carried Swift call through deadlineMs), so the client cancels by id before the gate stops it.
2609
+ const watched = carriedScan && s.class === SCAN_CLASS.B && s.quiet !== true && brokerLive !== null;
2610
+ let admission = null; let graceTimer = null; let genRefDir = null; let clockExpired = false;
2611
+ // A carried scan is cancelled by id first and its process tree stopped only after a grace; any other scan is stopped at once.
2612
+ const stopScan = () => {
2613
+ clockExpired = true;
2614
+ if (admission) { admission.cancelKnown().catch(() => {}); graceTimer = setTimeout(() => timer.abort(), options.brokerCancelGraceMs ?? CARRIED_CANCEL_GRACE_MS); } else timer.abort();
2615
+ };
2616
+ // A watched scan also has ONE wall-clock ceiling, its own limit x QUEUED_CEILING_FACTOR: a safety stop for a wedged broker queue (the broker's job deadlines are off by default), not a count.
2617
+ const runClock = createRunClock({ limitMs, onExpire: stopScan, ...(watched ? { ceilingMs: limitMs * QUEUED_CEILING_FACTOR, onCeiling: stopScan } : {}) });
2618
+ const stopClock = () => { runClock.stop(); admission?.stop(); if (graceTimer) clearTimeout(graceTimer); if (genRefDir) { fs.rmSync(genRefDir, { recursive: true, force: true }); genRefDir = null; } };
2431
2619
  const scanSignal = signal ? AbortSignal.any([signal, timer.signal]) : timer.signal;
2432
2620
  // A class A test row runs under the launch guard (class-a-launch-guard.mjs): its Node processes may not launch a compile.
2433
2621
  // Every class A row that executes repository code (tests, lint/typecheck with repository config, generators) runs under the
@@ -2440,12 +2628,12 @@ export async function runScans(plan, options = {}) {
2440
2628
  // build directory is the gate's own, keyed by the repository and the scan so it stays warm.
2441
2629
  // GB-67: on a broker host the scan is the carried `rightkit-swift` adapter job and the broker owns SwiftPM's scratch (it appends the one managed path and the
2442
2630
  // product location is `build --show-bin-path`): the gate sends no scratch or build path, and never adds one. Only a host with no broker (Linux direct) keeps its own.
2443
- const swiftBrokered = s.tool === "swift" && Boolean(deps.snapshotBroker);
2631
+ const swiftBrokered = carriedScan && s.tool === "swift" && Boolean(deps.snapshotBroker);
2444
2632
  // The adapter takes exactly one relative --package-path (adapter.rs check_swift_package); the scan runs in its package directory, so that is `.`.
2445
2633
  if (swiftBrokered && finalArgs[0] === "build" && !finalArgs.some((a) => a === "--package-path" || a.startsWith("--package-path="))) finalArgs.splice(1, 0, "--package-path", ".");
2446
2634
  if (s.tool === "swift" && finalArgs[0] === "build" && !swiftBrokered && !finalArgs.some((a) => a === "--scratch-path" || a === "--build-path" || a.startsWith("--scratch-path=") || a.startsWith("--build-path="))) {
2447
2635
  try { finalArgs.push("--scratch-path", swiftScratchPath({ tmpRoot, roots: [contentRoot, liveRoot, cargoLiveRoot], id: s.id })); }
2448
- catch (e) { advisory?.cleanup?.(); return { ...row, status: "fail", code: "SWIFT_SCRATCH", detail: `${s.id}: ${e.message}` }; }
2636
+ catch (e) { stopClock(); advisory?.cleanup?.(); return { ...row, status: "fail", code: "SWIFT_SCRATCH", detail: `${s.id}: ${e.message}` }; }
2449
2637
  }
2450
2638
  let carrier = null;
2451
2639
  if (viaSnapshotBroker) {
@@ -2456,7 +2644,7 @@ export async function runScans(plan, options = {}) {
2456
2644
  const cargoArgv = s.tool === "rustright" ? finalArgs.slice(1) : finalArgs;
2457
2645
  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") });
2458
2646
  carrier = { made, refDir, cargoArgv };
2459
- } 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}` }; }
2647
+ } catch (e) { fs.rmSync(refDir, { recursive: true, force: true }); stopClock(); advisory?.cleanup?.(); return { ...row, status: "fail", code: "SNAPSHOT_REQUEST", detail: `the snapshot request for ${s.id} could not be written: ${e.message}` }; }
2460
2648
  }
2461
2649
  // From here to the end of its verification the carrier and the request reference are this request's: every exit (cancel,
2462
2650
  // timeout, a thrown spawn, a refused receipt) removes them.
@@ -2479,32 +2667,59 @@ export async function runScans(plan, options = {}) {
2479
2667
  let marked = null; let why = null;
2480
2668
  try { if (typeof deps.snapshotBroker.markBrokerSubmit !== "function") throw new Error("no build-ownership submit hook"); marked = deps.snapshotBroker.markBrokerSubmit(sub); } catch (e) { why = e.message; }
2481
2669
  if (marked?.broker?.submitted !== true || marked.handle !== deps.snapshotBroker.handle || !marked.broker.handles?.includes(sub)) {
2482
- clearTimeout(clock); advisory?.cleanup?.();
2670
+ stopClock(); advisory?.cleanup?.();
2483
2671
  return { ...row, status: "fail", code: "SNAPSHOT_REQUEST", detail: `the snapshot request for ${s.id} was not sent: the build ownership did not record the broker submit under ${deps.snapshotBroker.handle}${why ? ` (${why})` : ""}` };
2484
2672
  }
2485
2673
  }
2486
2674
  if (swiftBrokered) {
2487
2675
  // One signed, ledgered adapter request in the commit's snapshot (the ownership records its sub-handle before it is sent); a refused or unverified call fails the scan.
2676
+ // The carry is asynchronous (the gate's other scans keep running while this one queues or builds) and takes the scan's own signal: the run limit, or a cancel, stops it.
2677
+ const swiftRefs = [];
2678
+ const swiftBudget = carriedCallBudget({ env: { ...deps.env, ...s.env }, limitMs, watched });
2679
+ if (watched) { admission = watchBrokerAdmission({ clock: runClock, refFiles: () => swiftRefs, live: brokerLive, poller: statusPoller }); admission.start(); }
2488
2680
  try {
2489
- const carried = carryBrokerAdapter({ adapter: "rightkit-swift", words: finalArgs, cwd: path.join(rootDir(s.root), s.cwd ?? ""), env: { ...deps.env, ...s.env, ...(extraEnv ?? {}) }, section: brokerSection(deps.snapshotBroker), record: (entry) => { if (cargoLedger && ledgerToken) appendLedger(cargoLedger.dir, ledgerToken, entry); }, err: () => {}, stdio: "capture" });
2681
+ const carried = await carryBrokerAdapterAsync({ adapter: "rightkit-swift", words: finalArgs, cwd: path.join(rootDir(s.root), s.cwd ?? ""), env: { ...deps.env, ...s.env, ...(extraEnv ?? {}), ...swiftBudget.env }, ...(swiftBudget.deadlineMs !== undefined ? { deadlineMs: swiftBudget.deadlineMs } : {}), section: brokerSection(deps.snapshotBroker), record: (entry) => { if (typeof entry.requestRef === "string" && !swiftRefs.includes(entry.requestRef)) swiftRefs.push(entry.requestRef); if (cargoLedger && ledgerToken) appendLedger(cargoLedger.dir, ledgerToken, entry); }, err: () => {}, stdio: "capture", signal: scanSignal, spawn: deps.adapterSpawn ?? defaultSpawnAsync });
2490
2682
  r = { status: carried.status, stdout: carried.stdout, stderr: carried.stderr };
2491
- } catch (e) { r = { error: e }; } finally { clearTimeout(clock); advisory?.cleanup?.(); }
2683
+ } catch (e) { r = { error: e }; } finally { stopClock(); advisory?.cleanup?.(); }
2492
2684
  } else {
2493
2685
  // The executables this scan runs are the ones resolved and verified above: read their bytes again now, immediately before the launch
2494
2686
  // (a replaced binary is TOOL_REPLACED), and hand the scan only those paths: actionlint its shellcheck, cargo its cargo-deny / cargo-audit.
2495
2687
  for (const identity of identities) {
2496
2688
  const swapped = trustedScanToolProblem(identity, { ops: options.toolOps });
2497
- if (swapped) { clearTimeout(clock); advisory?.cleanup?.(); return { ...row, status: "fail", code: swapped.code, detail: `${swapped.code}: ${swapped.detail}; the scan did not run` }; }
2689
+ if (swapped) { stopClock(); advisory?.cleanup?.(); return { ...row, status: "fail", code: swapped.code, detail: `${swapped.code}: ${swapped.detail}; the scan did not run` }; }
2498
2690
  }
2499
2691
  const shellcheckIdentity = s.tool === "actionlint" ? identities.find((i) => i.tool === "shellcheck") : null;
2500
2692
  const spawnArgs = shellcheckIdentity ? ["-shellcheck", shellcheckIdentity.path, ...finalArgs] : finalArgs;
2501
2693
  const trustedDirs = trusts.map((tool) => identities.find((i) => i.tool === tool)).filter(Boolean).map((i) => path.dirname(i.path));
2502
2694
  const pathKey = Object.keys({ ...deps.env, ...s.env }).find((k) => k.toLowerCase() === "path") ?? "PATH";
2503
- const trustEnv = trustedDirs.length ? { [pathKey]: [...trustedDirs, ({ ...deps.env, ...s.env })[pathKey]].filter(Boolean).join(deps.platform === "win32" ? ";" : ":") } : {};
2504
- try { r = await deps.spawn(p.command, spawnArgs, { cwd: path.join(rootDir(s.root), s.cwd ?? ""), env: { ...deps.env, ...sentinelEnv, ...readTargetEnv, ...s.env, ...(extraEnv ?? {}), ...guardEnv, ...ledgerEnv, ...(carrier ? carrier.made.env : {}), ...(carrier ? scanOperationEnv({ ...deps.env, ...s.env }, limitMs) : {}), ...trustEnv }, signal: scanSignal, timeout: limitMs }); } finally { clearTimeout(clock); advisory?.cleanup?.(); }
2695
+ // The gate's cargo wrapper stays first on PATH, ahead of the trusted directories: an enrolled analyzer's directory may also hold
2696
+ // a real cargo (cargo-deny in ~/.cargo/bin beside rustup's cargo), which would otherwise answer the scan's `cargo` unbound and,
2697
+ // with several workspaces, write into the sentinel (rightkit#1327).
2698
+ const scanPath = ({ ...deps.env, ...s.env })[pathKey];
2699
+ const pathSep = deps.platform === "win32" ? ";" : ":";
2700
+ const scanCargo = ({ ...deps.env, ...s.env }).CARGO;
2701
+ const wrapperBin = (cargoLedger || options.cargoSentinel) && typeof scanCargo === "string" && typeof scanPath === "string" ? (deps.platform === "win32" ? path.win32 : path.posix).dirname(scanCargo) : null;
2702
+ const wrapperFirst = wrapperBin !== null && scanPath.split(pathSep)[0] === wrapperBin;
2703
+ const trustEnv = trustedDirs.length ? { [pathKey]: (wrapperFirst ? [wrapperBin, ...trustedDirs, scanPath.slice(wrapperBin.length + 1)] : [...trustedDirs, scanPath]).filter(Boolean).join(pathSep) } : {};
2704
+ // A watched scan names where its client writes the request id (RUSTRIGHT_REQUEST_ID_FILE) so the gate can ask the broker for it: the carrier's own reference, a file of its own for
2705
+ // a scan that reaches the broker through `rustright cargo`, or the reference files its carried calls record in the cargo ledger (an npm suite with a broker call).
2706
+ let refEnv = {};
2707
+ if (watched) {
2708
+ let refFiles;
2709
+ if (carrier) refFiles = () => [path.join(carrier.refDir, "request.json")];
2710
+ else if (s.tool === "rustright" || s.tool === "cargo") { genRefDir = fs.mkdtempSync(path.join(os.tmpdir(), "rk-scanreq-")); const file = path.join(genRefDir, "request.json"); refEnv = { RUSTRIGHT_REQUEST_ID_FILE: file }; refFiles = () => [file]; }
2711
+ else refFiles = () => { try { return ledgerToken && cargoLedger ? readLedger(cargoLedger.dir, ledgerToken).entries.filter((e) => e.status === "pending" && typeof e.requestRef === "string").map((e) => e.requestRef) : []; } catch { return []; } };
2712
+ admission = watchBrokerAdmission({ clock: runClock, refFiles, live: brokerLive, poller: statusPoller });
2713
+ admission.start();
2714
+ }
2715
+ try { r = await deps.spawn(p.command, spawnArgs, { cwd: path.join(rootDir(s.root), s.cwd ?? ""), env: { ...deps.env, ...sentinelEnv, ...readTargetEnv, ...s.env, ...(extraEnv ?? {}), ...guardEnv, ...ledgerEnv, ...(carrier ? carrier.made.env : {}), ...refEnv, ...(carriedScan && !watched ? scanOperationEnv({ ...deps.env, ...s.env }, limitMs) : {}), ...trustEnv }, signal: scanSignal, ...(watched ? {} : { timeout: limitMs }) }); } finally { stopClock(); advisory?.cleanup?.(); }
2505
2716
  }
2506
2717
  if (signal?.aborted && (!r || r.error || r.status !== 0)) return { ...row, status: "not-run", code: "CANCELLED", detail: "cancelled while running" };
2507
- 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` };
2718
+ if ((clockExpired || timer.signal.aborted || r?.error?.code === "ETIMEDOUT") && (!r || r.error || r.status !== 0)) {
2719
+ const how = `was ${admission ? "cancelled at the broker by id and stopped" : "killed"} with its process tree; a scan that does not finish fails`;
2720
+ if (runClock.ceilingHit) return { ...row, status: "fail", code: "TIMEOUT", detail: `${s.id} hit its wall-clock ceiling while it waited in the broker's queue (${spanText(limitMs * QUEUED_CEILING_FACTOR)}: ${QUEUED_CEILING_FACTOR} x its ${spanText(limitMs)} run limit, a safety stop for a wedged broker queue, not a count; it had waited ${spanText(runClock.queuedMs)} in the queue) and ${how}` };
2721
+ return { ...row, status: "fail", code: "TIMEOUT", detail: `${s.id} ran longer than its ${spanText(limitMs)} run limit${runClock.queuedMs > 0 ? ` (the ${spanText(runClock.queuedMs)} it waited in the broker's queue does not count)` : ""} and ${how}` };
2722
+ }
2508
2723
  if (!r || r.error) { status = "fail"; code = "SPAWN_ERROR"; detail = r?.error?.message ?? "no result"; }
2509
2724
  else if (r.status !== 0) { status = "fail"; code = "EXIT"; detail = tail(r) || `exit ${r.status}`; }
2510
2725
  // cargo-deny reports some failures only as an [ERROR] line and still exits 0 (under the broker, its nested offline
@@ -2540,7 +2755,7 @@ export async function runScans(plan, options = {}) {
2540
2755
  const ref = readRequestRef(path.join(carrier.refDir, "request.json"));
2541
2756
  const receipt = await deps.snapshotBroker.receiptOf(ref.requestId);
2542
2757
  const { evidence } = verifySnapshotReceipt(receipt, { pins: deps.snapshotBroker.pins, jobId: ref.requestId, carrier: carrier.made.carrier, argv: carrier.cargoArgv, workspace: ref.workspace, observedEnv: observedEnvOf({ ...deps.env, ...s.env, ...(extraEnv ?? {}) }, deps.platform ?? process.platform), checkout: deps.snapshotBroker.checkout, outputRoots: deps.snapshotBroker.outputRoots, platform: deps.snapshotBroker.platform ?? deps.platform ?? process.platform });
2543
- row.snapshotEvidence = { requestId: ref.requestId, outputDir: evidence.outputDir, treeAtStart: evidence.snapshot.treeAtStart, treeAtExit: evidence.snapshot.treeAtExit };
2758
+ row.snapshotEvidence = { requestId: ref.requestId, outputDir: evidence.outputDir, cargoDir: s.cwd ?? ".", treeAtStart: evidence.snapshot.treeAtStart, treeAtExit: evidence.snapshot.treeAtExit };
2544
2759
  } catch (e) {
2545
2760
  status = "fail"; code = "SNAPSHOT_EVIDENCE"; detail = `${s.id}: the broker's snapshot evidence did not verify (${e.code ?? "error"}: ${e.message})`;
2546
2761
  }
package/development.mjs CHANGED
@@ -519,6 +519,14 @@ export function resolvePnpmCli({ root, env = process.env } = {}) {
519
519
  }
520
520
  // Unix global pnpm exposes its JavaScript entry through a PATH symlink.
521
521
  for (const dir of String(env.PATH ?? env.Path ?? "").split(path.delimiter).filter(Boolean)) candidates.push(path.join(dir, "pnpm"));
522
+ // pnpm >= 11 installed through pnpm/action-setup or `npm install -g pnpm` puts a native executable behind the PATH name (and behind npm_execpath);
523
+ // its package keeps the JavaScript entry Corepack loads beside it at bin/pnpm.mjs, which is the entry to launch under the current node.
524
+ for (const candidate of [...candidates]) {
525
+ try {
526
+ const resolved = realpathSync(candidate);
527
+ if (!/pnpm\.(?:mjs|cjs|js)$/i.test(resolved)) candidates.push(path.join(path.dirname(resolved), "bin", "pnpm.mjs"), path.join(path.dirname(resolved), "..", "pnpm", "bin", "pnpm.mjs"));
528
+ } catch { /* an absent candidate is skipped */ }
529
+ }
522
530
  const selected = candidates.map(resolveJavaScriptEntrypoint).find(Boolean);
523
531
  if (!selected) throw new Error("could not resolve pnpm JavaScript CLI entrypoint; install/pin pnpm through Corepack");
524
532
  return selected;
package/disk-policy.mjs CHANGED
@@ -338,9 +338,9 @@ export const BUDGET_PARTS = ["targets", "sccache", "cargoHome", "toolchains", "t
338
338
 
339
339
  /** RustRight's build budget (`buildBudget` of `rustright doctor --json --no-scan`, all parts in bytes), or null when RustRight is
340
340
  * missing, fails, times out or answers with anything else. Nothing is cached and no shell is used. */
341
- export function readRustrightBudget({ run = spawnSync } = {}) {
341
+ export function readRustrightBudget({ run = spawnSync, platform = process.platform } = {}) {
342
342
  try {
343
- const launch = brokerClientInvocation("rustright", ["doctor", "--json", "--no-scan"], { encoding: "utf8", timeout: 20_000, shell: false, maxBuffer: 1 << 24 }, { probe: run });
343
+ const launch = brokerClientInvocation("rustright", ["doctor", "--json", "--no-scan"], { encoding: "utf8", timeout: 20_000, shell: false, maxBuffer: 1 << 24 }, { probe: run, platform });
344
344
  const r = run(launch.command, launch.args, launch.options);
345
345
  if (!r || r.error || r.status !== 0) return null;
346
346
  const budget = JSON.parse(String(r.stdout)).buildBudget;