@rightkit/release 0.6.16 → 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.
- package/README.md +7 -3
- package/admission-host.mjs +9 -5
- package/app-closure.mjs +9 -3
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/rightkit-gate.mjs +469 -102
- package/bin/rightkit-linux-setup.mjs +15 -0
- package/broker-admission.mjs +184 -0
- package/broker-wire.mjs +15 -3
- package/build-launch.mjs +4 -1
- package/build-ownership.mjs +125 -36
- package/build-release.mjs +39 -7
- package/cargo-contract.mjs +4 -1
- package/cargo-guard.mjs +25 -5
- package/cargo-target-shim.mjs +56 -7
- package/clippy-route.mjs +8 -7
- package/crate-graph.mjs +5 -1
- package/credential-provider.mjs +174 -0
- package/deterministic-scans.mjs +247 -40
- package/development.mjs +8 -0
- package/disk-policy.mjs +2 -2
- package/gate-executor.mjs +5 -3
- package/gate-stages.mjs +34 -3
- package/gradle-lock.mjs +206 -0
- package/heavy-command.mjs +33 -2
- package/linux-artifacts.mjs +2 -1
- package/linux-setup.mjs +106 -0
- package/local-publish.mjs +21 -10
- package/local-qualify.mjs +9 -3
- package/managed-cargo.mjs +54 -18
- package/ownership-lockfiles.mjs +14 -3
- package/ownership.mjs +5 -1
- package/package.json +17 -11
- package/protected-credentials.mjs +13 -5
- package/publication-gate.mjs +9 -4
- package/python-lock.mjs +209 -0
- package/qa-admission-policy.json +32 -0
- package/qa-layout.mjs +229 -0
- package/qa-runner.mjs +156 -0
- package/release-artifact-path.mjs +29 -3
- package/release-config.mjs +1 -1
- package/release-qa-command.mjs +58 -8
- package/release-qa-policy.mjs +5 -3
- package/release-source-identity.mjs +1 -0
- package/release.mjs +10 -2
- package/rightkit-ownership.json +1465 -79
- package/rightkit-registry-resolver.mjs +7 -3
- package/rightkit-versions.json +173 -36
- package/runtime-budget-classes.json +4 -0
- package/runtime-budgets.mjs +5 -1
- package/shell-contract.mjs +1 -1
- package/snapshot-broker.mjs +107 -9
- package/snapshot-check.mjs +2 -0
- package/snapshot-request.mjs +20 -3
- package/snapshot.mjs +11 -4
- package/stage-commands.mjs +2 -1
- package/stage-ledger.mjs +15 -0
- package/stage-sidecar.mjs +17 -3
- package/suite-signing.mjs +40 -4
- package/swift-lock.mjs +334 -0
- package/test-command.mjs +13 -3
- package/tool-build.mjs +156 -0
- package/unit-gate.mjs +9 -2
- package/windows-identity-plan.mjs +119 -0
- package/windows-identity.mjs +136 -0
package/deterministic-scans.mjs
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
2127
|
-
|
|
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)
|
|
2213
|
-
//
|
|
2214
|
-
//
|
|
2215
|
-
//
|
|
2216
|
-
|
|
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) => (
|
|
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
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
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
|
|
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
|
-
|
|
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 });
|
|
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,22 +2667,26 @@ 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
|
-
|
|
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 =
|
|
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 {
|
|
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) {
|
|
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;
|
|
@@ -2509,10 +2701,25 @@ export async function runScans(plan, options = {}) {
|
|
|
2509
2701
|
const wrapperBin = (cargoLedger || options.cargoSentinel) && typeof scanCargo === "string" && typeof scanPath === "string" ? (deps.platform === "win32" ? path.win32 : path.posix).dirname(scanCargo) : null;
|
|
2510
2702
|
const wrapperFirst = wrapperBin !== null && scanPath.split(pathSep)[0] === wrapperBin;
|
|
2511
2703
|
const trustEnv = trustedDirs.length ? { [pathKey]: (wrapperFirst ? [wrapperBin, ...trustedDirs, scanPath.slice(wrapperBin.length + 1)] : [...trustedDirs, scanPath]).filter(Boolean).join(pathSep) } : {};
|
|
2512
|
-
|
|
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?.(); }
|
|
2513
2716
|
}
|
|
2514
2717
|
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))
|
|
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
|
+
}
|
|
2516
2723
|
if (!r || r.error) { status = "fail"; code = "SPAWN_ERROR"; detail = r?.error?.message ?? "no result"; }
|
|
2517
2724
|
else if (r.status !== 0) { status = "fail"; code = "EXIT"; detail = tail(r) || `exit ${r.status}`; }
|
|
2518
2725
|
// cargo-deny reports some failures only as an [ERROR] line and still exits 0 (under the broker, its nested offline
|
|
@@ -2548,7 +2755,7 @@ export async function runScans(plan, options = {}) {
|
|
|
2548
2755
|
const ref = readRequestRef(path.join(carrier.refDir, "request.json"));
|
|
2549
2756
|
const receipt = await deps.snapshotBroker.receiptOf(ref.requestId);
|
|
2550
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 });
|
|
2551
|
-
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 };
|
|
2552
2759
|
} catch (e) {
|
|
2553
2760
|
status = "fail"; code = "SNAPSHOT_EVIDENCE"; detail = `${s.id}: the broker's snapshot evidence did not verify (${e.code ?? "error"}: ${e.message})`;
|
|
2554
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;
|
package/gate-executor.mjs
CHANGED
|
@@ -19,13 +19,15 @@ export const EXECUTOR_KINDS = Object.freeze(["broker", "refuse", "direct"]);
|
|
|
19
19
|
* @param {{ forceDirect?: boolean, env?: object, platform?: string, exists?: (p: string) => boolean, which: (cmd: string) => string[], readFile: (p: string) => string, fsOps?: object }} o
|
|
20
20
|
* @returns {{ kind: "broker" | "refuse" | "direct", cargo: string[] | null, problem?: string, code?: string, route?: "linux-broker", client?: string, socket?: string, clientSha256?: string, env?: { PATH?: string, RUSTRIGHT_BUILD_BROKER_SOCKET?: string }, note?: string }}
|
|
21
21
|
*/
|
|
22
|
-
export function resolveExecutor({ env = process.env, platform = process.platform, exists, which, readFile, fsOps, forceDirect = false }) {
|
|
22
|
+
export function resolveExecutor({ env = process.env, platform = process.platform, exists, which, readFile, fsOps, forceDirect = false, gateWrappers = false }) {
|
|
23
23
|
// Every Linux (and any other non-Mac, non-Windows) execution host runs direct, whatever is on PATH or in the environment: WSL2, a VM,
|
|
24
24
|
// a cloud or CI runner, or Linux hosted on the Dell (GATE-CONTRACT §1 "One entry"; joint plan step 4), unless the host explicitly declared
|
|
25
25
|
// the optional Linux broker route (below). Adrian's Mac and Windows hosts use the broker as before.
|
|
26
26
|
// `cargo` there must be a real toolchain: RustRight shim directories are taken off the PATH the gate hands its children (`env`).
|
|
27
27
|
// `forceDirect` is the one documented exception (broker-exception.mjs, validated by the gate before it is passed): a Mac or Dell run
|
|
28
28
|
// outside the broker, by Adrian's recorded approval. It takes the direct route with the same shim scrub, never the Linux broker route.
|
|
29
|
+
// `gateWrappers`: only rightkit-gate resolving its OWN executor also takes an outer gate's cargo wrapper off PATH (a gate inside another gate's scan).
|
|
30
|
+
// build-launch never passes it: a right-release child inside a stage must keep the stage's wrapper first, the binding it goes through.
|
|
29
31
|
if (forceDirect || directPlatform(platform)) {
|
|
30
32
|
const linux = forceDirect ? { declared: false } : resolveLinuxBrokerRoute({ env, platform, fsOps });
|
|
31
33
|
if (linux.declared) {
|
|
@@ -39,8 +41,8 @@ export function resolveExecutor({ env = process.env, platform = process.platform
|
|
|
39
41
|
};
|
|
40
42
|
}
|
|
41
43
|
const pathKey = platform === "win32" ? Object.keys(env).find((k) => k.toUpperCase() === "PATH") ?? "PATH" : "PATH";
|
|
42
|
-
const direct = directCargoPath({ env: { ...env, PATH: env[pathKey] }, exists, platform });
|
|
43
|
-
return { kind: "direct", cargo: ["cargo"], ...(direct.dropped.length ? { env: { [pathKey]: direct.path }, note:
|
|
44
|
+
const direct = directCargoPath({ env: { ...env, PATH: env[pathKey] }, exists, platform, gateWrappers });
|
|
45
|
+
return { kind: "direct", cargo: ["cargo"], ...(direct.dropped.length ? { env: { [pathKey]: direct.path }, note: `${gateWrappers ? "RustRight shims and outer gate cargo wrappers" : "RustRight shims"} taken off PATH for direct cargo: ${direct.dropped.join(", ")}` } : {}), ...(direct.cargo ? {} : { note: `no direct cargo on PATH${direct.dropped.length ? ` (RustRight shims and gate wrappers skipped: ${direct.dropped.join(", ")})` : ""}; cargo scans fail as a missing tool` }) };
|
|
44
46
|
}
|
|
45
47
|
if (which("rustright").length) return { kind: "broker", cargo: ["rustright", "cargo"] };
|
|
46
48
|
const first = which("cargo")[0];
|