@rightkit/release 0.6.16 → 0.6.21

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 +481 -103
  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 +253 -42
  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/ownership-lockfiles.mjs +14 -3
  31. package/ownership.mjs +5 -1
  32. package/package.json +17 -11
  33. package/protected-credentials.mjs +13 -5
  34. package/publication-gate.mjs +9 -4
  35. package/python-lock.mjs +209 -0
  36. package/qa-admission-policy.json +32 -0
  37. package/qa-layout.mjs +229 -0
  38. package/qa-runner.mjs +156 -0
  39. package/release-artifact-path.mjs +29 -3
  40. package/release-config.mjs +1 -1
  41. package/release-qa-command.mjs +58 -8
  42. package/release-qa-policy.mjs +5 -3
  43. package/release-source-identity.mjs +1 -0
  44. package/release.mjs +10 -2
  45. package/rightkit-ownership.json +1465 -79
  46. package/rightkit-registry-resolver.mjs +7 -3
  47. package/rightkit-versions.json +173 -36
  48. package/runtime-budget-classes.json +4 -0
  49. package/runtime-budgets.mjs +5 -1
  50. package/shell-contract.mjs +1 -1
  51. package/snapshot-broker.mjs +114 -9
  52. package/snapshot-check.mjs +6 -1
  53. package/snapshot-request.mjs +20 -3
  54. package/snapshot.mjs +11 -4
  55. package/stage-commands.mjs +2 -1
  56. package/stage-ledger.mjs +15 -0
  57. package/stage-sidecar-command.mjs +7 -1
  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.
@@ -1326,7 +1348,9 @@ export function planScans({
1326
1348
  // A package that depends on another workspace package is therefore checked through a generated project that maps each sibling's
1327
1349
  // exported entry points to its committed sources (TSC_PROJECT, built by the runner); every other package keeps its own tsconfig.
1328
1350
  // `--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.
1329
- const workspaceDirs = (npmMap ? [...npmMap.values()].map((u) => u.dir) : listDir(path.join(liveRoot, "packages")).map((n) => `packages/${n}`)).sort();
1351
+ // Every workspace package is a possible sibling, whatever subset this plan checks (a publication plans only its units, and their
1352
+ // siblings' sources must still be mapped: publish scans of @rightkit/workspace-ui could not resolve @rightkit/mail-ui/*).
1353
+ const workspaceDirs = [...new Set([...(npmMap ? [...npmMap.values()].map((u) => u.dir) : []), ...listDir(path.join(liveRoot, "packages")).map((n) => `packages/${n}`)])].sort();
1330
1354
  const manifestOf = (dir) => { try { return JSON.parse(readFile(path.join(liveRoot, dir, "package.json")).replace(/^/, "")); } catch { return null; } };
1331
1355
  const workspaceNames = new Map(workspaceDirs.map((d) => [manifestOf(d)?.name, d]).filter(([n]) => typeof n === "string"));
1332
1356
  const usesWorkspaceSibling = (dir) => {
@@ -1355,10 +1379,43 @@ export function planScans({
1355
1379
  }
1356
1380
  for (const dir of [...packages].sort()) {
1357
1381
  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 }) });
1382
+ const declared = swiftPackages?.[dir === "" ? "." : dir]?.qualifyOnly;
1383
+ const reason = typeof declared?.reason === "string" && declared.reason.trim() !== "" ? declared.reason.replace(/\s+/g, " ").trim() : null;
1384
+ 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`);
1385
+ 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`); }
1386
+ // The committed Package.resolved is the lockfile: like cargo --locked and pnpm --frozen-lockfile, a stale one fails here with
1387
+ // SwiftPM's own message instead of SwiftPM rewriting it inside the snapshot (seen only after the compile as a changed tree).
1388
+ else if (platform === "darwin") add({ id, class: B, tool: "swift", args: ["build", "--disable-automatic-resolution"], ...(dir === "" || dir === "." ? {} : { cwd: dir }) });
1359
1389
  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
1390
  }
1361
1391
  }
1392
+ // Class A, file-only (rightkit swift-lock.mjs): the committed Package.resolved (or an Xcode project's) must match the declared dependencies, like
1393
+ // cargo --locked and pnpm --frozen-lockfile (Adrian, 2026-11-01: a stale lockfile must never reach the compile). One scan per Swift package or
1394
+ // .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
1395
+ // `executes`, so a scoped class A run keeps it (app-closure.mjs scopeScans keeps file-only checks), and it never needs a live checkout.
1396
+ const lockOwners = new Map();
1397
+ for (const f of files) {
1398
+ if (!(f.startsWith("swift/") || /(^|\/)(Package\.swift|Package\.resolved)$/.test(f) || /(^|\/)[^/]+\.xcodeproj\//.test(f))) continue;
1399
+ const o = lockOwnerOf(f, { exists: (d) => exists(path.join(contentRoot, d)), ancestorDirs: ancestorDirsOf });
1400
+ if (o) lockOwners.set(`${o.kind}:${o.dir}`, o);
1401
+ }
1402
+ for (const o of [...lockOwners.values()].sort((a, b) => (a.dir < b.dir ? -1 : a.dir > b.dir ? 1 : 0))) {
1403
+ const resolved = o.kind === "xcode" ? `${o.dir}/project.xcworkspace/xcshareddata/swiftpm/Package.resolved` : inDir(o.dir, "Package.resolved");
1404
+ 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] });
1405
+ }
1406
+ // The same file-only lock check for Gradle (`gradle-lock`: committed dependency locking must cover every direct dependency) and Python
1407
+ // (`python-lock`: pins and uv/poetry locks must agree with their requirements), one scan per build root or directory a changed file belongs to.
1408
+ const gradleRoots = new Set();
1409
+ for (const f of files) {
1410
+ if (!(GRADLE_BUILD_FILE.test(f) || /(^|\/)(gradle\.lockfile|libs\.versions\.toml)$/.test(f) || /(^|\/)gradle\/dependency-locks\//.test(f))) continue;
1411
+ const g = f.includes("/gradle/") && !GRADLE_BUILD_FILE.test(f) ? f.slice(0, f.indexOf("/gradle/") + 1) + "x" : f;
1412
+ const root = gradleOwnerOf(g, { exists: (d) => exists(path.join(contentRoot, d)), ancestorDirs: ancestorDirsOf });
1413
+ if (root !== null) gradleRoots.add(root);
1414
+ }
1415
+ for (const dir of [...gradleRoots].sort()) add({ id: dir === "." ? "gradle-lock" : `gradle-lock:${dir}`, class: A, internal: "gradle-lock", dir, files: [] });
1416
+ const pythonDirs = new Set();
1417
+ for (const f of files) if (PYTHON_LOCK_FILE.test(f)) pythonDirs.add(path.posix.dirname(f));
1418
+ for (const dir of [...pythonDirs].sort()) add({ id: dir === "." ? "python-lock" : `python-lock:${dir}`, class: A, internal: "python-lock", dir, files: [] });
1362
1419
  if (includeTests) {
1363
1420
  const deferredToCi = [];
1364
1421
  // Tests per declared feature set, serially too where the crate asks for it, as CI's gate runs them. `quietTests: true` marks
@@ -1371,9 +1428,12 @@ export function planScans({
1371
1428
  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
1429
  continue;
1373
1430
  }
1431
+ // 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.
1432
+ const e2e = e2eCrateDirs instanceof Set && e2eCrateDirs.has(rustDir(name));
1433
+ 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
1434
  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") });
1435
+ 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"])) });
1436
+ 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
1437
  });
1378
1438
  }
1379
1439
  for (const name of pkgNames) {
@@ -1429,7 +1489,7 @@ export function planScans({
1429
1489
  const reach = cargoReachOf({ unitDir, scripts: manifest.scripts, declared, readScripts: scriptsOf, packageDirOf });
1430
1490
  const hasRust = Object.keys(targetDirs).length > 0;
1431
1491
  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; }
1492
+ 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
1493
  } else if (reach.dir !== undefined || reach.dirs) {
1434
1494
  const workspaceOf = (from) => {
1435
1495
  let manifestDir = null;
@@ -1442,9 +1502,9 @@ export function planScans({
1442
1502
  };
1443
1503
  const found = (reach.dirs ?? [reach.dir]).map(workspaceOf);
1444
1504
  const unknown = found.find((f) => f.why);
1445
- if (unknown) { refuse(`npm unit ${name}`, unknown.why); continue; }
1505
+ if (unknown) { refuse(`npm unit ${name}`, unknown.why, { npm: name }); continue; }
1446
1506
  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; }
1507
+ 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
1508
  cargoTarget = targetDirs[workspaces[0]];
1449
1509
  }
1450
1510
  }
@@ -1500,7 +1560,7 @@ export function planScans({
1500
1560
  if (dup) throw new Error(`planScans: duplicate scan id ${dup}`);
1501
1561
  const classDigest = (cls) => digestScanSet(scans.filter((s) => s.class === cls));
1502
1562
  return {
1503
- schema: SCAN_RESULT_SCHEMA, roots: { contentRoot, liveRoot, ...(cargoLiveRoot ? { cargoLiveRoot } : {}) }, changed: files, inputDigest, scans, notes, deferred,
1563
+ schema: SCAN_RESULT_SCHEMA, roots: { contentRoot, liveRoot, ...(cargoLiveRoot ? { cargoLiveRoot } : {}) }, changed: files, inputDigest, scans, notes, deferred, qualifyOwed,
1504
1564
  scanSetDigest: digestScanSet(scans), classDigests: { A: classDigest(A), B: classDigest(B) },
1505
1565
  };
1506
1566
  }
@@ -1747,6 +1807,69 @@ export function scanTimeoutMs(scan, options = {}) {
1747
1807
  return scan?.class === SCAN_CLASS.B ? 60 * 60_000 : 30 * 60_000;
1748
1808
  }
1749
1809
 
1810
+ /**
1811
+ * 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
1812
+ * (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
1813
+ * 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
1814
+ * run clock, the snapshot carrier (`viaSnapshotBroker`) and the carried Swift route (`swiftBrokered`) all derive from it.
1815
+ */
1816
+ export function isBrokerCarriedScan(scan, { brokerHost = false, snapshotBroker = null } = {}) {
1817
+ if (brokerHost !== true && !snapshotBroker) return false;
1818
+ if (scan?.tool === "rustright" || scan?.tool === "cargo") return true;
1819
+ if (scan?.tool === "swift") return Boolean(snapshotBroker);
1820
+ return Boolean(scan?.brokerCall);
1821
+ }
1822
+
1823
+ /** 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). */
1824
+ export const LOCAL_CLASS_B_JOBS = 3;
1825
+
1826
+ /**
1827
+ * 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
1828
+ * 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.
1829
+ */
1830
+ export function isBrokerScheduledScan(scan, ctx) {
1831
+ return isBrokerCarriedScan(scan, ctx) && (scan?.tool === "rustright" || scan?.tool === "cargo" || scan?.tool === "swift");
1832
+ }
1833
+
1834
+ /** 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. */
1835
+ function scanDirectory(scan) {
1836
+ if (typeof scan?.cwd === "string") return scan.cwd;
1837
+ const at = Array.isArray(scan?.args) ? scan.args.indexOf("--dir") : -1;
1838
+ return at >= 0 && typeof scan.args[at + 1] === "string" ? scan.args[at + 1] : null;
1839
+ }
1840
+ /**
1841
+ * 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
1842
+ * 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
1843
+ * local limit counts real suites and a root build never waits behind them.
1844
+ */
1845
+ export function classBChainKey(scan, carried) {
1846
+ const dir = scanDirectory(scan);
1847
+ return carried ? (dir ?? ".") : `local:${dir ?? scan?.id ?? "."}`;
1848
+ }
1849
+
1850
+ /**
1851
+ * 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),
1852
+ * 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
1853
+ * of `localJobs` local slots while it runs. `run(index)` runs one scan and never rejects.
1854
+ */
1855
+ export async function scheduleBrokerHostClassB({ scans, lanes, carried, localJobs, run }) {
1856
+ const chains = new Map();
1857
+ for (const lane of ["compile", "doc", "tests"]) {
1858
+ 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); }
1859
+ }
1860
+ const limit = Math.max(1, Number(localJobs) || 1);
1861
+ let held = 0; const waiters = [];
1862
+ const take = async () => { while (held >= limit) await new Promise((resolve) => waiters.push(resolve)); held += 1; };
1863
+ const free = () => { held -= 1; waiters.shift()?.(); };
1864
+ await Promise.all([...chains.values()].map(async (chain) => {
1865
+ for (const i of chain) {
1866
+ const local = !carried(scans[i]);
1867
+ if (local) await take();
1868
+ try { await run(i); } finally { if (local) free(); }
1869
+ }
1870
+ }));
1871
+ }
1872
+
1750
1873
  export function laneOf(scan) {
1751
1874
  if (/^(clippy|check|check-features|check-dependent):/.test(scan.id)) return "compile";
1752
1875
  if (/^doc:/.test(scan.id)) return "doc";
@@ -1940,6 +2063,22 @@ const INTERNAL = {
1940
2063
  for (const f of s.files) { try { parse(ctx.read(f)); } catch (e) { bad.push(`${f}: ${String(e.message).split("\n")[0]}`); } }
1941
2064
  return bad;
1942
2065
  },
2066
+ // File-only SwiftPM lockfile check (swift-lock.mjs): runs no SwiftPM, reads Package.swift / project.pbxproj and Package.resolved as committed.
2067
+ "swift-lock": (s, ctx) => {
2068
+ const exists = (f) => ctx.deps.exists(path.join(ctx.contentRoot, f));
2069
+ return s.lock.kind === "xcode" ? xcodeProjectLockProblems({ project: s.lock.dir, read: ctx.read, exists }) : packageLockProblems({ dir: s.lock.dir, read: ctx.read, exists });
2070
+ },
2071
+ // File-only Gradle dependency-locking check (gradle-lock.mjs): no Gradle is run.
2072
+ "gradle-lock": (s, ctx) => gradleLockProblems({
2073
+ dir: s.dir, read: ctx.read, exists: (f) => ctx.deps.exists(path.join(ctx.contentRoot, f)),
2074
+ list: (d) => ctx.deps.readdir(path.join(ctx.contentRoot, d), { withFileTypes: true }).map((e) => ({ name: e.name, isDir: e.isDirectory() })),
2075
+ }),
2076
+ // File-only Python lock check (python-lock.mjs): no pip, uv or poetry is run.
2077
+ "python-lock": async (s, ctx) => {
2078
+ let parseToml;
2079
+ try { parseToml = await ctx.deps.loadToml(); } catch (e) { return { missing: `TOML parser unavailable: ${e.message}` }; }
2080
+ return pythonLockProblems({ dir: s.dir, names: ctx.deps.readdir(path.join(ctx.contentRoot, s.dir)).map(String), read: ctx.read, parseToml });
2081
+ },
1943
2082
  "yaml-parse": async (s, ctx) => {
1944
2083
  let parse;
1945
2084
  try { parse = await ctx.deps.loadYaml(); } catch (e) { return { missing: `YAML parser unavailable: ${e.message}` }; }
@@ -2005,8 +2144,18 @@ const INTERNAL = {
2005
2144
  * 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
2145
  * instead of being killed with the job still running; a user-set RUSTRIGHT_OPERATION_DEADLINE_MS or cancel wait is never replaced.
2007
2146
  */
2147
+ /** A span for a message: whole seconds under a minute, minutes from then on. */
2148
+ export const spanText = (ms) => (ms < 60_000 ? `${Math.max(0, Math.round(ms / 1000))} s` : `${Math.round(ms / 60_000)} min`);
2149
+ export const scanOperationDeadlineMs = (limitMs) => Math.max(Math.floor(limitMs / 2), limitMs - 20_000);
2150
+ /**
2151
+ * 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
2152
+ * retry wait); a watched one passes neither (the stopwatch and the ceiling bound it, and the client's deadline is absolute from its start).
2153
+ */
2154
+ export function carriedCallBudget({ env, limitMs, watched }) {
2155
+ return watched ? { env: {} } : { env: scanOperationEnv(env, limitMs), deadlineMs: scanOperationDeadlineMs(limitMs) };
2156
+ }
2008
2157
  export function scanOperationEnv(env, limitMs) {
2009
- const deadline = operationDeadlineEnv({ env, deadlineMs: Math.max(Math.floor(limitMs / 2), limitMs - 20_000) });
2158
+ const deadline = operationDeadlineEnv({ env, deadlineMs: scanOperationDeadlineMs(limitMs) });
2010
2159
  if (Object.keys(deadline).length === 0) return {};
2011
2160
  return { ...deadline, ...(Object.keys(env ?? {}).some((k) => k.toUpperCase() === CANCEL_WAIT_ENV) ? {} : { [CANCEL_WAIT_ENV]: "15000" }) };
2012
2161
  }
@@ -2070,6 +2219,14 @@ export async function runScans(plan, options = {}) {
2070
2219
  const ordered = [...selected.filter((s) => s.class === SCAN_CLASS.A), ...selected.filter((s) => s.class === SCAN_CLASS.B)];
2071
2220
  const ctx = { deps, contentRoot, read: (f) => deps.readFile(path.join(contentRoot, f)) };
2072
2221
  const cargoReadsAreCarried = Boolean(deps.snapshotBroker) || options.cargoReadsCarried === true;
2222
+ // 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.
2223
+ const brokerHost = options.brokerHost === true || Boolean(deps.snapshotBroker);
2224
+ const carriedCtx = { brokerHost, snapshotBroker: deps.snapshotBroker ?? null };
2225
+ // 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.
2226
+ const brokerLive = options.brokerLive ?? (brokerHost && typeof deps.snapshotBroker?.client === "string" ? createBrokerLive({ client: deps.snapshotBroker.client, env: deps.env, spawn: defaultSpawnAsync, platform: deps.platform }) : null);
2227
+ // 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.
2228
+ const pollMs = options.brokerPollMs ?? ADMISSION_POLL_MS;
2229
+ const statusPoller = brokerLive ? createStatusPoller({ live: brokerLive, intervalMs: pollMs }) : null;
2073
2230
  const toolVersions = {}; const toolIdentities = {}; const probed = new Map(); const advisoryDbs = new Map();
2074
2231
  const results = [];
2075
2232
  // The gate-owned cargo ledger of a wrapped snapshot compile (options.cargoLedger = { dir }): every call the wrapper decided, filed
@@ -2097,7 +2254,8 @@ export async function runScans(plan, options = {}) {
2097
2254
  // excluding every class A scan (or a plan with none) never unlocks class B; explicit prior evidence is needed.
2098
2255
  const planA = plan.scans.filter((x) => x.class === SCAN_CLASS.A);
2099
2256
  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;
2257
+ // `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).
2258
+ const classBBlocked = !inCallA && !priorClassAValid && options.classAEarlier !== true;
2101
2259
 
2102
2260
  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
2261
  async function probe(tool, scanDir = null) {
@@ -2118,13 +2276,20 @@ export async function runScans(plan, options = {}) {
2118
2276
  // A version probe is bounded (PROBE_TIMEOUT_MS): a hung tool reads as unavailable instead of holding a lane, or a quiet
2119
2277
  // lease, open.
2120
2278
  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);
2279
+ const signalFor = (ms) => (signal ? AbortSignal.any([signal, AbortSignal.timeout(ms)]) : AbortSignal.timeout(ms));
2122
2280
  // 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
2281
  // gate claims and checks like a scan's.
2124
2282
  const probeLedgerEnv = cargoLedger ? { [SCAN_TOKEN_ENV]: PROBE_LEDGER_TOKEN } : {};
2125
2283
  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}` };
2284
+ 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) });
2285
+ // One retry, with three times the bound, when only the bound stopped it (the run itself was not aborted).
2286
+ let r = await probeOnce(probeMs);
2287
+ if (r?.cancelled && !signal?.aborted) r = await probeOnce(probeMs * 3);
2288
+ // 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
2289
+ // named as a timeout and not remembered, so the next scan probes again (on a loaded Dell `cargo --version` outlived the 60 s
2290
+ // bound while three compiles ran, and the cached failure then failed every later class B row at 0 s, hrwin 4ccadec2).
2291
+ const timedOut = Boolean(r?.cancelled) && !signal?.aborted;
2292
+ 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
2293
  else {
2129
2294
  const text = String(r.stdout || r.stderr || "");
2130
2295
  const version = PROBE_VERSION[tool] ? PROBE_VERSION[tool](text) : text.split(/\r?\n/).find((l) => l.trim())?.trim() ?? "unknown";
@@ -2134,7 +2299,7 @@ export async function runScans(plan, options = {}) {
2134
2299
  }
2135
2300
  }
2136
2301
  toolVersions[tool] = outcome.ok ? outcome.version : null;
2137
- probed.set(tool, outcome);
2302
+ if (!outcome.transient) probed.set(tool, outcome);
2138
2303
  return outcome;
2139
2304
  }
2140
2305
 
@@ -2209,11 +2374,15 @@ export async function runScans(plan, options = {}) {
2209
2374
  const slots = new Array(bScans.length);
2210
2375
  const lanes = { compile: [], doc: [], tests: [], quiet: [] };
2211
2376
  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);
2377
+ // RightKit is many components, never one build (Adrian, 2026-10-29; rightkit#1285). A component is the directory a scan runs in (`cwd`, its
2378
+ // own Cargo workspace and output directory); scans of one component stay in plan order, because they share its output directory and cargo
2379
+ // locks it.
2380
+ // Direct host: class B scans of different components run `classBJobs` at a time (nothing else bounds work there), compile, doc, then tests.
2381
+ // Broker host (options.brokerHost; rightkit#1367 step 2, Adrian 2026-11-01: "the broker manages the how many builds happen. That is not the
2382
+ // 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
2383
+ // 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.
2384
+ const bJobs = Math.max(1, Number(options.classBJobs ?? (brokerHost ? LOCAL_CLASS_B_JOBS : 1)) || 1);
2385
+ 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
2386
  const drain = async (queue, extraEnv = null) => {
2218
2387
  const busy = new Set(); const running = new Set();
2219
2388
  const keyOf = (i) => bScans[i].cwd ?? ".";
@@ -2223,14 +2392,21 @@ export async function runScans(plan, options = {}) {
2223
2392
  const [i] = queue.splice(at, 1); const key = keyOf(i); busy.add(key);
2224
2393
  // A scan that throws becomes a failed row: with several components in flight, a rejection must not abandon the others.
2225
2394
  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) }))
2395
+ .catch((error) => failedRow(i, error))
2227
2396
  .then((row) => { slots[i] = row; }).finally(() => { busy.delete(key); running.delete(job); });
2228
2397
  running.add(job);
2229
2398
  }
2230
2399
  };
2231
- await drain(lanes.compile);
2232
- await drain(lanes.doc);
2233
- await drain(lanes.tests);
2400
+ if (brokerHost) {
2401
+ await scheduleBrokerHostClassB({
2402
+ scans: bScans, lanes, localJobs: bJobs, carried: (scan) => isBrokerScheduledScan(scan, carriedCtx),
2403
+ run: async (i) => { try { slots[i] = await runOne(bScans[i]); } catch (error) { slots[i] = failedRow(i, error); } },
2404
+ });
2405
+ } else {
2406
+ await drain(lanes.compile);
2407
+ await drain(lanes.doc);
2408
+ await drain(lanes.tests);
2409
+ }
2234
2410
  if (lanes.quiet.length) {
2235
2411
  // One lease per quiet suite, as in class A above.
2236
2412
  for (const i of lanes.quiet) {
@@ -2337,7 +2513,8 @@ export async function runScans(plan, options = {}) {
2337
2513
  let cacheKey = null;
2338
2514
  // A cargo request that builds the commit snapshot through the broker (options.snapshotBroker) is never served from or written
2339
2515
  // 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");
2516
+ const carriedScan = isBrokerCarriedScan(s, carriedCtx);
2517
+ const viaSnapshotBroker = carriedScan && Boolean(deps.snapshotBroker) && s.root === "cargoLive" && (s.tool === "rustright" || s.tool === "cargo");
2341
2518
  const carriedRead = cargoReadsAreCarried && s.class === SCAN_CLASS.A && (s.tool === "rustright" || s.tool === "cargo"); // a snapshot compile never reuses a cargo read either
2342
2519
  if (cache && !viaSnapshotBroker && !carriedRead) {
2343
2520
  try {
@@ -2421,13 +2598,28 @@ export async function runScans(plan, options = {}) {
2421
2598
  // generated outside the checked tree and removed after the run, so the check reads nothing a commit lacks and writes nothing.
2422
2599
  let tscProject = null;
2423
2600
  if (args.includes(TSC_PROJECT)) {
2424
- try { tscProject = writeTscProject({ contentRoot, packageDir: path.join(rootDir(s.root), s.cwd ?? ""), workspace: s.tscWorkspace ?? [] }); }
2601
+ // The siblings are mapped in the same tree tsc runs in (a publication scans a content checkout and a live checkout of
2602
+ // one verified commit; mixing them puts the package outside the project's rootDir, TS6059).
2603
+ try { tscProject = writeTscProject({ contentRoot: rootDir(s.root), packageDir: path.join(rootDir(s.root), s.cwd ?? ""), workspace: s.tscWorkspace ?? [] }); }
2425
2604
  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}` }; }
2426
2605
  args = args.map((a) => (a === TSC_PROJECT ? tscProject.file : a));
2427
2606
  }
2428
2607
  const limitMs = scanTimeoutMs(s, options);
2429
2608
  const timer = new AbortController();
2430
- const clock = setTimeout(() => timer.abort(), limitMs);
2609
+ // 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
2610
+ // 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.
2611
+ // 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
2612
+ // (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.
2613
+ const watched = carriedScan && s.class === SCAN_CLASS.B && s.quiet !== true && brokerLive !== null;
2614
+ let admission = null; let graceTimer = null; let genRefDir = null; let clockExpired = false;
2615
+ // A carried scan is cancelled by id first and its process tree stopped only after a grace; any other scan is stopped at once.
2616
+ const stopScan = () => {
2617
+ clockExpired = true;
2618
+ if (admission) { admission.cancelKnown().catch(() => {}); graceTimer = setTimeout(() => timer.abort(), options.brokerCancelGraceMs ?? CARRIED_CANCEL_GRACE_MS); } else timer.abort();
2619
+ };
2620
+ // 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.
2621
+ const runClock = createRunClock({ limitMs, onExpire: stopScan, ...(watched ? { ceilingMs: limitMs * QUEUED_CEILING_FACTOR, onCeiling: stopScan } : {}) });
2622
+ const stopClock = () => { runClock.stop(); admission?.stop(); if (graceTimer) clearTimeout(graceTimer); if (genRefDir) { fs.rmSync(genRefDir, { recursive: true, force: true }); genRefDir = null; } };
2431
2623
  const scanSignal = signal ? AbortSignal.any([signal, timer.signal]) : timer.signal;
2432
2624
  // A class A test row runs under the launch guard (class-a-launch-guard.mjs): its Node processes may not launch a compile.
2433
2625
  // Every class A row that executes repository code (tests, lint/typecheck with repository config, generators) runs under the
@@ -2440,12 +2632,12 @@ export async function runScans(plan, options = {}) {
2440
2632
  // build directory is the gate's own, keyed by the repository and the scan so it stays warm.
2441
2633
  // 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
2634
  // 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);
2635
+ const swiftBrokered = carriedScan && s.tool === "swift" && Boolean(deps.snapshotBroker);
2444
2636
  // The adapter takes exactly one relative --package-path (adapter.rs check_swift_package); the scan runs in its package directory, so that is `.`.
2445
2637
  if (swiftBrokered && finalArgs[0] === "build" && !finalArgs.some((a) => a === "--package-path" || a.startsWith("--package-path="))) finalArgs.splice(1, 0, "--package-path", ".");
2446
2638
  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
2639
  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}` }; }
2640
+ catch (e) { stopClock(); advisory?.cleanup?.(); return { ...row, status: "fail", code: "SWIFT_SCRATCH", detail: `${s.id}: ${e.message}` }; }
2449
2641
  }
2450
2642
  let carrier = null;
2451
2643
  if (viaSnapshotBroker) {
@@ -2456,7 +2648,7 @@ export async function runScans(plan, options = {}) {
2456
2648
  const cargoArgv = s.tool === "rustright" ? finalArgs.slice(1) : finalArgs;
2457
2649
  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
2650
  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}` }; }
2651
+ } 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
2652
  }
2461
2653
  // From here to the end of its verification the carrier and the request reference are this request's: every exit (cancel,
2462
2654
  // timeout, a thrown spawn, a refused receipt) removes them.
@@ -2479,22 +2671,26 @@ export async function runScans(plan, options = {}) {
2479
2671
  let marked = null; let why = null;
2480
2672
  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
2673
  if (marked?.broker?.submitted !== true || marked.handle !== deps.snapshotBroker.handle || !marked.broker.handles?.includes(sub)) {
2482
- clearTimeout(clock); advisory?.cleanup?.();
2674
+ stopClock(); advisory?.cleanup?.();
2483
2675
  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
2676
  }
2485
2677
  }
2486
2678
  if (swiftBrokered) {
2487
2679
  // 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.
2680
+ // 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.
2681
+ const swiftRefs = [];
2682
+ const swiftBudget = carriedCallBudget({ env: { ...deps.env, ...s.env }, limitMs, watched });
2683
+ if (watched) { admission = watchBrokerAdmission({ clock: runClock, refFiles: () => swiftRefs, live: brokerLive, poller: statusPoller }); admission.start(); }
2488
2684
  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" });
2685
+ 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
2686
  r = { status: carried.status, stdout: carried.stdout, stderr: carried.stderr };
2491
- } catch (e) { r = { error: e }; } finally { clearTimeout(clock); advisory?.cleanup?.(); }
2687
+ } catch (e) { r = { error: e }; } finally { stopClock(); advisory?.cleanup?.(); }
2492
2688
  } else {
2493
2689
  // The executables this scan runs are the ones resolved and verified above: read their bytes again now, immediately before the launch
2494
2690
  // (a replaced binary is TOOL_REPLACED), and hand the scan only those paths: actionlint its shellcheck, cargo its cargo-deny / cargo-audit.
2495
2691
  for (const identity of identities) {
2496
2692
  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` }; }
2693
+ if (swapped) { stopClock(); advisory?.cleanup?.(); return { ...row, status: "fail", code: swapped.code, detail: `${swapped.code}: ${swapped.detail}; the scan did not run` }; }
2498
2694
  }
2499
2695
  const shellcheckIdentity = s.tool === "actionlint" ? identities.find((i) => i.tool === "shellcheck") : null;
2500
2696
  const spawnArgs = shellcheckIdentity ? ["-shellcheck", shellcheckIdentity.path, ...finalArgs] : finalArgs;
@@ -2509,10 +2705,25 @@ export async function runScans(plan, options = {}) {
2509
2705
  const wrapperBin = (cargoLedger || options.cargoSentinel) && typeof scanCargo === "string" && typeof scanPath === "string" ? (deps.platform === "win32" ? path.win32 : path.posix).dirname(scanCargo) : null;
2510
2706
  const wrapperFirst = wrapperBin !== null && scanPath.split(pathSep)[0] === wrapperBin;
2511
2707
  const trustEnv = trustedDirs.length ? { [pathKey]: (wrapperFirst ? [wrapperBin, ...trustedDirs, scanPath.slice(wrapperBin.length + 1)] : [...trustedDirs, scanPath]).filter(Boolean).join(pathSep) } : {};
2512
- 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?.(); }
2708
+ // 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
2709
+ // 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).
2710
+ let refEnv = {};
2711
+ if (watched) {
2712
+ let refFiles;
2713
+ if (carrier) refFiles = () => [path.join(carrier.refDir, "request.json")];
2714
+ 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]; }
2715
+ 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 []; } };
2716
+ admission = watchBrokerAdmission({ clock: runClock, refFiles, live: brokerLive, poller: statusPoller });
2717
+ admission.start();
2718
+ }
2719
+ 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?.(); }
2513
2720
  }
2514
2721
  if (signal?.aborted && (!r || r.error || r.status !== 0)) return { ...row, status: "not-run", code: "CANCELLED", detail: "cancelled while running" };
2515
- 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` };
2722
+ if ((clockExpired || timer.signal.aborted || r?.error?.code === "ETIMEDOUT") && (!r || r.error || r.status !== 0)) {
2723
+ const how = `was ${admission ? "cancelled at the broker by id and stopped" : "killed"} with its process tree; a scan that does not finish fails`;
2724
+ 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}` };
2725
+ 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}` };
2726
+ }
2516
2727
  if (!r || r.error) { status = "fail"; code = "SPAWN_ERROR"; detail = r?.error?.message ?? "no result"; }
2517
2728
  else if (r.status !== 0) { status = "fail"; code = "EXIT"; detail = tail(r) || `exit ${r.status}`; }
2518
2729
  // cargo-deny reports some failures only as an [ERROR] line and still exits 0 (under the broker, its nested offline
@@ -2548,7 +2759,7 @@ export async function runScans(plan, options = {}) {
2548
2759
  const ref = readRequestRef(path.join(carrier.refDir, "request.json"));
2549
2760
  const receipt = await deps.snapshotBroker.receiptOf(ref.requestId);
2550
2761
  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 });
2551
- row.snapshotEvidence = { requestId: ref.requestId, outputDir: evidence.outputDir, treeAtStart: evidence.snapshot.treeAtStart, treeAtExit: evidence.snapshot.treeAtExit };
2762
+ row.snapshotEvidence = { requestId: ref.requestId, outputDir: evidence.outputDir, cargoDir: s.cwd ?? ".", treeAtStart: evidence.snapshot.treeAtStart, treeAtExit: evidence.snapshot.treeAtExit };
2552
2763
  } catch (e) {
2553
2764
  status = "fail"; code = "SNAPSHOT_EVIDENCE"; detail = `${s.id}: the broker's snapshot evidence did not verify (${e.code ?? "error"}: ${e.message})`;
2554
2765
  }
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;