@rightkit/release 0.6.8 → 0.6.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -12
- package/admission-files.mjs +4 -1
- package/admission.mjs +2 -2
- package/advisory-exceptions.mjs +41 -5
- package/android-play-upload.mjs +16 -15
- package/app-check.mjs +105 -27
- package/assemble-app-command.mjs +151 -0
- package/assembled-app.mjs +445 -0
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/darwin/rightkit-swap.sign.json +6 -0
- package/bin/rightkit-gate.mjs +1608 -345
- package/broker-pins.mjs +152 -0
- package/build-launch.mjs +88 -0
- package/build-ownership.mjs +1231 -0
- package/build-release.mjs +82 -28
- package/cargo-contract.mjs +3 -1
- package/cargo-ledger.mjs +62 -0
- package/cargo-target-shim.mjs +238 -0
- package/class-a-launch-guard.mjs +126 -0
- package/cli/right-release.mjs +31 -5
- package/clippy-equivalence.mjs +202 -0
- package/clippy-route.mjs +543 -0
- package/codesign-shim.mjs +11 -1
- package/committed-source.mjs +39 -5
- package/crate-workspaces.mjs +87 -0
- package/create-mac-updater.mjs +14 -2
- package/deterministic-scans.mjs +997 -71
- package/development-signing.mjs +4 -1
- package/development.mjs +120 -21
- package/direct-receipt.mjs +135 -0
- package/door-policy.mjs +71 -6
- package/door.mjs +6 -6
- package/freshness-census.mjs +257 -0
- package/gate-executor.mjs +14 -2
- package/gate-stages.mjs +91 -15
- package/heavy-command.mjs +3 -2
- package/hook-retirement.mjs +65 -0
- package/init-command.mjs +1 -1
- package/install-app.mjs +11 -2
- package/install-assembled-app.mjs +423 -0
- package/inventory-check.mjs +11 -8
- package/ios-dev.mjs +70 -3
- package/ios-upload.mjs +20 -15
- package/linux-artifacts.mjs +93 -0
- package/local-publish.mjs +224 -53
- package/local-qualify.mjs +5 -3
- package/local-runtime.mjs +5 -4
- package/mac-package-recipe.mjs +4 -1
- package/managed-cargo.mjs +47 -5
- package/managed-root-guard.mjs +34 -0
- package/mirror-root-artifact.mjs +18 -5
- package/native-helpers.mjs +355 -0
- package/ownership-class-rules.mjs +1 -1
- package/ownership-evidence.mjs +37 -13
- package/ownership-hooks.mjs +20 -59
- package/ownership-lockfiles.mjs +14 -1
- package/ownership-policy-bundle.mjs +3 -2
- package/ownership-staged.mjs +2 -1
- package/ownership.mjs +24 -0
- package/package-recipe-targets.mjs +84 -10
- package/package.json +4 -3
- package/pipeline-admission.mjs +4 -4
- package/pipeline-receipts.mjs +302 -302
- package/preflight.mjs +27 -4
- package/project-type.mjs +2 -2
- package/provenance-verify.mjs +115 -0
- package/publish-cargo.mjs +12 -9
- package/qa-admission-policy.json +10 -1
- package/recipe-build.mjs +106 -14
- package/release-artifact-path.mjs +341 -0
- package/release-config.mjs +1 -1
- package/release-invocation.mjs +8 -1
- package/release-qa-command.mjs +25 -9
- package/release-source-brand.mjs +17 -0
- package/release-source-identity.mjs +171 -0
- package/release.mjs +59 -22
- package/rightkit-versions.json +73 -47
- package/runtime-budgets.mjs +6 -2
- package/scan-digest.mjs +71 -5
- package/sign-updater.mjs +6 -3
- package/snapshot-broker.mjs +172 -0
- package/snapshot-check.mjs +312 -0
- package/snapshot-git.mjs +166 -0
- package/snapshot-request.mjs +363 -0
- package/snapshot-root-config.mjs +76 -0
- package/snapshot.mjs +969 -0
- package/source-split.mjs +73 -0
- package/stage-commands.mjs +60 -0
- package/stage-ledger.mjs +204 -0
- package/stage-sidecar-command.mjs +5 -1
- package/stage-sidecar.mjs +157 -27
- package/swap-helper-bundle.mjs +5 -2
- package/test-command.mjs +17 -4
- package/unit-gate.mjs +301 -0
- package/web-build.mjs +40 -6
- package/win-package-recipe.mjs +20 -7
package/deterministic-scans.mjs
CHANGED
|
@@ -23,11 +23,16 @@ import os from "node:os";
|
|
|
23
23
|
import path from "node:path";
|
|
24
24
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
25
25
|
import { spawn as spawnChild, spawnSync } from "./background-process.mjs";
|
|
26
|
+
import { spawnUnderOwnership } from "./build-ownership.mjs";
|
|
26
27
|
import { checkAdvisoryExceptions } from "./advisory-exceptions.mjs";
|
|
27
|
-
import { canonicalJson, digestScanSet, requiredScanIds, rustCratePlan, rustScanId } from "./scan-digest.mjs";
|
|
28
|
+
import { CLIPPY_UNIVERSAL_FEATURE_FLAGS, canonicalJson, clippyBatchPlan, clippySelectionOf, digestScanSet, requiredScanIds, rustCratePlan, rustScanId } from "./scan-digest.mjs";
|
|
28
29
|
import { reach } from "./lock-graph.mjs";
|
|
29
30
|
import { cargoManifestState } from "./cargo-manifest-text.mjs";
|
|
31
|
+
import { LAUNCH_GUARD_IMPORT } from "./class-a-launch-guard.mjs";
|
|
30
32
|
import { advisoryCacheRoot, ensureAdvisoryDb, prepareDenyOverlay, readAdvisoryPin } from "./advisory-db.mjs";
|
|
33
|
+
import { observedEnvOf, readRequestRef, verifySnapshotReceipt, writeSnapshotRequest } from "./snapshot-request.mjs";
|
|
34
|
+
import { SCAN_TOKEN_ENV, readLedger, scanTokenOf, unclaimedLedgers } from "./cargo-ledger.mjs";
|
|
35
|
+
import { verifyBrokerLedger } from "./snapshot-broker.mjs";
|
|
31
36
|
|
|
32
37
|
export const SCAN_RESULT_SCHEMA = "rightkit.deterministic-scans/1";
|
|
33
38
|
/**
|
|
@@ -45,16 +50,29 @@ export const CHECKER_IDENTITY = (() => {
|
|
|
45
50
|
})();
|
|
46
51
|
export const CHECKER_VERSION = CHECKER_IDENTITY;
|
|
47
52
|
export const SCAN_CLASS = Object.freeze({ A: "A", B: "B" });
|
|
53
|
+
/**
|
|
54
|
+
* Recorded in the result of every wrapped snapshot compile (item 7 row 7.10, lead re-decision F7): cargo hands a build script its own
|
|
55
|
+
* path in CARGO (measured, cargo 1.98.0), so a cargo a build script runs through $CARGO never reaches the wrapper's ledger.
|
|
56
|
+
*/
|
|
57
|
+
export const UNOBSERVED_NESTED_CARGO_NOTE = "cargo calls that a build script makes through $CARGO are unobserved: cargo hands build scripts its own path, not the gate's wrapper, so they are not in the cargo ledger; they inherit the parent's bound CARGO_TARGET_DIR, and the per-scan sentinel and the after-compile snapshot verify are the backstops";
|
|
58
|
+
|
|
59
|
+
/** The ledger token of the gate's own tool version probes (they reach the cargo wrapper like any scan does). */
|
|
60
|
+
const PROBE_LEDGER_TOKEN = scanTokenOf("tool-probe");
|
|
48
61
|
|
|
49
62
|
/** Scan id prefixes (the text before the first ":") that belong to each class. */
|
|
50
63
|
export const CLASS_A_SCAN_PREFIXES = Object.freeze([
|
|
51
64
|
"rustfmt", "node-check", "json-parse", "toml-parse", "yaml-parse", "ownership-catalog",
|
|
52
65
|
"actionlint", "shellcheck", "pnpm-lockfile", "pnpm-audit", "cargo-metadata", "cargo-deny", "cargo-audit", "cargo-package-list",
|
|
53
66
|
"changelog-version", "license", "secret-scan", "path-policy", "advisory-exceptions", "suite-api",
|
|
67
|
+
// Everything mechanical and deterministic that does not compile is class A (Adrian, 2026-10-27): typecheck, lint, generated docs.
|
|
68
|
+
"tsc", "eslint", "biome", "generated-docs",
|
|
54
69
|
]);
|
|
70
|
+
// Class B includes compiles and tests that execute project code.
|
|
55
71
|
export const CLASS_B_SCAN_PREFIXES = Object.freeze([
|
|
56
|
-
"clippy", "check", "check-all-features", "check-features", "check-dependent", "doc", "
|
|
72
|
+
"clippy", "check", "check-all-features", "check-features", "check-dependent", "doc", "swift-build",
|
|
57
73
|
]);
|
|
74
|
+
// Test scripts can invoke a compiler indirectly, so all tests run after Class A.
|
|
75
|
+
export const TEST_SCAN_CLASS = Object.freeze({ "test:rust": "B", "test:rust-serial": "B", "test:npm": "B", "test:scripts-ci": "B" });
|
|
58
76
|
|
|
59
77
|
/**
|
|
60
78
|
* Rollout switch for the CI integration (ci/gate.config.json: { "deterministicScans": { "enabled": true } }).
|
|
@@ -92,8 +110,14 @@ export function stripJsonComments(text) {
|
|
|
92
110
|
}
|
|
93
111
|
|
|
94
112
|
/** "A", "B" or null for an id such as "clippy:rightkit-fs". */
|
|
95
|
-
export function scanClassOf(id) {
|
|
96
|
-
const
|
|
113
|
+
export function scanClassOf(id, scan = null) {
|
|
114
|
+
const parts = String(id).split(":");
|
|
115
|
+
const prefix = parts[0];
|
|
116
|
+
if (prefix === "test") {
|
|
117
|
+
const kind = TEST_SCAN_CLASS[`test:${parts[1]}`] ?? null;
|
|
118
|
+
if (kind === "A" && scan?.brokerCall) return SCAN_CLASS.B;
|
|
119
|
+
return kind === null ? null : SCAN_CLASS[kind];
|
|
120
|
+
}
|
|
97
121
|
if (CLASS_A_SCAN_PREFIXES.includes(prefix)) return SCAN_CLASS.A;
|
|
98
122
|
if (CLASS_B_SCAN_PREFIXES.includes(prefix)) return SCAN_CLASS.B;
|
|
99
123
|
return null;
|
|
@@ -183,6 +207,19 @@ export function checkArgs(unit, { features = [], messageFormat, locked = false }
|
|
|
183
207
|
}
|
|
184
208
|
|
|
185
209
|
/** `cargo doc -p <unit> --no-deps --all-features --locked`; run it with RUSTDOCFLAGS=DOC_RUSTDOCFLAGS (CI). */
|
|
210
|
+
// Every compile reports every failure (Adrian, 2026-10-29: "even to ship, why isn't it better to get all errors and fix them all
|
|
211
|
+
// wholesale?"; rightkit#1293): a compile keeps going past a failed crate (`--keep-going`) and a test run past a failed test binary
|
|
212
|
+
// (`--no-fail-fast`). The run still fails if anything failed; the crates that compiled stay warm for the next run.
|
|
213
|
+
// Cargo 1.98: build, check, clippy (through its cargo check) and doc take `--keep-going`; test and bench reject it and take
|
|
214
|
+
// `--no-fail-fast` instead (the broker's BR29 adds the same split).
|
|
215
|
+
const KEEP_GOING_SUBCOMMANDS = new Set(["build", "check", "clippy", "doc"]);
|
|
216
|
+
const NO_FAIL_FAST_SUBCOMMANDS = new Set(["test", "bench"]);
|
|
217
|
+
export function everyFailure(args) {
|
|
218
|
+
const [sub, ...rest] = args;
|
|
219
|
+
const until = rest.indexOf("--"); const own = until === -1 ? rest : rest.slice(0, until);
|
|
220
|
+
const flag = KEEP_GOING_SUBCOMMANDS.has(sub) ? "--keep-going" : NO_FAIL_FAST_SUBCOMMANDS.has(sub) ? "--no-fail-fast" : null;
|
|
221
|
+
return flag && !own.includes(flag) ? [sub, flag, ...rest] : args;
|
|
222
|
+
}
|
|
186
223
|
export function docArgs(unit) { return ["doc", "-p", unit, "--no-deps", "--all-features", "--locked"]; }
|
|
187
224
|
export const DOC_RUSTDOCFLAGS = "-D warnings";
|
|
188
225
|
/** The same check as rustdoc arguments (`cargo rustdoc ... -- -D warnings`), for the broker path. */
|
|
@@ -194,6 +231,35 @@ export function fmtCheckArgs(unit = null) { return unit ? ["fmt", "-p", unit, "-
|
|
|
194
231
|
/** `cargo metadata` that validates manifests and the lock without resolving anything new. */
|
|
195
232
|
export function metadataArgs({ noDeps = true } = {}) { return ["metadata", ...(noDeps ? ["--no-deps"] : []), "--format-version", "1", "--locked"]; }
|
|
196
233
|
|
|
234
|
+
// ---- Clippy batching (GATE-CONTRACT §1 "Clippy route & batching"; item 8 rows 8.6, 8.8, 8.15) -----------------------------------------
|
|
235
|
+
|
|
236
|
+
// The batch planner itself (clippyBatchPlan, clippySelectionOf, CLIPPY_UNIVERSAL_FEATURE_FLAGS) lives in scan-digest.mjs, beside the scan
|
|
237
|
+
// ids, and is re-exported here.
|
|
238
|
+
export { CLIPPY_UNIVERSAL_FEATURE_FLAGS, clippyBatchPlan, clippySelectionOf };
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Does this crate's directory declare sidecars its build needs staged first? A Tauri crate with `bundle.externalBin` refuses to build
|
|
242
|
+
* until its sidecars are in place (bin/rightkit-gate.mjs, the compile stage), so it is scheduled after the independent crates. The
|
|
243
|
+
* declaration is read from the crate's own Tauri configuration (JSON, JSON5 or TOML), in its directory or its `src-tauri`.
|
|
244
|
+
*/
|
|
245
|
+
export function sidecarBoundCrate({ dir, exists = fs.existsSync, readFile = (f) => fs.readFileSync(f, "utf8") }) {
|
|
246
|
+
for (const base of [dir, path.join(dir, "src-tauri")]) {
|
|
247
|
+
for (const name of ["tauri.conf.json", "tauri.conf.json5", "Tauri.toml"]) {
|
|
248
|
+
const file = path.join(base, name);
|
|
249
|
+
let body;
|
|
250
|
+
try { if (!exists(file)) continue; body = readFile(file); } catch { continue; }
|
|
251
|
+
try {
|
|
252
|
+
const conf = JSON.parse(body);
|
|
253
|
+
const list = conf?.bundle?.externalBin ?? conf?.tauri?.bundle?.externalBin;
|
|
254
|
+
if (Array.isArray(list) && list.length) return { bound: true, file, externalBin: list };
|
|
255
|
+
continue;
|
|
256
|
+
} catch { /* not strict JSON: read as text */ }
|
|
257
|
+
if (/(^|[\s{,"'])externalBin["']?\s*[:=]\s*\[\s*["']/.test(body)) return { bound: true, file, externalBin: null };
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return { bound: false };
|
|
261
|
+
}
|
|
262
|
+
|
|
197
263
|
// ---- plan ---------------------------------------------------------------------------------------------
|
|
198
264
|
|
|
199
265
|
const posix = (p) => p.replaceAll("\\", "/");
|
|
@@ -224,6 +290,49 @@ const PNPM_NO_HOOKS_ENV = Object.freeze({ npm_config_ignore_pnpmfile: "true", np
|
|
|
224
290
|
// fails in the tool or the test with its own error. The `pnpm_config_` spelling is the one pnpm 11.25 honours for this setting
|
|
225
291
|
// (`npm_config_verify_deps_before_run=false` still aborted, checked on a stale tree), the lowercase spelling PNPM_NO_HOOKS_ENV uses for its settings.
|
|
226
292
|
const PNPM_RUN_ENV = Object.freeze({ pnpm_config_verify_deps_before_run: "false" });
|
|
293
|
+
|
|
294
|
+
// The placeholder a tsc row passes as its project when its package depends on a workspace sibling (planScans; runScans replaces it).
|
|
295
|
+
export const TSC_PROJECT = "{tscProject}";
|
|
296
|
+
const DIST_TYPES = /^\.\/dist\/(.+)\.d\.ts$/;
|
|
297
|
+
/**
|
|
298
|
+
* Writes the project a tsc row checks through: it extends the package's own tsconfig (its include, exclude and options, resolved from
|
|
299
|
+
* there), emits nothing, widens rootDir to the checked tree, keeps the package's own @types lookup chain (typeRoots), and maps every
|
|
300
|
+
* exported entry point of each workspace sibling whose types
|
|
301
|
+
* are built output (`types: ./dist/<x>.d.ts`) to that sibling's committed source (`src/<x>.ts`, `.tsx`, `/index.ts`, `/index.tsx`).
|
|
302
|
+
* `workspace` is the siblings' tree-relative directories. Returns { dir, file }; the caller removes `dir`. Throws when a sibling's
|
|
303
|
+
* manifest cannot be read or an exported entry point has no committed source: such a sibling could only be checked against a build.
|
|
304
|
+
*/
|
|
305
|
+
export function writeTscProject({ contentRoot, packageDir, workspace, tmpdir = os.tmpdir() }) {
|
|
306
|
+
const paths = {};
|
|
307
|
+
for (const rel of workspace) {
|
|
308
|
+
const dir = path.join(contentRoot, rel);
|
|
309
|
+
let manifest;
|
|
310
|
+
try { manifest = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8").replace(/^/, "")); }
|
|
311
|
+
catch (e) { throw new Error(`${rel}/package.json: ${e.message}`); }
|
|
312
|
+
if (typeof manifest.name !== "string" || path.resolve(dir) === path.resolve(packageDir)) continue;
|
|
313
|
+
const exported = manifest.exports && typeof manifest.exports === "object" ? Object.entries(manifest.exports) : [];
|
|
314
|
+
for (const [sub, target] of exported) {
|
|
315
|
+
const types = target && typeof target === "object" ? target.types : null;
|
|
316
|
+
const m = typeof types === "string" ? DIST_TYPES.exec(types) : null;
|
|
317
|
+
if (!m) continue;
|
|
318
|
+
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));
|
|
319
|
+
if (!source) throw new Error(`${manifest.name} exports ${sub} with types ${types}, and ${rel}/src has no committed source for it`);
|
|
320
|
+
paths[sub === "." ? manifest.name : `${manifest.name}${sub.slice(1)}`] = [source];
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
// TypeScript looks type libraries up (`types`, and every @types package by default) in node_modules/@types of the CONFIG's directory
|
|
324
|
+
// and its ancestors. The project lives outside the tree, so it names the chain the package's own tsconfig would have used.
|
|
325
|
+
const typeRoots = [];
|
|
326
|
+
for (let d = path.resolve(packageDir); ; d = path.dirname(d)) {
|
|
327
|
+
const at = path.join(d, "node_modules", "@types");
|
|
328
|
+
if (fs.existsSync(at)) typeRoots.push(at);
|
|
329
|
+
if (path.dirname(d) === d) break;
|
|
330
|
+
}
|
|
331
|
+
const out = fs.mkdtempSync(path.join(tmpdir, "rk-tsc-"));
|
|
332
|
+
const file = path.join(out, "tsconfig.json");
|
|
333
|
+
fs.writeFileSync(file, `${JSON.stringify({ extends: path.join(packageDir, "tsconfig.json"), compilerOptions: { noEmit: true, rootDir: path.resolve(contentRoot), typeRoots, paths } }, null, 2)}\n`);
|
|
334
|
+
return { dir: out, file };
|
|
335
|
+
}
|
|
227
336
|
const CONFIG_CRATE_FILE = /(^|\/)Cargo\.(toml|lock)$/;
|
|
228
337
|
const JS_TOOLING_FILE = /(^|\/)(package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml)$/;
|
|
229
338
|
// Root files that change how EVERY crate (or package) builds: a change to one puts every unit in scope,
|
|
@@ -248,6 +357,11 @@ export const ancestorConfigChanged = (ws, changed, pattern = ANCESTOR_RUST_CONFI
|
|
|
248
357
|
const rel = dir === "." ? f : f.startsWith(`${dir}/`) ? f.slice(dir.length + 1) : null;
|
|
249
358
|
return rel !== null && pattern.test(rel);
|
|
250
359
|
}));
|
|
360
|
+
// Genuinely shared inputs of a repository whose every crate is its own Cargo workspace (rightkit#1285): no crate directory holds them,
|
|
361
|
+
// so a change to one reaches every unit. Anything else outside a crate directory reaches no crate (a vendored source maps to the crates
|
|
362
|
+
// that patch or path-depend on it through `externalInputs`; a repository-level toolchain, .cargo, clippy or rustfmt file is an
|
|
363
|
+
// ANCESTOR_RUST_CONFIG_FILE of every workspace). The gate configuration, the pipeline's toolchain pins and the gate itself are named here.
|
|
364
|
+
export const SHARED_RUST_SCOPE_FILE = /^(ci\/gate\.config\.json|ci\/rk-pipeline\.json|packages\/release\/(bin\/rightkit-gate|deterministic-scans|scan-digest|advisory-exceptions|crate-workspaces|lock-graph|cargo-target-shim)\.mjs)$/;
|
|
251
365
|
const WORKSPACE_JS_FILE = /^(package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml|\.npmrc)$/;
|
|
252
366
|
// The linter a package has is the one it configures. ESLint reads a flat `eslint.config.*` or a legacy `.eslintrc*` from the package directory or
|
|
253
367
|
// any ancestor of it; Biome reads `biome.json` / `biome.jsonc` the same way. A Biome-only app (ViewRight, ScrapeRight: `"lint": "biome lint ."`)
|
|
@@ -278,6 +392,26 @@ const PROBES = Object.freeze({
|
|
|
278
392
|
* provision exactly this version; scripts/ci/scan-receipt.test.mjs holds them to it.
|
|
279
393
|
*/
|
|
280
394
|
export const PINNED_SCAN_TOOLS = Object.freeze({ shellcheck: "0.11.0" });
|
|
395
|
+
/** Scans whose answer depends on a remote service at run time, not only on their inputs: `pnpm-audit` asks the registry's advisory
|
|
396
|
+
* service. A verdict that includes them is reusable only after they are re-run (`rightkit-gate live-scans`; GATE-CONTRACT §1, E18),
|
|
397
|
+
* and a red caused by one is never deterministic. Ids are `<prefix>` or `<prefix>:<root>`; rustright's REMOTE_SCANS mirrors this list. */
|
|
398
|
+
export const LIVE_SCAN_IDS = Object.freeze(["pnpm-audit"]);
|
|
399
|
+
export const isLiveScan = (id) => LIVE_SCAN_IDS.some((p) => id === p || String(id).startsWith(`${p}:`));
|
|
400
|
+
/** Scans whose FAILURE may come from the registry rather than their inputs: `pnpm-lockfile` (`install --frozen-lockfile --lockfile-only`)
|
|
401
|
+
* re-verifies lockfile entries against the registry when pnpm's cache is cold, so an outage fails it. Their pass is deterministic, but a
|
|
402
|
+
* red they cause is never a reusable deterministic verdict (rightkit#1257; Oracle review of 79c4427b). */
|
|
403
|
+
export const REGISTRY_SCAN_IDS = Object.freeze(["pnpm-lockfile"]);
|
|
404
|
+
export const isRegistryScan = (id) => REGISTRY_SCAN_IDS.some((p) => id === p || String(id).startsWith(`${p}:`));
|
|
405
|
+
/** Files per rustfmt scan: 120 paths of ~100 bytes stay under Windows' 32 KB command line with the tool and flags. */
|
|
406
|
+
export const RUSTFMT_BATCH = 120;
|
|
407
|
+
|
|
408
|
+
/** True when this module is the checked repository's own in-tree copy (packages/release of `contentRoot`), where its scripts/ live. */
|
|
409
|
+
export function inTreeGate(contentRoot, exists = fs.existsSync) {
|
|
410
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
411
|
+
const real = (p) => { try { return fs.realpathSync(p); } catch { return path.resolve(p); } };
|
|
412
|
+
const own = real(path.resolve(here, "..", ".."));
|
|
413
|
+
return own === real(contentRoot) && exists(path.join(own, "scripts", "gen-suite-api.mjs"));
|
|
414
|
+
}
|
|
281
415
|
// The version a probe's output names, for a tool whose first line is not its version (`shellcheck --version` opens with a banner).
|
|
282
416
|
const PROBE_VERSION = Object.freeze({
|
|
283
417
|
shellcheck: (text) => { const m = /^version:\s*(\S+)\s*$/m.exec(text); return m ? `shellcheck ${m[1]}` : null; },
|
|
@@ -410,6 +544,195 @@ export function scriptBrokerCall(scripts, entry = "test", { maxDepth = 8 } = {})
|
|
|
410
544
|
return visit(String(entry), []);
|
|
411
545
|
}
|
|
412
546
|
|
|
547
|
+
/**
|
|
548
|
+
* Whether the package script `entry` builds (emits) code before or while it tests: `tsc` without --noEmit, or a package-manager
|
|
549
|
+
* run of a `build` / `build:*` script in this package or another (`pnpm run build`, `pnpm --filter <pkg> build`), followed through
|
|
550
|
+
* the scripts it starts in the same package.json like scriptBrokerCall. Such a test is not a pre-compile check: it runs after
|
|
551
|
+
* class A, with the compile scans (Codex reviewer 2026-10-28). Returns `{ via, command }` or null.
|
|
552
|
+
*/
|
|
553
|
+
export function scriptBuildCall(scripts, entry = "test", { maxDepth = 8 } = {}) {
|
|
554
|
+
if (scripts === null || typeof scripts !== "object" || Array.isArray(scripts)) return null;
|
|
555
|
+
const textOf = (name) => (Object.hasOwn(scripts, name) && typeof scripts[name] === "string" ? scripts[name].slice(0, SCRIPT_TEXT_LIMIT) : null);
|
|
556
|
+
const builds = (words) => {
|
|
557
|
+
let i = 0;
|
|
558
|
+
while (i < words.length && (ENV_ASSIGNMENT.test(words[i]) || COMMAND_WRAPPERS.has(path.posix.basename(words[i])) || PACKAGE_RUNNERS.has(path.posix.basename(words[i])))) i += 1;
|
|
559
|
+
const word = path.posix.basename(String(words[i] ?? "").replaceAll("\\", "/")).replace(/\.(exe|cmd|bat)$/i, "");
|
|
560
|
+
if (word === "tsc") return !words.slice(i + 1).some((w) => /^--?noEmit(=true)?$/i.test(w));
|
|
561
|
+
if (!PACKAGE_MANAGERS.has(word)) return false;
|
|
562
|
+
// pnpm's `-w` is `--workspace-root` (a flag); npm's `-w` is `--workspace <name>` (takes a value).
|
|
563
|
+
const rest = words.slice(i + 1); const values = new Set(word === "pnpm" ? ["--filter", "-F", "--dir", "-C"] : word === "npm" ? ["--workspace", "-w", "--prefix"] : ["--cwd", "--filter"]);
|
|
564
|
+
for (let j = 0; j < rest.length; j += 1) {
|
|
565
|
+
if (values.has(rest[j])) { j += 1; continue; }
|
|
566
|
+
if (rest[j].startsWith("-") || rest[j] === "run" || rest[j] === "run-script") continue;
|
|
567
|
+
return /^build(:|$)/.test(rest[j]);
|
|
568
|
+
}
|
|
569
|
+
return false;
|
|
570
|
+
};
|
|
571
|
+
const seen = new Set();
|
|
572
|
+
const visit = (name, via) => {
|
|
573
|
+
if (via.length >= maxDepth || seen.has(name)) return null;
|
|
574
|
+
seen.add(name);
|
|
575
|
+
for (const part of [`pre${name}`, name, `post${name}`]) {
|
|
576
|
+
const text = textOf(part);
|
|
577
|
+
if (text === null) continue;
|
|
578
|
+
for (const words of shellCommands(text)) {
|
|
579
|
+
if (builds(words)) return { via: [...via, part], command: words.join(" ").slice(0, 200) };
|
|
580
|
+
const found = inspectCommand(words, scripts);
|
|
581
|
+
if (found.run !== undefined) { const deeper = visit(found.run, [...via, part]); if (deeper) return deeper; }
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
return null;
|
|
585
|
+
};
|
|
586
|
+
return visit(String(entry), []);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Cargo redirections on a reachable command line or script: an explicit manifest or output directory, or a working-directory change.
|
|
590
|
+
const CARGO_REDIRECT = /--manifest-path|--target-dir|(^|[\s;&|(])(CARGO_TARGET_DIR|CARGO_BUILD_TARGET_DIR)=|(^|[\s;&|(])(cd|pushd)\s/;
|
|
591
|
+
|
|
592
|
+
// The forms a test's path to cargo may take for its target directory to be bound (cargoReachOf). Everything else is unprovable.
|
|
593
|
+
// Tools that run no repository code and start no cargo: any arguments.
|
|
594
|
+
const REACH_SAFE_TOOLS = new Set(["tsc", "eslint", "prettier", "biome", "oxlint", "echo", "true"]);
|
|
595
|
+
// cargo options that move its manifest, workspace or output, or change its configuration (a short-option cluster holding C or Z too).
|
|
596
|
+
const CARGO_REDIRECT_ARG = /^(--manifest-path|--target-dir|--config)(=|$)|^-[A-Za-z]*[CZ]/;
|
|
597
|
+
// The cargo subcommands a bound test may run; anything else (an alias, cargo make, xtask, install, a plugin) is unprovable.
|
|
598
|
+
const CARGO_REACH_SUBCOMMANDS = new Set(["test", "build", "check", "clippy"]);
|
|
599
|
+
// Package-manager built-ins: never one of the package's scripts, whatever its package.json names (npm pack runs pack, not a script).
|
|
600
|
+
const PM_BUILTINS = new Set(["install", "i", "add", "ci", "pack", "publish", "version", "link", "unlink", "rebuild", "prune", "update", "up", "upgrade", "uninstall", "remove", "rm", "init", "create", "exec", "dlx", "x", "workspace", "workspaces", "config", "set", "import", "patch", "deploy", "audit", "outdated", "why", "store", "cache", "env", "setup", "self-update", "fetch"]);
|
|
601
|
+
// Characters a shell gives meaning outside quotes beyond the separators shellCommands splits on: an escape, a glob, a home or
|
|
602
|
+
// redirection, a brace or history expansion, a comment, and cmd.exe's ^ and %VAR%. A script holding one unquoted is unprovable.
|
|
603
|
+
const UNQUOTED_SHELL_META = new Set(["\\", "*", "?", "[", "]", "~", "<", ">", "{", "}", "!", "#", "^", "%"]);
|
|
604
|
+
/** The first character a shell would read specially outside quotes in `text`, or null. */
|
|
605
|
+
function unquotedShellMeta(text) {
|
|
606
|
+
let quote = null;
|
|
607
|
+
for (const c of String(text)) {
|
|
608
|
+
if (quote) { if (c === quote) quote = null; continue; }
|
|
609
|
+
if (c === '"' || c === "'") { quote = c; continue; }
|
|
610
|
+
if (UNQUOTED_SHELL_META.has(c)) return c;
|
|
611
|
+
}
|
|
612
|
+
return null;
|
|
613
|
+
}
|
|
614
|
+
// Package-manager options the walk resolves; any other option is unprovable.
|
|
615
|
+
const PM_DIR_OPTIONS = new Set(["--dir", "-C", "--prefix", "--cwd"]);
|
|
616
|
+
const PM_PACKAGE_OPTIONS = new Set(["--filter", "-F", "--filter-prod", "--workspace"]);
|
|
617
|
+
const PM_PLAIN_FLAGS = new Set(["-s", "--silent", "--if-present", "--color", "--no-color", "--stream"]);
|
|
618
|
+
const PM_VALUE_FLAGS = new Set(["--reporter", "--loglevel"]);
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Where a package's `test` script makes cargo run, for a commit-snapshot compile (joint plan step 4c): `{ none: true }` when it reaches
|
|
622
|
+
* no cargo, `{ dir }` (repository-relative) when every cargo it reaches runs from that one directory, `{ dirs }` when from several (the
|
|
623
|
+
* caller requires them to be one workspace), or `{ unprovable }` with the reason. One CARGO_TARGET_DIR binds the whole test.
|
|
624
|
+
*
|
|
625
|
+
* Fail-closed allowlist (root reviews of 431fbeae, f883d5ca, 15300d82): a test is bound only when every command on every path it
|
|
626
|
+
* reaches (each script with its pre/post hooks) parses completely into one of these forms:
|
|
627
|
+
* - `cargo <args>` or `rustright cargo <args>` with no option that moves the manifest, workspace or output (--manifest-path,
|
|
628
|
+
* --target-dir, --config, -C, -Z): cargo runs from the script's package directory;
|
|
629
|
+
* - `pnpm|npm|yarn` with only resolvable options in any position before the script (--dir/-C/--prefix/--cwd, --filter/-F/
|
|
630
|
+
* --filter-prod/--workspace naming one package of this repository, pnpm -w, npm -w <workspace>, and output flags) running
|
|
631
|
+
* `run <script>`, `test`, `<script>` with no further arguments, or `exec <command>` (walked the same way in that package);
|
|
632
|
+
* - a tool that runs no repository code (tsc, eslint, prettier, biome, oxlint, echo, true).
|
|
633
|
+
* Anything else is unprovable: `cd`, an environment assignment, a shell expansion, node or any test runner, an unknown command or
|
|
634
|
+
* option, a recursive run, an unresolvable package, arguments passed on to a script, a script past the size limit, the depth limit,
|
|
635
|
+
* and a requiresBroker declaration (the declared path is not in the scripts). Over-refusal is the accepted cost.
|
|
636
|
+
*/
|
|
637
|
+
export function cargoReachOf({ unitDir, scripts, declared = null, readScripts = () => null, packageDirOf = () => null, maxDepth = 8 }) {
|
|
638
|
+
const dirs = new Set();
|
|
639
|
+
// Keyed on package and script alone: the walk refuses everything that could change how a script runs (cd, environment), so a
|
|
640
|
+
// script reached twice from one package runs the same way both times.
|
|
641
|
+
const seen = new Set();
|
|
642
|
+
const unprovable = (why) => { throw Object.assign(new Error(why), { reach: true }); };
|
|
643
|
+
const walkScript = (dirScripts, name, dir, via) => {
|
|
644
|
+
const key = `${dir}\0${name}`;
|
|
645
|
+
if (seen.has(key)) return;
|
|
646
|
+
if (via.length >= maxDepth) unprovable(`the path to ${dir}'s ${name} (${via.join(" -> ")}) is deeper than ${maxDepth} scripts`);
|
|
647
|
+
seen.add(key);
|
|
648
|
+
for (const part of [`pre${name}`, name, `post${name}`]) {
|
|
649
|
+
if (!Object.hasOwn(dirScripts, part)) continue;
|
|
650
|
+
const text = dirScripts[part];
|
|
651
|
+
if (typeof text !== "string") unprovable(`${dir}'s ${part} script is not text`);
|
|
652
|
+
if (text.length > SCRIPT_TEXT_LIMIT) unprovable(`${dir}'s ${part} script is longer than ${SCRIPT_TEXT_LIMIT} characters`);
|
|
653
|
+
const meta = unquotedShellMeta(text);
|
|
654
|
+
if (meta !== null) unprovable(`${dir}'s ${part} script has an unquoted ${JSON.stringify(meta)}, which a shell would expand or redirect`);
|
|
655
|
+
const step = [...via, dir === unitDir ? part : `${dir}:${part}`];
|
|
656
|
+
for (const words of shellCommands(text)) walkCommand(words, dirScripts, dir, step);
|
|
657
|
+
}
|
|
658
|
+
};
|
|
659
|
+
const walkCommand = (words, dirScripts, dir, via) => {
|
|
660
|
+
const where = `${via.join(" -> ")}`;
|
|
661
|
+
const shown = words.join(" ").slice(0, 120);
|
|
662
|
+
if (words.some((w) => /[`$]/.test(w))) unprovable(`${where} runs \`${shown}\`, whose shell expansion this check cannot follow`);
|
|
663
|
+
if (ENV_ASSIGNMENT.test(words[0])) unprovable(`${where} runs \`${shown}\` with its own environment`);
|
|
664
|
+
const tool = path.posix.basename(words[0].replaceAll("\\", "/")).replace(/\.(exe|cmd|bat)$/i, "");
|
|
665
|
+
if (tool === "cargo" || tool === "rustright") {
|
|
666
|
+
const args = tool === "rustright" ? (words[1] === "cargo" ? words.slice(2) : unprovable(`${where} runs \`${shown}\`, a rustright command other than cargo`)) : words.slice(1);
|
|
667
|
+
const end = args.indexOf("--");
|
|
668
|
+
const own = end === -1 ? args : args.slice(0, end);
|
|
669
|
+
if (own.some((w) => CARGO_REDIRECT_ARG.test(w))) unprovable(`the path to cargo (${where}) redirects it`);
|
|
670
|
+
const sub = own.find((w) => !w.startsWith("-") && !w.startsWith("+"));
|
|
671
|
+
if (!CARGO_REACH_SUBCOMMANDS.has(sub)) unprovable(`${where} runs \`${shown}\`, a cargo command (${sub ?? "none"}) other than ${[...CARGO_REACH_SUBCOMMANDS].join(", ")}`);
|
|
672
|
+
dirs.add(dir);
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
if (PACKAGE_MANAGERS.has(tool)) return walkPackageManager(tool, words.slice(1), dirScripts, dir, via, shown);
|
|
676
|
+
if (REACH_SAFE_TOOLS.has(tool)) return;
|
|
677
|
+
unprovable(`${where} runs \`${shown}\` (${tool}), which this check cannot follow to cargo`);
|
|
678
|
+
};
|
|
679
|
+
const walkPackageManager = (tool, args, dirScripts, dir, via, shown) => {
|
|
680
|
+
const where = `${via.join(" -> ")}`;
|
|
681
|
+
let target = dir;
|
|
682
|
+
let i = 0;
|
|
683
|
+
let retargets = 0;
|
|
684
|
+
// Options, in any position before the script or command: each one resolved, or the command is unprovable. At most one of them
|
|
685
|
+
// may name the package or directory (two would leave which one wins to the package manager).
|
|
686
|
+
const options = () => {
|
|
687
|
+
while (i < args.length && args[i].startsWith("-")) {
|
|
688
|
+
const [name, ...inline] = args[i].split("=");
|
|
689
|
+
const value = inline.length ? inline.join("=") : args[i + 1];
|
|
690
|
+
const width = inline.length ? 1 : 2;
|
|
691
|
+
if (PM_DIR_OPTIONS.has(name) || PM_PACKAGE_OPTIONS.has(name) || ((name === "-w" || name === "--workspace-root") && (tool === "pnpm" || tool === "npm"))) {
|
|
692
|
+
retargets += 1;
|
|
693
|
+
if (retargets > 1) unprovable(`${where}: \`${shown}\` names its package or directory more than once`);
|
|
694
|
+
}
|
|
695
|
+
if (PM_DIR_OPTIONS.has(name)) { if (value === undefined) unprovable(`${where}: \`${shown}\` names no directory`); target = path.posix.normalize(path.posix.join(dir, String(value))); i += width; }
|
|
696
|
+
else if (PM_PACKAGE_OPTIONS.has(name)) { const d = packageDirOf(String(value)); if (d === null || d === undefined) unprovable(`${where}: \`${shown}\` runs a package (${value}) this repository cannot resolve`); target = d; i += width; }
|
|
697
|
+
else if ((name === "-w" || name === "--workspace-root") && tool === "pnpm") { target = "."; i += 1; }
|
|
698
|
+
else if ((name === "-w") && tool === "npm") { const d = packageDirOf(String(value)); if (d === null || d === undefined) unprovable(`${where}: \`${shown}\` runs a workspace (${value}) this repository cannot resolve`); target = d; i += width; }
|
|
699
|
+
else if (PM_PLAIN_FLAGS.has(name) && !inline.length) i += 1;
|
|
700
|
+
else if (PM_VALUE_FLAGS.has(name)) i += width;
|
|
701
|
+
else unprovable(`${where}: \`${shown}\` has an option (${name}) this check does not resolve`);
|
|
702
|
+
}
|
|
703
|
+
};
|
|
704
|
+
options();
|
|
705
|
+
const sub = args[i++];
|
|
706
|
+
if (sub === undefined) unprovable(`${where}: \`${shown}\` runs no script`);
|
|
707
|
+
// Options after `run`/`exec` count as well: the target is known only once every option before the script has been read.
|
|
708
|
+
if (sub === "run" || sub === "run-script" || (sub === "exec" && tool === "pnpm")) options();
|
|
709
|
+
if (target.startsWith("..")) unprovable(`${where}: \`${shown}\` runs outside this repository (${target})`);
|
|
710
|
+
const targetScripts = target === dir ? dirScripts : readScripts(target);
|
|
711
|
+
if (targetScripts === null || targetScripts === undefined) unprovable(`${where}: \`${shown}\` runs scripts of ${target}, which has no readable package.json`);
|
|
712
|
+
const hop = target === dir ? via : [...via, `${tool} ${args.join(" ")}`.slice(0, 120)];
|
|
713
|
+
if (sub === "exec" && tool === "pnpm") {
|
|
714
|
+
if (i >= args.length) unprovable(`${where}: \`${shown}\` executes nothing`);
|
|
715
|
+
return walkCommand(args.slice(i), targetScripts, target, hop);
|
|
716
|
+
}
|
|
717
|
+
let script;
|
|
718
|
+
if (sub === "run" || sub === "run-script") { script = args[i++]; if (script === undefined) unprovable(`${where}: \`${shown}\` names no script`); }
|
|
719
|
+
else if (sub === "test" || sub === "t" || sub === "tst") script = "test";
|
|
720
|
+
else if (!PM_BUILTINS.has(sub) && Object.hasOwn(targetScripts, sub)) script = sub;
|
|
721
|
+
else unprovable(`${where}: \`${shown}\` is a ${tool} command (${sub}) this check cannot follow`);
|
|
722
|
+
if (i < args.length) unprovable(`${where}: \`${shown}\` passes arguments to ${target}'s ${script}`);
|
|
723
|
+
walkScript(targetScripts, script, target, hop);
|
|
724
|
+
};
|
|
725
|
+
if (declared?.call) return { unprovable: "it is declared to reach the broker in ways its scripts do not show (requiresBroker)" };
|
|
726
|
+
try {
|
|
727
|
+
walkScript(scripts ?? {}, "test", unitDir, []);
|
|
728
|
+
} catch (e) {
|
|
729
|
+
if (e.reach) return { unprovable: e.message };
|
|
730
|
+
throw e;
|
|
731
|
+
}
|
|
732
|
+
const list = [...dirs];
|
|
733
|
+
return list.length === 0 ? { none: true } : list.length === 1 ? { dir: list[0] } : { dirs: list };
|
|
734
|
+
}
|
|
735
|
+
|
|
413
736
|
// A test script can reach the broker in ways scriptBrokerCall cannot read from package.json alone: `node -e "...spawnSync('pnpm', ['run', n])..."`,
|
|
414
737
|
// `node ./scripts/verify-local.mjs` (a script that spawns `rustright cargo test`), `sh -c`, `pnpm --dir <elsewhere> run verify`, `pnpm --filter`. HeardRight's
|
|
415
738
|
// root `test` is a `node -e` over `verify`, which is `pnpm --dir tauri-app-next run verify`, which is `node ./scripts/verify-local.mjs`, which runs
|
|
@@ -434,12 +757,25 @@ export function declaredBrokerCall(unit, value) {
|
|
|
434
757
|
* managed `rustright cargo` and `pnpm` run in. A scan whose files did not change is not planned.
|
|
435
758
|
*/
|
|
436
759
|
export function planScans({
|
|
437
|
-
changed = [], contentRoot, liveRoot = contentRoot, exists = fs.existsSync, readFile = (f) => fs.readFileSync(f, "utf8"),
|
|
760
|
+
changed = [], contentRoot, liveRoot = contentRoot, trustedRoot = null, exists = fs.existsSync, readFile = (f) => fs.readFileSync(f, "utf8"),
|
|
438
761
|
platform = process.platform, includeTests = true, localCompile = true,
|
|
439
762
|
// Where the quiet test scans (ci/gate.config.json `quietTests`) run: "local" plans them for the quiet lane; "ci" leaves them to the
|
|
440
763
|
// CI shards (one crate per runner, where the port, pipe, desktop and wall-clock conflicts that force the serial lane do not exist)
|
|
441
764
|
// and records a note naming them (Adrian 2026-10-22: compile locally on the warm cache, qualify the conflicting tests on VMs).
|
|
442
765
|
quietTests = "local",
|
|
766
|
+
// Which scans this plan owes (Adrian, 2026-10-26; app-check.mjs TIERS): "compile" leaves out the scans added after @rightkit/release
|
|
767
|
+
// 0.6.6 (shellcheck, and actionlint's shellcheck requirement) until every app builds through the door again; "release" plans everything.
|
|
768
|
+
tier = "compile",
|
|
769
|
+
// A broker host (rustright): cargo scans run in this primary checkout instead of the content root, because the RustRight client refuses
|
|
770
|
+
// every cargo command but a locked metadata outside the app's primary checkout (RK_NOT_PRIMARY_CHECKOUT). The gate sets it only after
|
|
771
|
+
// proving the checkout's Cargo inputs are blob-identical to the snapshot's (cargoInputsDiffer); null keeps the content/live roots.
|
|
772
|
+
cargoLiveRoot = null,
|
|
773
|
+
// { [cargoDir]: absolute directory }: the checkout's resolved target directory per Cargo workspace, set as CARGO_TARGET_DIR on
|
|
774
|
+
// that workspace's cargo scans when the scans read a snapshot (the door's commit compile, joint plan step 4c).
|
|
775
|
+
targetDirs = null,
|
|
776
|
+
// True when the gate's runtime cargo wrapper (cargo-target-shim.mjs) is first on PATH for every scan: each cargo invocation binds its
|
|
777
|
+
// own workspace's checkout target directory or is refused at run time, so the static walk (cargoReachOf) is advisory, never a refusal.
|
|
778
|
+
cargoShim = false,
|
|
443
779
|
// The command prefix every cargo scan runs through: `rustright cargo` on a managed host, plain `cargo` in CI.
|
|
444
780
|
cargoCommand = ["rustright", "cargo"],
|
|
445
781
|
// Physical containment: a changed path whose real path leaves the real content root (a symlink) is refused.
|
|
@@ -452,13 +788,15 @@ export function planScans({
|
|
|
452
788
|
crateFeatures = {},
|
|
453
789
|
// ci/gate.config.json `npm`: per-package gate settings (`quietTests`), read by the package's unit name (packages/<dir> → rightkit-<dir>).
|
|
454
790
|
packageFeatures = {},
|
|
791
|
+
// True for full class A on the live checkout, which runs the repository's own code anyway (tests, typecheck, lint).
|
|
792
|
+
liveExecution = false,
|
|
455
793
|
// Crates a workspace-wide Rust change (Cargo.lock, vendor/) actually reaches, computed by the caller from
|
|
456
794
|
// the lock graph (scripts/ci/select.mjs). null keeps every crate in scope.
|
|
457
795
|
rustScope = null,
|
|
458
796
|
// Repository-specific units for any layout: [{ name, dir, publish? }], dir repo-relative ("." for a root unit). When given they
|
|
459
797
|
// replace the crates/<name> and packages/<name> layout (rightkit-gate). `cargoDir` is the Cargo workspace root the cargo scans run in.
|
|
460
798
|
// A rust unit may name its own workspace: { name, dir, cargoDir, crate, publish? } runs its scans with cwd cargoDir and `-p <crate>`;
|
|
461
|
-
// `name` is then the unit's identity in scan ids and feature config (rightkit-gate names it `<cargoDir>:<crate>` below the root), and
|
|
799
|
+
// `name` is then the unit's identity in scan ids and feature config (rightkit-gate names it `<cargoDir>:<crate>` below the root, and the plain crate for a `crates/<crate>` workspace of its own), and
|
|
462
800
|
// `crateFeatures` is read by `name` first, then by `crate`. Units of different workspaces never share a lock, a deny/audit run or a
|
|
463
801
|
// `cargo metadata`; a unit that sets no cargoDir belongs to the global `cargoDir` above.
|
|
464
802
|
rustUnits = null, npmUnits = null, cargoDir = ".",
|
|
@@ -476,6 +814,19 @@ export function planScans({
|
|
|
476
814
|
rustLocks = null,
|
|
477
815
|
// The pinned RustSec advisory database ({ url, commit }, advisory-db.json): cargo-deny and cargo-audit read exactly that commit and never fetch.
|
|
478
816
|
advisoryPin = null,
|
|
817
|
+
// Cross-workspace reach of a crate-per-workspace repository (rightkit#1285): `crateDependents(unitNames: Set) -> Set` answers the
|
|
818
|
+
// units that path-depend on any of `unitNames`, transitively, across Cargo workspaces (the gate builds it from packages/release/
|
|
819
|
+
// crate-workspaces.mjs; manifests only). Every such unit that is not itself in scope gets a `check-dependent` scan. null plans none beyond
|
|
820
|
+
// the per-workspace lock graph (rustLocks).
|
|
821
|
+
crateDependents = null,
|
|
822
|
+
// `{ "<repo-relative directory>": [unit names] }`: sources outside every unit that a unit compiles (vendor/citationberg is patched into
|
|
823
|
+
// rightkit-typeset). A changed file under the directory puts those units in scope. null maps none.
|
|
824
|
+
externalInputs = null,
|
|
825
|
+
// Clippy batching (item 8; clippyBatchPlan). false keeps the production path: one Clippy scan per crate and lint feature set. It is
|
|
826
|
+
// switched on only after the batch-equivalence run (clippy-equivalence.mjs, row 8.7) has passed on the hosts the map names; then the
|
|
827
|
+
// planner emits one scan per compatible batch and no per-crate Clippy scan for a crate that belongs to one (row 8.15).
|
|
828
|
+
// `shardSolo` is ci/shards.json `solo`; `clippySeparate` maps a unit name to the reason equivalence found batching changed it.
|
|
829
|
+
clippyBatching = false, shardSolo = [], clippySeparate = new Map(),
|
|
479
830
|
} = {}) {
|
|
480
831
|
if (!contentRoot) throw new TypeError("planScans: contentRoot is required");
|
|
481
832
|
if (!Array.isArray(cargoCommand) || cargoCommand.length === 0 || !["rustright", "cargo"].includes(cargoCommand[0]) || cargoCommand.slice(1).some((a) => a !== "cargo")) {
|
|
@@ -503,9 +854,14 @@ export function planScans({
|
|
|
503
854
|
const bytesDigest = (f) => { try { return sha256(readBytes(path.join(contentRoot, f))); } catch { return "unreadable"; } };
|
|
504
855
|
const inputDigest = sha256(canonicalJson({ files: [...files].sort().map((f) => [f, bytesDigest(f)]), refused: refusals.map((r) => r.raw).sort() }));
|
|
505
856
|
const under = (prefix) => files.filter((f) => f.startsWith(prefix));
|
|
857
|
+
if (!["compile", "release"].includes(tier)) throw new Error(`planScans: tier must be compile or release, not ${JSON.stringify(tier)}`);
|
|
506
858
|
let scans = []; const notes = [];
|
|
859
|
+
if (cargoLiveRoot) notes.push(`broker host: cargo scans run in the primary checkout ${cargoLiveRoot}, whose Cargo inputs match the checked snapshot (rustright refuses cargo outside the primary checkout)`);
|
|
860
|
+
// 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
|
|
861
|
+
// live-checkout run (or a compile with another map) is taken for one that writes to these directories.
|
|
862
|
+
const snapshotTargets = targetDirs ? { RK_SNAPSHOT_TARGETS: canonicalJson(targetDirs) } : {};
|
|
507
863
|
const add = (d) => {
|
|
508
|
-
const entry = { root: "live",
|
|
864
|
+
const entry = { root: "live", args: [], ...d, env: { ...snapshotTargets, ...(d.env ?? {}) } };
|
|
509
865
|
entry.command = commandText(entry);
|
|
510
866
|
scans.push(entry);
|
|
511
867
|
};
|
|
@@ -524,7 +880,7 @@ export function planScans({
|
|
|
524
880
|
const plainDir = (d) => !ABSOLUTE_PATH.test(d) && !d.split("/").includes("..") && SAFE_UNIT_DIR.test(d);
|
|
525
881
|
const cargoWs = cleanDir(cargoDir);
|
|
526
882
|
if (!plainDir(cargoWs)) throw new TypeError("planScans: cargoDir must be a plain repository-relative directory");
|
|
527
|
-
const cargoIn = (ws, ...args) => ({ tool: cargoCommand[0], args: [...cargoCommand.slice(1), ...args], ...(ws !== "." ? { cwd: ws } : {}) });
|
|
883
|
+
const cargoIn = (ws, ...args) => ({ tool: cargoCommand[0], args: [...cargoCommand.slice(1), ...everyFailure(args)], ...(ws !== "." ? { cwd: ws } : {}), ...(cargoLiveRoot ? { root: "cargoLive" } : {}), ...(targetDirs?.[ws] ? { env: { CARGO_TARGET_DIR: targetDirs[ws] } } : {}) });
|
|
528
884
|
const unitMap = (units, kind) => {
|
|
529
885
|
if (units === null) return null;
|
|
530
886
|
if (!Array.isArray(units)) throw new TypeError(`planScans: ${kind}Units must be an array of { name, dir }`);
|
|
@@ -579,7 +935,13 @@ export function planScans({
|
|
|
579
935
|
};
|
|
580
936
|
// Units of an explicit-workspace layout (some unit names its cargoDir) get workspace-suffixed ids for the per-workspace scans.
|
|
581
937
|
const perWorkspaceIds = Array.isArray(rustUnits) && rustUnits.some((u) => u?.cargoDir !== undefined);
|
|
582
|
-
|
|
938
|
+
// The label a per-workspace scan id carries: the workspace directory, except for a workspace that is exactly one crate-named unit
|
|
939
|
+
// (`crates/rightkit-fs` holding rightkit-fs, rightkit#1285 D2), which is labelled by the crate (`cargo-deny:rightkit-fs`).
|
|
940
|
+
const wsLabel = (ws) => {
|
|
941
|
+
if (rustMap) for (const [name, u] of rustMap) if (u.cargoDir === ws && name === u.crate && ws === `crates/${u.crate}`) return name;
|
|
942
|
+
return ws;
|
|
943
|
+
};
|
|
944
|
+
const wsIdSuffix = (ws) => (perWorkspaceIds && ws !== "." ? `:${wsLabel(ws)}` : "");
|
|
583
945
|
const wsOf = (n) => (rustMap ? rustMap.get(n).cargoDir : cargoWs);
|
|
584
946
|
const crateOf = (n) => (rustMap?.get(n)?.crate ?? n);
|
|
585
947
|
const featuresOf = (n) => crateFeatures?.[n] ?? crateFeatures?.[crateOf(n)] ?? {};
|
|
@@ -613,13 +975,32 @@ export function planScans({
|
|
|
613
975
|
const verbatimUpstream = (f) => f.startsWith("vendor/") && typeof upstream[f] === "string" && bytesDigest(f) === upstream[f];
|
|
614
976
|
const recordedPatch = (f) => f.startsWith("vendor/") && DIGEST.test(patched[f]?.base ?? "") && typeof patched[f]?.note === "string"
|
|
615
977
|
&& patched[f].note.startsWith("vendor/") && DIGEST.test(patched[f]?.sha256 ?? "") && bytesDigest(f) === patched[f].sha256;
|
|
978
|
+
// Declared fixture files (Adrian, 2026-10-30: "there should be a .rkignore list that somehow allows for those to be passed";
|
|
979
|
+
// rightkit#1291): `.rightkit/fixture-files.json` { schema: 1, files: { "<path>": { sha256, note } } } lists files that are
|
|
980
|
+
// deliberately malformed (an audit's known-answer inputs). A listed file whose bytes still match its sha256 skips the per-file
|
|
981
|
+
// format and parse scans only (rustfmt, node --check, json-parse, toml-parse); every other scan still reads it. Any edit changes
|
|
982
|
+
// the digest, so the file is checked again until re-recorded. A malformed manifest, a bad digest or an empty note refuses.
|
|
983
|
+
let fixtureFiles = {};
|
|
984
|
+
if (exists(path.join(contentRoot, ".rightkit/fixture-files.json"))) {
|
|
985
|
+
try {
|
|
986
|
+
const manifest = JSON.parse(readFile(path.join(contentRoot, ".rightkit/fixture-files.json")));
|
|
987
|
+
if (manifest?.schema !== 1 || !manifest.files || typeof manifest.files !== "object" || Array.isArray(manifest.files)) throw new Error("schema 1 with a files object is required");
|
|
988
|
+
for (const [f, row] of Object.entries(manifest.files)) {
|
|
989
|
+
if (!/^[0-9a-f]{64}$/.test(row?.sha256 ?? "")) throw new Error(`${f}: sha256 must be 64 lowercase hex characters`);
|
|
990
|
+
if (typeof row?.note !== "string" || !row.note.trim()) throw new Error(`${f}: a note saying why the file is malformed is required`);
|
|
991
|
+
if (f.startsWith("/") || f.split("/").includes("..")) throw new Error(`${f}: paths are repository-relative`);
|
|
992
|
+
}
|
|
993
|
+
fixtureFiles = manifest.files;
|
|
994
|
+
} catch (error) { refuse(".rightkit/fixture-files.json", error.message); }
|
|
995
|
+
}
|
|
996
|
+
const declaredFixture = (f) => Object.hasOwn(fixtureFiles, f) && bytesDigest(f) === fixtureFiles[f].sha256;
|
|
616
997
|
// Generated Rust is not hand-formatted: bindgen output, or rustfmt's own `@generated` marker in the first five lines.
|
|
617
998
|
const generatedRust = (f) => {
|
|
618
999
|
let head = "";
|
|
619
1000
|
try { head = readFile(path.join(contentRoot, f)).split("\n", 5).join("\n"); } catch { return false; }
|
|
620
1001
|
return /@generated\b|automatically generated by rust-bindgen\b/.test(head);
|
|
621
1002
|
};
|
|
622
|
-
const rustFiles = files.filter((f) => f.endsWith(".rs") && !verbatimUpstream(f) && !recordedPatch(f) && !generatedRust(f));
|
|
1003
|
+
const rustFiles = files.filter((f) => f.endsWith(".rs") && !verbatimUpstream(f) && !recordedPatch(f) && !generatedRust(f) && !declaredFixture(f));
|
|
623
1004
|
const byEdition = new Map();
|
|
624
1005
|
const editionOf = rustEditionResolver({ contentRoot, exists, readFile });
|
|
625
1006
|
const refusedEditions = new Set();
|
|
@@ -632,15 +1013,21 @@ export function planScans({
|
|
|
632
1013
|
}
|
|
633
1014
|
byEdition.set(found.edition, [...(byEdition.get(found.edition) ?? []), f]);
|
|
634
1015
|
}
|
|
1016
|
+
// One rustfmt scan per edition, batched: Windows refuses a command line past ~32 KB (spawn ENAMETOOLONG on a large repository,
|
|
1017
|
+
// HeardRight on the Dell, 2026-10-27), so a batch holds at most RUSTFMT_BATCH files and the first batch keeps the plain id.
|
|
635
1018
|
for (const [edition, group] of [...byEdition].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
|
|
636
|
-
|
|
1019
|
+
const sorted = group.sort();
|
|
1020
|
+
for (let i = 0; i < sorted.length; i += RUSTFMT_BATCH) {
|
|
1021
|
+
const batch = sorted.slice(i, i + RUSTFMT_BATCH);
|
|
1022
|
+
add({ id: `rustfmt:${edition}${i ? `:${i / RUSTFMT_BATCH + 1}` : ""}`, class: A, tool: "rustfmt", args: ["--check", "--edition", edition, ...batch], root: "content" });
|
|
1023
|
+
}
|
|
637
1024
|
}
|
|
638
|
-
for (const f of files.filter((f) => /\.(mjs|cjs|js)$/.test(f) && !f.includes("node_modules")).sort()) {
|
|
1025
|
+
for (const f of files.filter((f) => /\.(mjs|cjs|js)$/.test(f) && !f.includes("node_modules") && !declaredFixture(f)).sort()) {
|
|
639
1026
|
add({ id: `node-check:${f}`, class: A, tool: "node", args: ["--check", f], root: "content" });
|
|
640
1027
|
}
|
|
641
|
-
const jsonFiles = files.filter((f) => f.endsWith(".json") && !f.includes("node_modules")).sort();
|
|
1028
|
+
const jsonFiles = files.filter((f) => f.endsWith(".json") && !f.includes("node_modules") && !declaredFixture(f)).sort();
|
|
642
1029
|
if (jsonFiles.length) add({ id: "json-parse", class: A, internal: "json-parse", files: jsonFiles });
|
|
643
|
-
const tomlFiles = files.filter((f) => f.endsWith(".toml") && !f.includes("node_modules")).sort();
|
|
1030
|
+
const tomlFiles = files.filter((f) => f.endsWith(".toml") && !f.includes("node_modules") && !declaredFixture(f)).sort();
|
|
644
1031
|
if (tomlFiles.length) add({ id: "toml-parse", class: A, internal: "toml-parse", files: tomlFiles });
|
|
645
1032
|
// The strict workflow YAML parser covers the subset GitHub workflows and actions use; other YAML is not parsed with it.
|
|
646
1033
|
const yamlFiles = files.filter((f) => /^\.github\/.+\.ya?ml$/.test(f)).sort();
|
|
@@ -658,7 +1045,12 @@ export function planScans({
|
|
|
658
1045
|
const wideJsRoots = new Set(pnpmRoots.filter((r) => files.some((f) => { const rel = wsRel(r, f); return rel !== null && WORKSPACE_JS_FILE.test(rel); })));
|
|
659
1046
|
const wideCrates = wideWs.size ? (rustMap ? [...rustMap.keys()].filter((n) => wideWs.has(wsOf(n))) : listDir(path.join(contentRoot, "crates"))).filter((n) => !rustScope || rustScope.has(n)) : [];
|
|
660
1047
|
if (wideWs.size && rustScope) notes.push(`workspace Rust change reaches ${wideCrates.length} crate(s) through the lock graph`);
|
|
661
|
-
|
|
1048
|
+
// A source outside every unit that a unit compiles (externalInputs), and a shared input, reach their units; nothing else outside a
|
|
1049
|
+
// unit's directory does (a crate-per-workspace repository has no root manifest or lock to widen the scope).
|
|
1050
|
+
const externalUnits = rustMap && externalInputs ? Object.entries(externalInputs).flatMap(([dir, names]) => (files.some((f) => f === dir || f.startsWith(`${dir.replace(/\/+$/, "")}/`)) ? names : [])).filter((n) => rustMap.has(n)) : [];
|
|
1051
|
+
const sharedUnits = rustMap && files.some((f) => SHARED_RUST_SCOPE_FILE.test(f)) ? [...rustMap.keys()] : [];
|
|
1052
|
+
if (sharedUnits.length) notes.push(`a shared build input changed (${files.filter((f) => SHARED_RUST_SCOPE_FILE.test(f)).join(", ")}); every Rust unit is in scope`);
|
|
1053
|
+
const crateNames = safeUnits([...new Set([...(rustMap ? files.map((f) => unitOf(rustMap, f)).filter(Boolean) : under("crates/").map((f) => f.split("/")[1])), ...wideCrates, ...externalUnits, ...sharedUnits])].filter((n) => exists(path.join(contentRoot, rustDir(n), "Cargo.toml"))).sort(), "crate");
|
|
662
1054
|
// The workspace a changed file belongs to is the deepest one that holds it (a root workspace holds only what no nested one does).
|
|
663
1055
|
const wsOfFile = (f) => { let best = null; for (const ws of workspaces) if ((ws === "." || f.startsWith(`${ws}/`)) && (best === null || ws.length > best.length)) best = ws; return best; };
|
|
664
1056
|
const configWs = new Set();
|
|
@@ -687,13 +1079,14 @@ export function planScans({
|
|
|
687
1079
|
const actionlintConfig = exists(path.join(contentRoot, ".github/actionlint.yaml")) ? ["-config-file", ".github/actionlint.yaml"] : [];
|
|
688
1080
|
// actionlint lints every run: script with shellcheck only when shellcheck is found, and otherwise passes silently; class A
|
|
689
1081
|
// requires it (`requires`), so a host without it fails the scan instead of skipping the shell checks.
|
|
690
|
-
if (workflowFiles.length) add({ id: "actionlint", class: A, tool: "actionlint", requires: ["shellcheck"], args: [...actionlintConfig, ...workflowFiles], root: "content" });
|
|
1082
|
+
if (workflowFiles.length) add({ id: "actionlint", class: A, tool: "actionlint", requires: tier === "release" ? ["shellcheck"] : [], args: [...actionlintConfig, ...workflowFiles], root: "content", runScriptLimit: { files: workflowFiles, bytes: RUN_SCRIPT_LIMIT_BYTES } });
|
|
691
1083
|
// Standalone shell scripts (SellRight's deploy scripts failed SC2154, SC2030/2031 and SC2016 in CI): every tracked .sh/.bash file,
|
|
692
1084
|
// with ShellCheck's own defaults and the repository's .shellcheckrc (data, not code). actionlint covers only workflow run: blocks.
|
|
693
1085
|
// A script whose shebang names another shell (zsh, fish) is not ShellCheck's to judge, so it is left out, never failed.
|
|
694
1086
|
const otherShell = (f) => { try { return /^#!.*\b(zsh|fish|pwsh|powershell)\b/.test(String(readFile(path.join(contentRoot, f))).split("\n", 1)[0]); } catch { return false; } };
|
|
695
1087
|
const shellFiles = files.filter((f) => /\.(sh|bash)$/.test(f) && !/(^|\/)node_modules\//.test(f) && !otherShell(f)).sort();
|
|
696
|
-
if (shellFiles.length)
|
|
1088
|
+
if (shellFiles.length && tier !== "release") notes.push("compile tier: shellcheck (added after 0.6.6) is not planned; it runs in the release tier (--to qualify|release)");
|
|
1089
|
+
if (shellFiles.length && tier === "release") add({ id: "shellcheck", class: A, tool: "shellcheck", tier: "release", args: ["--format=gcc", "--external-sources", ...shellFiles], root: "content" });
|
|
697
1090
|
// No right-git drift scan: the one workflow right-git renders here, ci.yml, is hand-owned (its header;
|
|
698
1091
|
// docs/CI.md) and held by scripts/ci/ci-scans-contract.mjs instead, so drift is always reported by design.
|
|
699
1092
|
|
|
@@ -706,22 +1099,36 @@ export function planScans({
|
|
|
706
1099
|
for (const ws of workspaces) {
|
|
707
1100
|
if (typeof ws !== "string") continue;
|
|
708
1101
|
const dir = ws === "." ? "" : ws;
|
|
709
|
-
|
|
710
|
-
|
|
1102
|
+
// A non-empty list only: `[^\]\s]` is one character that is neither whitespace nor the closing bracket, so `ignore = []` followed by
|
|
1103
|
+
// a later `]` (the next table header) is not an ignore (Pulse, 2026-10-27: `\S` matched the `]` itself).
|
|
1104
|
+
const ignores = ["deny.toml", ".cargo/audit.toml"].some((f) => { try { return /\bignore\s*=\s*\[[^\]]*[^\]\s][^\]]*\]/s.test(readFile(path.join(contentRoot, dir, f))); } catch { return false; } });
|
|
1105
|
+
if (ignores && !(dir === "" && exists(path.join(contentRoot, "ci/advisory-exceptions.json")))) add({ id: `advisory-exceptions${dir ? `:${wsLabel(ws)}` : ""}`, class: A, internal: "advisory-exceptions", dir, exceptionsFile: ".rightkit/advisory-exceptions.json" });
|
|
711
1106
|
}
|
|
712
1107
|
// Like generated-docs, planned only when a changed file is one of the contract's inputs or projections. The generator runs from
|
|
713
1108
|
// the trusted scanner checkout (the import below); the exported contract and projections are data it checks.
|
|
714
|
-
if (files.some((f) => SUITE_API_INPUT.test(f)) && exists(path.join(contentRoot, "contracts/right-suite-api.v1.json")))
|
|
1109
|
+
if (files.some((f) => SUITE_API_INPUT.test(f)) && exists(path.join(contentRoot, "contracts/right-suite-api.v1.json"))) {
|
|
1110
|
+
// RightKit's own generator contract (scripts/gen-suite-api.mjs) is the checked repository's code: like scripts/preflight.mjs it
|
|
1111
|
+
// runs only when this gate is that repository's in-tree copy (and in CI), never from a pinned or installed copy checking the
|
|
1112
|
+
// repository (the broker's trusted gate refused RightKit's own compile on a module it could not find, 2026-10-27).
|
|
1113
|
+
// The trusted home is the gate's own repository (`trustedRoot`, the checkout rightkit-gate runs for: a commit check exports the
|
|
1114
|
+
// commit to a snapshot and hands that as contentRoot and liveRoot); the generator is code and runs only from that checkout's
|
|
1115
|
+
// in-tree gate, reading the snapshot as data. Without a trusted root (library callers), the live checkout or content root decides.
|
|
1116
|
+
const home = trustedRoot ?? (path.resolve(liveRoot) !== path.resolve(contentRoot) ? liveRoot : contentRoot);
|
|
1117
|
+
if (inTreeGate(home, exists)) add({ id: "suite-api", class: A, internal: "suite-api" });
|
|
1118
|
+
else notes.push("suite-api: RightKit's own generator contract runs from the in-tree gate and CI, not from this installed copy");
|
|
1119
|
+
}
|
|
715
1120
|
if (rustTouched) {
|
|
716
1121
|
for (const ws of touchedWs) add({ id: `cargo-metadata${wsIdSuffix(ws)}`, class: A, ...cargoIn(ws, ...metadataArgs()) });
|
|
717
|
-
|
|
1122
|
+
// Locked package listings are observations in RustRight (#1216). They run through the selected executor
|
|
1123
|
+
// from the checked Cargo inputs, just like metadata; an executor refusal remains a failed scan.
|
|
1124
|
+
if (tier === "release") for (const name of crateNames.filter((n) => rustMap?.get(n).publish !== false)) add({ id: rustScanId.packageList(name), class: A, tier: "release", ...cargoIn(wsOf(name), "package", "-p", crateOf(name), "--list", "--allow-dirty", "--locked") });
|
|
718
1125
|
}
|
|
719
1126
|
for (const ws of [...configWs].sort()) {
|
|
720
1127
|
// Offline against the pinned database. The pin commit is part of the command (env), so a pin change is a different scan, and the
|
|
721
1128
|
// placeholders are filled at run time from the materialised checkout (runScans).
|
|
722
1129
|
const pin = advisoryPin ?? (advisoryPin = readAdvisoryPin());
|
|
723
1130
|
const pinEnv = { RK_ADVISORY_DB_COMMIT: pin.commit };
|
|
724
|
-
add({ id: `cargo-deny${wsIdSuffix(ws)}`, class: A, ...cargoIn(ws, "deny", "--
|
|
1131
|
+
add({ id: `cargo-deny${wsIdSuffix(ws)}`, class: A, ...cargoIn(ws, "deny", "--frozen", "--metadata-path", "{advisoryMetadata}", "--config", "{advisoryDenyConfig}", "check"), env: pinEnv, advisoryDb: "deny", advisoryPin: pin });
|
|
725
1132
|
add({ id: `cargo-audit${wsIdSuffix(ws)}`, class: A, ...cargoIn(ws, "audit", "--db", "{advisoryDb}", "--no-fetch"), env: pinEnv, advisoryDb: "audit", advisoryPin: pin });
|
|
726
1133
|
}
|
|
727
1134
|
// The audit policy is the consumer's own: every package.json script that is exactly `pnpm audit <flags>` (only --prod, --dev,
|
|
@@ -770,12 +1177,15 @@ export function planScans({
|
|
|
770
1177
|
// requiring a trusted-root run: deferred (never a pass), not run from submitted code.
|
|
771
1178
|
// Planned only when a changed file is one of the generator's inputs (GENERATOR_INPUT).
|
|
772
1179
|
if (exists(path.join(contentRoot, "scripts/generate-index.mjs")) && files.some((f) => GENERATOR_INPUT.test(f))) {
|
|
773
|
-
|
|
1180
|
+
// Full class A on the live checkout (`liveExecution`) already executes the repository's own code (its tests): there the
|
|
1181
|
+
// generator runs from that same checkout. Everywhere else the separation above holds.
|
|
1182
|
+
const trusted = (liveExecution || path.resolve(liveRoot) !== path.resolve(contentRoot)) && exists(path.join(liveRoot, "scripts/generate-index.mjs"));
|
|
774
1183
|
add({
|
|
775
|
-
id: "generated-docs", class:
|
|
1184
|
+
id: "generated-docs", class: A, executes: true, tool: "node", args: ["scripts/generate-index.mjs", "--check", "--root", "{contentRoot}"],
|
|
776
1185
|
...(trusted ? {} : { deferred: "requires a trusted-root run: the generator is repository code and no separate trusted checkout provides it; the content root's copy is never executed" }),
|
|
777
1186
|
});
|
|
778
1187
|
}
|
|
1188
|
+
const clippyEntries = [];
|
|
779
1189
|
for (const name of crateNames) {
|
|
780
1190
|
// Feature sets exactly as CI's gate runs them (scripts/ci/gate.mjs, ci/gate.config.json rust.<crate>):
|
|
781
1191
|
// clippy per `featureSets` (default features when absent); check per `checkFeatureSets` (default
|
|
@@ -788,16 +1198,25 @@ export function planScans({
|
|
|
788
1198
|
// check runs only when no clippy set covers default features.
|
|
789
1199
|
if (defaultCheck) add({ id: rustScanId.check(name), class: B, ...cargoIn(ws, ...checkArgs(crate, { locked: true })) });
|
|
790
1200
|
checkSets.forEach((set, i) => add({ id: rustScanId.checkFeatures(name, i), class: B, ...cargoIn(ws, ...checkArgs(crate, { features: set, locked: true })) }));
|
|
791
|
-
lintSets.forEach((set
|
|
1201
|
+
if (clippyBatching) lintSets.forEach((set) => clippyEntries.push({ name, crate, ws, set, count: lintSets.length, env: targetDirs?.[ws] ?? null }));
|
|
1202
|
+
else lintSets.forEach((set, i) => add({ id: rustScanId.clippy(name, lintSets.length, i), class: B, ...cargoIn(ws, ...clippyArgs(crate, { features: set, locked: true })) }));
|
|
792
1203
|
// rustdoc flags go after `--` for this crate's library only: the broker owns RUSTDOCFLAGS and refuses
|
|
793
1204
|
// a request that sets it. A crate with no library has no API docs to check.
|
|
794
1205
|
if (exists(path.join(contentRoot, rustDir(name), "src", "lib.rs"))) {
|
|
795
1206
|
add({ id: rustScanId.doc(name), class: B, ...cargoIn(ws, "rustdoc", "-p", crate, "--lib", ...lintSets[0], "--locked", "--", ...DOC_RUSTDOC_ARGS) });
|
|
796
1207
|
}
|
|
797
1208
|
}
|
|
1209
|
+
if (clippyBatching && clippyEntries.length) {
|
|
1210
|
+
// One Clippy scan per batch (row 8.6): the independent batches first, the sidecar-bound ones after them (row 8.8; the compile lane
|
|
1211
|
+
// drains in plan order). The reason a configuration stays separate is recorded in the plan notes.
|
|
1212
|
+
const sidecarBound = new Set(clippyEntries.filter((e) => sidecarBoundCrate({ dir: path.join(contentRoot, rustDir(e.name)), exists, readFile }).bound).map((e) => e.name));
|
|
1213
|
+
const plan = clippyBatchPlan(clippyEntries, { solo: shardSolo, separate: clippySeparate, sidecarBound, toolchain: cargoCommand.join(" ") });
|
|
1214
|
+
for (const b of plan.batches) add({ id: b.id, class: B, batch: { units: b.units, order: b.order, reason: b.reason }, ...cargoIn(b.ws, ...clippyArgs(b.crates, { features: b.set, locked: true })) });
|
|
1215
|
+
notes.push(...plan.notes);
|
|
1216
|
+
}
|
|
798
1217
|
// Reverse dependents: a workspace crate that is not in scope but whose lock closure reaches one that is must still compile.
|
|
799
1218
|
// A unit only depends on units of its own Cargo workspace (its own lock), so each workspace is judged by its own lock.
|
|
800
|
-
const inScope = new Set(crateNames);
|
|
1219
|
+
const inScope = new Set(crateNames); const planned = new Set();
|
|
801
1220
|
for (const ws of [...new Set(crateNames.map(wsOf))].sort()) {
|
|
802
1221
|
const lock = lockOf(ws);
|
|
803
1222
|
if (!lock) continue;
|
|
@@ -805,19 +1224,53 @@ export function planScans({
|
|
|
805
1224
|
const scoped = crateNames.filter((n) => wsOf(n) === ws).map(crateOf);
|
|
806
1225
|
const workspace = (rustMap ? [...rustMap.keys()] : listDir(path.join(contentRoot, "crates"))).filter((n) => wsOf(n) === ws);
|
|
807
1226
|
const dependents = safeUnits(workspace.filter((n) => !inScope.has(n) && exists(path.join(contentRoot, rustDir(n), "Cargo.toml")) && scoped.some((t) => reach(lock, crateOf(n), memo).has(t))).sort(), "crate");
|
|
808
|
-
for (const name of dependents) add({ id: `check-dependent:${name}`, class: B, ...cargoIn(ws, ...checkArgs(crateOf(name), { locked: true })) });
|
|
1227
|
+
for (const name of dependents) { planned.add(name); add({ id: `check-dependent:${name}`, class: B, ...cargoIn(ws, ...checkArgs(crateOf(name), { locked: true })) }); }
|
|
1228
|
+
}
|
|
1229
|
+
// Across Cargo workspaces (one workspace and lock per crate): a unit that path-depends on a unit in scope, transitively, is judged by
|
|
1230
|
+
// the manifests' path dependencies, since no lock holds both.
|
|
1231
|
+
if (typeof crateDependents === "function" && rustMap && crateNames.length) {
|
|
1232
|
+
const across = [...crateDependents(new Set(crateNames))].filter((n) => !inScope.has(n) && !planned.has(n) && rustMap.has(n) && exists(path.join(contentRoot, rustDir(n), "Cargo.toml"))).sort();
|
|
1233
|
+
for (const name of safeUnits(across, "crate")) add({ id: `check-dependent:${name}`, class: B, ...cargoIn(wsOf(name), ...checkArgs(crateOf(name), { locked: true })) });
|
|
809
1234
|
}
|
|
810
1235
|
// A linter config in the unit's directory or any ancestor of it (a repository-level config covers every unit, as the tools look it up).
|
|
811
1236
|
const hasConfig = (dir, names) => ancestorDirsOf(dir).some((d) => names.some((f) => exists(path.join(liveRoot, inDir(d, f)))));
|
|
1237
|
+
// tsc reads a workspace sibling's types where its package.json exports point: built output (dist/*.d.ts) that no commit holds, so a
|
|
1238
|
+
// check of the committed tree (CI, a commit snapshot) cannot find them, and a checkout with an old build checks against stale types.
|
|
1239
|
+
// A package that depends on another workspace package is therefore checked through a generated project that maps each sibling's
|
|
1240
|
+
// exported entry points to its committed sources (TSC_PROJECT, built by the runner); every other package keeps its own tsconfig.
|
|
1241
|
+
// `--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.
|
|
1242
|
+
const workspaceDirs = (npmMap ? [...npmMap.values()].map((u) => u.dir) : listDir(path.join(liveRoot, "packages")).map((n) => `packages/${n}`)).sort();
|
|
1243
|
+
const manifestOf = (dir) => { try { return JSON.parse(readFile(path.join(liveRoot, dir, "package.json")).replace(/^/, "")); } catch { return null; } };
|
|
1244
|
+
const workspaceNames = new Map(workspaceDirs.map((d) => [manifestOf(d)?.name, d]).filter(([n]) => typeof n === "string"));
|
|
1245
|
+
const usesWorkspaceSibling = (dir) => {
|
|
1246
|
+
const m = manifestOf(dir);
|
|
1247
|
+
return ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"].some((k) => Object.keys(m?.[k] ?? {}).some((n) => n !== m.name && workspaceNames.has(n)));
|
|
1248
|
+
};
|
|
812
1249
|
for (const name of pkgNames) {
|
|
813
1250
|
const dir = npmDir(name);
|
|
814
|
-
if (exists(path.join(liveRoot, dir, "tsconfig.json")))
|
|
815
|
-
|
|
816
|
-
|
|
1251
|
+
if (exists(path.join(liveRoot, dir, "tsconfig.json"))) {
|
|
1252
|
+
add(usesWorkspaceSibling(dir)
|
|
1253
|
+
? { id: `tsc:${name}`, class: A, executes: true, tool: "pnpm", args: ["exec", "tsc", "--noEmit", "-p", TSC_PROJECT], tscWorkspace: [...workspaceNames.values()].sort(), env: PNPM_RUN_ENV, cwd: dir }
|
|
1254
|
+
: { id: `tsc:${name}`, class: A, executes: true, tool: "pnpm", args: ["exec", "tsc", "--noEmit", "-p", "tsconfig.json"], env: PNPM_RUN_ENV, cwd: dir });
|
|
1255
|
+
}
|
|
1256
|
+
if (hasConfig(dir, ESLINT_CONFIG_FILES)) add({ id: `eslint:${name}`, class: A, executes: true, tool: "pnpm", args: ["exec", "eslint", "."], env: PNPM_RUN_ENV, cwd: dir });
|
|
1257
|
+
if (hasConfig(dir, BIOME_CONFIG_FILES)) add({ id: `biome:${name}`, class: A, executes: true, tool: "pnpm", args: ["exec", "biome", "lint", "."], env: PNPM_RUN_ENV, cwd: dir });
|
|
817
1258
|
}
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
1259
|
+
// One `swift build` per Swift package a changed file belongs to: the nearest Package.swift at or above the file, built in its own
|
|
1260
|
+
// directory (rightkit#1229: Pulse's package is swift/RightKitHub, and a root `swift build` found no package and blocked every push).
|
|
1261
|
+
const swiftFiles = files.filter((f) => f.startsWith("swift/") || f === "Package.swift" || f.endsWith("/Package.swift"));
|
|
1262
|
+
if (swiftFiles.length) {
|
|
1263
|
+
const packages = new Set();
|
|
1264
|
+
for (const f of swiftFiles) {
|
|
1265
|
+
const owner = ancestorDirsOf(path.posix.dirname(f)).reverse().find((d) => exists(path.join(contentRoot, inDir(d, "Package.swift"))));
|
|
1266
|
+
if (owner !== undefined) packages.add(owner);
|
|
1267
|
+
else notes.push(`${f}: no Package.swift at or above it, so no Swift package builds it`);
|
|
1268
|
+
}
|
|
1269
|
+
for (const dir of [...packages].sort()) {
|
|
1270
|
+
const id = dir === "" || dir === "." ? "swift-build" : `swift-build:${dir}`;
|
|
1271
|
+
if (platform === "darwin") add({ id, class: B, tool: "swift", args: ["build"], ...(dir === "" || dir === "." ? {} : { cwd: dir }) });
|
|
1272
|
+
else notes.push(`swift files changed in ${dir === "" || dir === "." ? "the root package" : dir}; \`swift build\` is required on the Mac host before its push`);
|
|
1273
|
+
}
|
|
821
1274
|
}
|
|
822
1275
|
if (includeTests) {
|
|
823
1276
|
const deferredToCi = [];
|
|
@@ -851,8 +1304,64 @@ export function planScans({
|
|
|
851
1304
|
// A declaration (`npm.<unit>.requiresBroker`, declaredBrokerCall) covers what the script text does not show; a call the scripts do show wins.
|
|
852
1305
|
const declared = declaredBrokerCall(name, own.requiresBroker);
|
|
853
1306
|
if (declared.problem) notes.push(declared.problem);
|
|
1307
|
+
// `npm.<unit>.afterClassA.reason` (ci/gate.config.json): the unit's tests launch stand-ins named like compilers (stub scripts,
|
|
1308
|
+
// which can run anything), so they cannot run under the class A launch guard; they run after class A, with the compile scans.
|
|
1309
|
+
// A reason is required.
|
|
1310
|
+
const afterClassA = typeof own.afterClassA?.reason === "string" && own.afterClassA.reason.trim() !== "" ? own.afterClassA.reason.trim() : null;
|
|
854
1311
|
const brokerCall = scriptBrokerCall(manifest.scripts, "test") ?? declared.call ?? null;
|
|
855
|
-
|
|
1312
|
+
const buildCall = brokerCall ? null : scriptBuildCall(manifest.scripts, "test");
|
|
1313
|
+
const laterReason = brokerCall ? null : buildCall ? `its test script builds before testing (${buildCall.command}); it runs after class A, with the compile scans` : afterClassA ? `declared to run after class A (ci/gate.config.json npm.${name}.afterClassA): ${afterClassA}` : null;
|
|
1314
|
+
// Isolated only where the tests run on an export (contentRoot is not the live checkout): there sibling repositories are
|
|
1315
|
+
// absent by construction. On the live checkout (full class A, broker admission) the cross-app contract tests run for real.
|
|
1316
|
+
const isolatedCheckout = path.resolve(contentRoot) !== path.resolve(liveRoot);
|
|
1317
|
+
// `npm.<unit>.classATests.evidence` (ci/gate.config.json): the unit's tests were shown, by import inspection and launch observation
|
|
1318
|
+
// under the class A launch guard, to compile and build nothing (docs/GATE-CLASS-A-INVENTORY.md "Test units"); they run in class A,
|
|
1319
|
+
// still under the guard, which refuses any compile at run time. Never when the script reaches the broker, builds, or is declared after class A.
|
|
1320
|
+
const classAEvidence = typeof own.classATests?.evidence === "string" && own.classATests.evidence.trim() !== "" ? own.classATests.evidence.trim() : null;
|
|
1321
|
+
const inClassA = classAEvidence && !brokerCall && !buildCall && !afterClassA;
|
|
1322
|
+
if (classAEvidence && !inClassA) notes.push(`npm.${name}.classATests is ignored: ${brokerCall ? "its test script reaches the broker" : buildCall ? `its test script builds (${buildCall.command})` : "it is declared afterClassA"}; it runs after class A`);
|
|
1323
|
+
// A commit snapshot's compile (targetDirs): a test that reaches cargo writes where cargo puts the outputs of the workspace it
|
|
1324
|
+
// finds from the directory it runs in, which would be inside the snapshot. Bound to that workspace's checkout target directory
|
|
1325
|
+
// when the reachable path proves the directory (cargoReachOf); refused before launch only when it reaches Rust unprovably.
|
|
1326
|
+
let cargoTarget = null;
|
|
1327
|
+
if (targetDirs && cargoShim) {
|
|
1328
|
+
// Advisory only: the wrapper binds or refuses each cargo the test starts, whatever path reaches it.
|
|
1329
|
+
const reach = cargoReachOf({ unitDir: npmDir(name), scripts: manifest.scripts, declared });
|
|
1330
|
+
if (reach.unprovable && Object.keys(targetDirs).length) notes.push(`npm unit ${name}: the static walk cannot prove where its cargo writes (${reach.unprovable}); the runtime cargo wrapper binds each invocation to its workspace's checkout target directory or refuses it`);
|
|
1331
|
+
} else if (targetDirs) {
|
|
1332
|
+
const unitDir = npmDir(name);
|
|
1333
|
+
const scriptsOf = (dir) => { try { const m = JSON.parse(readFile(path.join(contentRoot, ...(dir === "." ? [] : dir.split("/")), "package.json"))); return m?.scripts && typeof m.scripts === "object" ? m.scripts : {}; } catch { return null; } };
|
|
1334
|
+
const packageDirOf = (pkg) => {
|
|
1335
|
+
for (const [unit, entry] of npmMap ?? []) {
|
|
1336
|
+
if (unit === pkg) return entry.dir;
|
|
1337
|
+
try { if (JSON.parse(readFile(path.join(contentRoot, ...(entry.dir === "." ? [] : entry.dir.split("/")), "package.json"))).name === pkg) return entry.dir; } catch { /* unreadable: not this one */ }
|
|
1338
|
+
}
|
|
1339
|
+
return null;
|
|
1340
|
+
};
|
|
1341
|
+
// Every call the test reaches, not the first: one CARGO_TARGET_DIR binds the whole test, so all of them must be one workspace.
|
|
1342
|
+
const reach = cargoReachOf({ unitDir, scripts: manifest.scripts, declared, readScripts: scriptsOf, packageDirOf });
|
|
1343
|
+
const hasRust = Object.keys(targetDirs).length > 0;
|
|
1344
|
+
if (reach.unprovable) {
|
|
1345
|
+
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; }
|
|
1346
|
+
} else if (reach.dir !== undefined || reach.dirs) {
|
|
1347
|
+
const workspaceOf = (from) => {
|
|
1348
|
+
let manifestDir = null;
|
|
1349
|
+
for (let d = from; ; d = path.posix.dirname(d) === "." || d === "." ? "." : path.posix.dirname(d)) {
|
|
1350
|
+
if (exists(path.join(contentRoot, ...(d === "." ? [] : d.split("/")), "Cargo.toml"))) { manifestDir = d; break; }
|
|
1351
|
+
if (d === ".") break;
|
|
1352
|
+
}
|
|
1353
|
+
const ws = manifestDir === null ? null : Object.hasOwn(targetDirs, manifestDir) ? manifestDir : ([...(rustMap?.values() ?? [])].find((u) => u.dir === manifestDir)?.cargoDir ?? null);
|
|
1354
|
+
return ws && targetDirs[ws] ? { ws } : { why: `its test reaches cargo from ${from}, ${manifestDir === null ? `which no Cargo.toml encloses` : `whose ${manifestDir}/Cargo.toml is not a known workspace or member`}; outputs must be the checkout's` };
|
|
1355
|
+
};
|
|
1356
|
+
const found = (reach.dirs ?? [reach.dir]).map(workspaceOf);
|
|
1357
|
+
const unknown = found.find((f) => f.why);
|
|
1358
|
+
if (unknown) { refuse(`npm unit ${name}`, unknown.why); continue; }
|
|
1359
|
+
const workspaces = [...new Set(found.map((f) => f.ws))];
|
|
1360
|
+
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; }
|
|
1361
|
+
cargoTarget = targetDirs[workspaces[0]];
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
add({ id: `test:npm:${name}`, class: inClassA ? A : B, kind: "test", executes: true, classReason: inClassA ? `class A on recorded evidence (${classAEvidence}); run under the launch guard` : brokerCall ? "its test script reaches the broker (compiles); it runs with the compile scans" : laterReason ?? "test scripts execute project code and run after class A", ...(own.quietTests === true ? { quiet: true } : {}), ...(brokerCall ? { brokerCall } : {}), tool: "pnpm", args: ["--dir", npmDir(name), "run", "test"], env: { ...PNPM_RUN_ENV, ...(isolatedCheckout ? { RIGHTKIT_ISOLATED_CHECKOUT: "1" } : {}), ...(cargoTarget ? { CARGO_TARGET_DIR: cargoTarget } : {}) } });
|
|
856
1365
|
}
|
|
857
1366
|
if (deferredToCi.length) notes.push(`${deferredToCi.length} quiet test scan(s) left to the CI shards (one crate per runner), not run here: ${deferredToCi.join(", ")}`);
|
|
858
1367
|
// scripts/ci tests run when scripts/, ci/ or .github/ changed, or a file the scripts/ci sources reference (imports and
|
|
@@ -883,7 +1392,7 @@ export function planScans({
|
|
|
883
1392
|
// The suite is the exported commit's test files, named explicitly: an untracked WIP test in the live
|
|
884
1393
|
// checkout is never run and can neither block nor pass a push.
|
|
885
1394
|
const suite = listDir(path.join(contentRoot, "scripts", "ci")).filter((n) => n.endsWith(".test.mjs")).sort().map((n) => `scripts/ci/${n}`);
|
|
886
|
-
add({ id: "test:scripts-ci", class: B, tool: "node", args: ["--test", ...(suite.length ? suite : ["scripts/ci/*.test.mjs"])] });
|
|
1395
|
+
add({ id: "test:scripts-ci", class: B, kind: "test", executes: true, classReason: "tests execute project code and run after class A", tool: "node", args: ["--test", ...(suite.length ? suite : ["scripts/ci/*.test.mjs"])] });
|
|
887
1396
|
}
|
|
888
1397
|
}
|
|
889
1398
|
|
|
@@ -904,7 +1413,7 @@ export function planScans({
|
|
|
904
1413
|
if (dup) throw new Error(`planScans: duplicate scan id ${dup}`);
|
|
905
1414
|
const classDigest = (cls) => digestScanSet(scans.filter((s) => s.class === cls));
|
|
906
1415
|
return {
|
|
907
|
-
schema: SCAN_RESULT_SCHEMA, roots: { contentRoot, liveRoot }, changed: files, inputDigest, scans, notes, deferred,
|
|
1416
|
+
schema: SCAN_RESULT_SCHEMA, roots: { contentRoot, liveRoot, ...(cargoLiveRoot ? { cargoLiveRoot } : {}) }, changed: files, inputDigest, scans, notes, deferred,
|
|
908
1417
|
scanSetDigest: digestScanSet(scans), classDigests: { A: classDigest(A), B: classDigest(B) },
|
|
909
1418
|
};
|
|
910
1419
|
}
|
|
@@ -921,7 +1430,7 @@ const SAFE_SHIM_PATH = /^[A-Za-z0-9_.,:=@+ \/\\()-]+$/;
|
|
|
921
1430
|
* run any other .cmd/.bat shim only when its path and every argument pass a strict allowlist.
|
|
922
1431
|
* Returns { file, args, verbatim } or { error }.
|
|
923
1432
|
*/
|
|
924
|
-
export function resolveInvocation(command, args, { platform = process.platform, env = process.env, exists = fs.existsSync, execPath = process.execPath } = {}) {
|
|
1433
|
+
export function resolveInvocation(command, args, { platform = process.platform, env = process.env, exists = fs.existsSync, execPath = process.execPath, readFile = (f) => fs.readFileSync(f, "utf8") } = {}) {
|
|
925
1434
|
if (platform !== "win32") return { file: command, args, verbatim: false };
|
|
926
1435
|
const hasExt = /\.[A-Za-z0-9]{1,4}$/.test(command);
|
|
927
1436
|
const pathKey = Object.keys(env).find((k) => k.toLowerCase() === "path");
|
|
@@ -936,6 +1445,10 @@ export function resolveInvocation(command, args, { platform = process.platform,
|
|
|
936
1445
|
if (!/\.(cmd|bat)$/i.test(found)) return { file: found, args, verbatim: false };
|
|
937
1446
|
const cjs = path.win32.join(path.win32.dirname(found), "node_modules", "pnpm", "bin", "pnpm.cjs");
|
|
938
1447
|
if (/^pnpm\.cmd$/i.test(path.win32.basename(found)) && exists(cjs)) return { file: execPath, args: [cjs, ...args], verbatim: false };
|
|
1448
|
+
// A Node cmd-shim (what npm, pnpm and nvm install: `"%~dp0\...\entry.js" %*`, npm's `SET "NPM_CLI_JS=%~dp0\...npm-cli.js"`) runs
|
|
1449
|
+
// its JS entry with node: started here as node + that entry, no shell, so any argument passes unchanged (rightkit#1261).
|
|
1450
|
+
const entry = cmdShimEntry(found, { readFile, exists });
|
|
1451
|
+
if (entry) return { file: execPath, args: [entry, ...args], verbatim: false };
|
|
939
1452
|
const bad = args.find((a) => !SAFE_SHIM_ARG.test(a));
|
|
940
1453
|
if (bad !== undefined) return { error: new Error(`refusing to pass ${JSON.stringify(bad)} to the shell shim ${found}`) };
|
|
941
1454
|
if (!SAFE_SHIM_PATH.test(found)) return { error: new Error(`refusing to run shell shim ${found}: unsafe path`) };
|
|
@@ -943,14 +1456,83 @@ export function resolveInvocation(command, args, { platform = process.platform,
|
|
|
943
1456
|
return { file: env.ComSpec || "cmd.exe", args: ["/d", "/s", "/c", `""${found}"${args.map((a) => ` ${a}`).join("")}"`], verbatim: true };
|
|
944
1457
|
}
|
|
945
1458
|
|
|
1459
|
+
/**
|
|
1460
|
+
* The JS entry a Node cmd-shim at `shim` runs, or null: the line that passes the caller's arguments (`%*`) names it, either as a
|
|
1461
|
+
* `%~dp0`-relative path (`"%~dp0\\..\\pnpm.mjs" %*`) or through a variable set to one (npm's `"%NODE_EXE%" "%NPM_CLI_JS%" %*` with
|
|
1462
|
+
* `SET "NPM_CLI_JS=%~dp0\\node_modules\\npm\\bin\\npm-cli.js"`). Helpers the shim runs on other lines (npm's npm-prefix.js) are not it.
|
|
1463
|
+
*/
|
|
1464
|
+
export function cmdShimEntry(shim, { readFile = (f) => fs.readFileSync(f, "utf8"), exists = fs.existsSync } = {}) {
|
|
1465
|
+
let text;
|
|
1466
|
+
try { text = String(readFile(shim)); } catch { return null; }
|
|
1467
|
+
const vars = new Map();
|
|
1468
|
+
// Every value a variable is set to (npm's NPM_CLI_JS is reassigned to a global-prefix copy when one exists): the %~dp0 one is used.
|
|
1469
|
+
for (const m of text.matchAll(/^\s*@?SET\s+"?([A-Za-z_][A-Za-z0-9_]*)=([^"\r\n]*)"?\s*$/gim)) vars.set(m[1].toUpperCase(), [...(vars.get(m[1].toUpperCase()) ?? []), m[2]]);
|
|
1470
|
+
const fromDp0 = (value) => {
|
|
1471
|
+
const m = /^%~dp0\\?(.+\.(?:mjs|cjs|js))$/i.exec(String(value ?? "").trim());
|
|
1472
|
+
if (!m) return null;
|
|
1473
|
+
const entry = path.win32.resolve(path.win32.dirname(shim), m[1]);
|
|
1474
|
+
return exists(entry) ? entry : null;
|
|
1475
|
+
};
|
|
1476
|
+
for (const line of text.split(/\r?\n/)) {
|
|
1477
|
+
if (!line.includes("%*")) continue;
|
|
1478
|
+
for (const q of line.matchAll(/"([^"]+)"/g)) {
|
|
1479
|
+
const token = q[1];
|
|
1480
|
+
const direct = fromDp0(token);
|
|
1481
|
+
if (direct) return direct;
|
|
1482
|
+
const v = /^%([A-Za-z_][A-Za-z0-9_]*)%$/.exec(token);
|
|
1483
|
+
if (v) for (const value of vars.get(v[1].toUpperCase()) ?? []) { const viaVar = fromDp0(value); if (viaVar) return viaVar; }
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
return null;
|
|
1487
|
+
}
|
|
1488
|
+
|
|
946
1489
|
/** Default spawn: synchronous, no console window, never `shell: true`. */
|
|
947
1490
|
export function defaultSpawn(command, args, options = {}) {
|
|
1491
|
+
if (options.boundedMs) return boundedSpawn(command, args, options);
|
|
948
1492
|
const inv = resolveInvocation(command, args, { env: options.env ?? process.env });
|
|
949
1493
|
if (inv.error) return { status: null, stdout: "", stderr: "", error: inv.error };
|
|
950
1494
|
return spawnSync(inv.file, inv.args, { encoding: "utf8", windowsHide: true, maxBuffer: 64 * 1024 * 1024, windowsVerbatimArguments: inv.verbatim, ...options, shell: false });
|
|
951
1495
|
}
|
|
952
1496
|
|
|
953
1497
|
const OUTPUT_KEEP = 4 * 1024 * 1024;
|
|
1498
|
+
/**
|
|
1499
|
+
* defaultSpawn with a deadline (`options.boundedMs`), for short identity probes that must never hang the caller: the command runs
|
|
1500
|
+
* in a helper node process through defaultSpawnAsync, which on the deadline kills the command's whole process tree
|
|
1501
|
+
* (killProcessTree) and reports `timedOut: true`. The helper records the command's pid first; if the helper itself overruns its
|
|
1502
|
+
* backstop (`backstopMs`, default deadline + 15 s), it is killed and the command's tree is killed from here by that pid, so no
|
|
1503
|
+
* probe outlives the call. A launch failure keeps its `error` (e.g. ENOENT); a command that ran keeps its `status`.
|
|
1504
|
+
*/
|
|
1505
|
+
export function boundedSpawn(command, args, { boundedMs, backstopMs = boundedMs + 15000, cwd, env, pidFile = null } = {}) {
|
|
1506
|
+
const dir = pidFile ? null : fs.mkdtempSync(path.join(os.tmpdir(), "rk-probe-"));
|
|
1507
|
+
const pids = pidFile ?? path.join(dir, "pid");
|
|
1508
|
+
const script = `import(${JSON.stringify(new URL(import.meta.url).href)}).then(async (m) => {
|
|
1509
|
+
const [command, args, ms, pidFile] = JSON.parse(process.argv[1]); const fs = await import("node:fs");
|
|
1510
|
+
const ac = new AbortController(); const timer = setTimeout(() => ac.abort(), ms);
|
|
1511
|
+
const r = await m.defaultSpawnAsync(command, args, { signal: ac.signal, onSpawn: (pid) => fs.writeFileSync(pidFile, String(pid)) }); clearTimeout(timer);
|
|
1512
|
+
process.stdout.write(JSON.stringify({ status: r.status, stdout: r.stdout, stderr: r.stderr, timedOut: r.cancelled === true, error: r.cancelled ? null : (r.error ? { code: r.error.code ?? null, message: r.error.message } : null) }));
|
|
1513
|
+
});`;
|
|
1514
|
+
try {
|
|
1515
|
+
const r = spawnSync(process.execPath, ["--input-type=module", "-e", script, JSON.stringify([command, args, boundedMs, pids])], { cwd, env: env ?? process.env, encoding: "utf8", windowsHide: true, stdio: ["ignore", "pipe", "pipe"], timeout: backstopMs, killSignal: "SIGKILL", maxBuffer: 16 * 1024 * 1024 });
|
|
1516
|
+
const timeout = (why) => ({ status: null, stdout: "", stderr: "", timedOut: true, error: Object.assign(new Error(`timed out after ${boundedMs} ms${why}`), { code: "ETIMEDOUT" }) });
|
|
1517
|
+
if (r.error?.code === "ETIMEDOUT") {
|
|
1518
|
+
let pid = null; try { pid = Number(fs.readFileSync(pids, "utf8")) || null; } catch { /* never started */ }
|
|
1519
|
+
if (pid) killProcessTree({ pid });
|
|
1520
|
+
return timeout(" (backstop)");
|
|
1521
|
+
}
|
|
1522
|
+
try {
|
|
1523
|
+
const o = JSON.parse(r.stdout);
|
|
1524
|
+
if (o.timedOut) return timeout("");
|
|
1525
|
+
return { status: o.status, stdout: o.stdout ?? "", stderr: o.stderr ?? "", ...(o.error ? { error: Object.assign(new Error(o.error.message), { code: o.error.code }) } : {}) };
|
|
1526
|
+
} catch { return { status: null, stdout: "", stderr: r.stderr ?? "", error: new Error(`the bounded probe gave no result (${r.error?.message ?? `exit ${r.status}`})`) }; }
|
|
1527
|
+
} finally { if (dir) fs.rmSync(dir, { recursive: true, force: true }); }
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
/** The environment additions that load the class A launch guard into a test row's Node processes (NODE_OPTIONS appended, not replaced). */
|
|
1531
|
+
export function launchGuardEnv(env = process.env) {
|
|
1532
|
+
const current = String(env.NODE_OPTIONS ?? "");
|
|
1533
|
+
return { RIGHTKIT_CLASS_A_LAUNCH_GUARD: "1", NODE_OPTIONS: current.includes(LAUNCH_GUARD_IMPORT) ? current : `${current} ${LAUNCH_GUARD_IMPORT}`.trim() };
|
|
1534
|
+
}
|
|
1535
|
+
|
|
954
1536
|
/** Kill `child` and everything it started: taskkill /T /F on Windows, the whole process group elsewhere (the child leads one when spawned with a signal). */
|
|
955
1537
|
export function killProcessTree(child, { platform = process.platform, run = spawnSync, kill = (pid, sig) => process.kill(pid, sig) } = {}) {
|
|
956
1538
|
if (!child?.pid) return;
|
|
@@ -967,17 +1549,21 @@ export function killProcessTree(child, { platform = process.platform, run = spaw
|
|
|
967
1549
|
* With `options.stdoutFile`, stdout is written to that file whole instead of kept in memory.
|
|
968
1550
|
*/
|
|
969
1551
|
export function defaultSpawnAsync(command, args, options = {}) {
|
|
970
|
-
const { signal, stdoutFile, ...rest } = options;
|
|
1552
|
+
const { signal, stdoutFile, onSpawn, ...rest } = options;
|
|
971
1553
|
const inv = resolveInvocation(command, args, { env: rest.env ?? process.env });
|
|
972
1554
|
if (inv.error) return Promise.resolve({ status: null, stdout: "", stderr: "", error: inv.error });
|
|
973
1555
|
if (signal?.aborted) return Promise.resolve({ status: null, stdout: "", stderr: "", error: new Error("cancelled"), cancelled: true });
|
|
974
1556
|
return new Promise((resolve) => {
|
|
975
1557
|
let stdout = ""; let stderr = ""; let error = null;
|
|
976
|
-
|
|
1558
|
+
let truncated = false;
|
|
1559
|
+
const keep = (buf, chunk) => { const next = buf + chunk; if (next.length > OUTPUT_KEEP) { truncated = true; return next.slice(-OUTPUT_KEEP); } return next; };
|
|
977
1560
|
let child;
|
|
978
1561
|
try {
|
|
979
|
-
|
|
1562
|
+
// stdin is closed: no scan reads it, and an open pipe nobody writes can block a tool that probes it (Windows).
|
|
1563
|
+
// A detached child (POSIX, its own group for the abort kill) is recorded under the held build ownership (build-ownership.mjs).
|
|
1564
|
+
child = spawnUnderOwnership(spawnChild, inv.file, inv.args, { windowsHide: true, windowsVerbatimArguments: inv.verbatim, stdio: ["ignore", "pipe", "pipe"], ...(signal && process.platform !== "win32" ? { detached: true } : {}), ...rest, shell: false, stdio: ["ignore", "pipe", "pipe"] });
|
|
980
1565
|
} catch (e) { resolve({ status: null, stdout: "", stderr: "", error: e }); return; }
|
|
1566
|
+
if (onSpawn && child.pid) onSpawn(child.pid);
|
|
981
1567
|
const onAbort = () => killProcessTree(child);
|
|
982
1568
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
983
1569
|
child.stdout.setEncoding("utf8"); child.stderr.setEncoding("utf8");
|
|
@@ -989,7 +1575,7 @@ export function defaultSpawnAsync(command, args, options = {}) {
|
|
|
989
1575
|
if (sink) await new Promise((done) => (sink.writableFinished ? done() : sink.on("finish", done).on("error", done)));
|
|
990
1576
|
signal?.removeEventListener("abort", onAbort);
|
|
991
1577
|
const cancelled = signal?.aborted === true;
|
|
992
|
-
resolve({ status, stdout, stderr, ...(error ? { error } : cancelled ? { error: new Error("cancelled") } : {}), ...(cancelled ? { cancelled: true } : {}) });
|
|
1578
|
+
resolve({ status, stdout, stderr, ...(truncated ? { truncated: true } : {}), ...(error ? { error } : cancelled ? { error: new Error("cancelled") } : {}), ...(cancelled ? { cancelled: true } : {}) });
|
|
993
1579
|
});
|
|
994
1580
|
if (signal?.aborted) onAbort();
|
|
995
1581
|
});
|
|
@@ -1000,6 +1586,80 @@ export function defaultSpawnAsync(command, args, options = {}) {
|
|
|
1000
1586
|
// time; a test scan marked `quiet` (ci/gate.config.json `rust.<crate>.quietTests`: the crate's tests share ports, devices,
|
|
1001
1587
|
// wall-clock budgets or the broker's own queue, so they fail under load) runs last, one at a time, under the broker's quiet
|
|
1002
1588
|
// lease when the gate can take one (runScans `quietLane`).
|
|
1589
|
+
/** The counts a test runner printed last: node:test's `ℹ tests N` / `ℹ pass` / `ℹ fail` / `ℹ skipped` / `ℹ todo` lines, or vitest's
|
|
1590
|
+
* `Tests N passed | M skipped | K failed (T)` line. Null when neither is present. Several node:test summaries (a script that runs
|
|
1591
|
+
* the runner more than once) are summed. */
|
|
1592
|
+
/** null when a test summary is evidence of a pass; otherwise { code, detail }. Used for fresh and cached test rows alike. */
|
|
1593
|
+
export function testResultsProblem(results) {
|
|
1594
|
+
if (!results) return { code: "NO_TEST_SUMMARY", detail: "no test counts recorded" };
|
|
1595
|
+
if (!(results.tests > 0)) return { code: "NO_TESTS", detail: "the test scan executed no test" };
|
|
1596
|
+
if (results.fail > 0) return { code: "SUMMARY_FAILED", detail: `the test runner exited 0 but its summary reports ${results.fail} failed test(s)` };
|
|
1597
|
+
// A suite that ran tests but passed none of them (every test skipped or todo) proves nothing (D17, rightkit#1236).
|
|
1598
|
+
if (!(results.pass > 0)) return { code: "ALL_SKIPPED", detail: `the test scan passed no test: ${results.skipped ?? 0} skipped, ${results.todo ?? 0} todo of ${results.tests}` };
|
|
1599
|
+
return null;
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
export function parseTestSummary(text) {
|
|
1603
|
+
const lines = String(text ?? "").split(/\r?\n/);
|
|
1604
|
+
const node = { tests: 0, pass: 0, fail: 0, skipped: 0, todo: 0 }; let sawNode = false;
|
|
1605
|
+
for (const line of lines) {
|
|
1606
|
+
const m = /^[ℹ#]\s+(tests|pass|fail|skipped|todo)\s+(\d+)\s*$/.exec(line.trim());
|
|
1607
|
+
if (m) { node[m[1]] += Number(m[2]); sawNode = true; }
|
|
1608
|
+
}
|
|
1609
|
+
if (sawNode) return { runner: "node:test", ...node };
|
|
1610
|
+
const vt = lines.map((l) => l.replace(/\x1b\[[0-9;]*m/g, "")).reverse().find((l) => /^\s*Tests\s+.*\(\d+\)\s*$/.test(l));
|
|
1611
|
+
if (vt) {
|
|
1612
|
+
const n = (re) => Number((re.exec(vt) ?? [0, 0])[1]);
|
|
1613
|
+
const tests = Number(/\((\d+)\)\s*$/.exec(vt)[1]);
|
|
1614
|
+
return { runner: "vitest", tests, pass: n(/(\d+) passed/), fail: n(/(\d+) failed/), skipped: n(/(\d+) skipped/), todo: n(/(\d+) todo/) };
|
|
1615
|
+
}
|
|
1616
|
+
return null;
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
/** The largest inline `run:` script a workflow may carry while the pinned actionlint is 1.7.12 (rhysd/actionlint#702). */
|
|
1620
|
+
export const RUN_SCRIPT_LIMIT_BYTES = 3800;
|
|
1621
|
+
/** Every block `run:` script (`run: |` or `run: >`) of a workflow text larger than `limit` bytes, as "<file>:<line> <bytes> bytes".
|
|
1622
|
+
* Line-based and conservative: the block is every following line indented deeper than the `run:` key (blank lines included),
|
|
1623
|
+
* with that common indentation removed, as YAML reads a literal block. */
|
|
1624
|
+
export function oversizedRunScripts(text, file, limit = RUN_SCRIPT_LIMIT_BYTES) {
|
|
1625
|
+
const lines = String(text).split(/\r?\n/);
|
|
1626
|
+
const out = [];
|
|
1627
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
1628
|
+
const m = /^(\s*)(?:-\s+)?run:\s*[|>][-+0-9]*\s*$/.exec(lines[i]);
|
|
1629
|
+
if (!m) continue;
|
|
1630
|
+
const keyIndent = lines[i].indexOf("run:");
|
|
1631
|
+
const body = [];
|
|
1632
|
+
let j = i + 1;
|
|
1633
|
+
for (; j < lines.length; j += 1) {
|
|
1634
|
+
const line = lines[j];
|
|
1635
|
+
if (line.trim() === "") { body.push(""); continue; }
|
|
1636
|
+
if (line.length - line.trimStart().length <= keyIndent) break;
|
|
1637
|
+
body.push(line);
|
|
1638
|
+
}
|
|
1639
|
+
while (body.length && body[body.length - 1] === "") body.pop();
|
|
1640
|
+
const indent = Math.min(...body.filter(Boolean).map((l) => l.length - l.trimStart().length));
|
|
1641
|
+
const script = body.map((l) => l.slice(Number.isFinite(indent) ? indent : 0)).join("\n") + "\n";
|
|
1642
|
+
const bytes = Buffer.byteLength(script);
|
|
1643
|
+
if (bytes > limit) out.push(`${file}:${i + 1} ${bytes} bytes`);
|
|
1644
|
+
i = j - 1;
|
|
1645
|
+
}
|
|
1646
|
+
return out;
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
/** The time limit of one scan: its own `timeoutMs`, then the run's `scanTimeoutMs`, else 5 min for actionlint, 30 min for class A and
|
|
1650
|
+
* 60 min for class B (a cold compile of the largest crate). */
|
|
1651
|
+
/** The broker's quiet lease ceiling (rustright-state quiet.rs QUIET_LEASE_MAX_TTL_MS) and the quiet suite timeout under it. */
|
|
1652
|
+
export const QUIET_LEASE_MAX_MS = 30 * 60_000;
|
|
1653
|
+
export const QUIET_SCAN_MAX_MS = 26 * 60_000;
|
|
1654
|
+
/** A tool version probe's bound; with QUIET_SCAN_MAX_MS it keeps a whole leased quiet suite (probes + run) under the 30 min lease. */
|
|
1655
|
+
export const PROBE_TIMEOUT_MS = 60_000;
|
|
1656
|
+
export function scanTimeoutMs(scan, options = {}) {
|
|
1657
|
+
if (Number.isFinite(scan?.timeoutMs) && scan.timeoutMs > 0) return scan.timeoutMs;
|
|
1658
|
+
if (Number.isFinite(options.scanTimeoutMs) && options.scanTimeoutMs > 0) return options.scanTimeoutMs;
|
|
1659
|
+
if (scan?.tool === "actionlint") return 5 * 60_000;
|
|
1660
|
+
return scan?.class === SCAN_CLASS.B ? 60 * 60_000 : 30 * 60_000;
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1003
1663
|
export function laneOf(scan) {
|
|
1004
1664
|
if (/^(clippy|check|check-features|check-dependent):/.test(scan.id)) return "compile";
|
|
1005
1665
|
if (/^doc:/.test(scan.id)) return "doc";
|
|
@@ -1052,6 +1712,30 @@ async function defaultScanSecrets() {
|
|
|
1052
1712
|
return (await import("./publish-cargo.mjs")).scanCrateForSecrets;
|
|
1053
1713
|
}
|
|
1054
1714
|
|
|
1715
|
+
/**
|
|
1716
|
+
* For a content root that is a git worktree's toplevel: a predicate over absolute paths that is true for every git-ignored file or
|
|
1717
|
+
* directory there (`git ls-files --others --ignored --exclude-standard --directory`, which lists an ignored directory once, with a
|
|
1718
|
+
* trailing slash). Null when the root is not a git toplevel (a committed export) or git cannot answer: then nothing is skipped.
|
|
1719
|
+
*/
|
|
1720
|
+
export async function ignoredPathSkip(ctx) {
|
|
1721
|
+
const git = async (args) => { try { return await ctx.deps.spawn("git", args, { cwd: ctx.contentRoot, encoding: "utf8", env: ctx.deps.env }); } catch { return null; } };
|
|
1722
|
+
const top = await git(["rev-parse", "--show-toplevel"]);
|
|
1723
|
+
if (!top || top.status !== 0) return null;
|
|
1724
|
+
const same = (a, b) => { try { return fs.realpathSync(a) === fs.realpathSync(b); } catch { return path.resolve(a) === path.resolve(b); } };
|
|
1725
|
+
if (!same(String(top.stdout ?? "").trim(), ctx.contentRoot)) return null;
|
|
1726
|
+
const listed = await git(["ls-files", "-z", "--others", "--ignored", "--exclude-standard", "--directory"]);
|
|
1727
|
+
if (!listed || listed.status !== 0) return null;
|
|
1728
|
+
const entries = String(listed.stdout ?? "").split("\0").filter(Boolean);
|
|
1729
|
+
const dirs = entries.filter((e) => e.endsWith("/"));
|
|
1730
|
+
const files = new Set(entries.filter((e) => !e.endsWith("/")));
|
|
1731
|
+
const root = ctx.contentRoot;
|
|
1732
|
+
return (absolute) => {
|
|
1733
|
+
const rel = path.relative(root, absolute).replaceAll("\\", "/");
|
|
1734
|
+
if (!rel || rel.startsWith("..")) return false;
|
|
1735
|
+
return files.has(rel) || dirs.some((d) => rel === d.slice(0, -1) || rel.startsWith(d));
|
|
1736
|
+
};
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1055
1739
|
// What rustfmt needs from a Cargo.toml, read by table so an `edition` under [package.metadata.x] or [dependencies.x] is not the
|
|
1056
1740
|
// package's: `pkg` is the [package] edition (a literal year, or "workspace" for `edition.workspace = true` / `edition = { workspace = true }`),
|
|
1057
1741
|
// `ws` the [workspace.package] edition literal, `isWorkspace` whether the manifest declares a [workspace]. An `edition` entry in one of
|
|
@@ -1196,11 +1880,15 @@ const INTERNAL = {
|
|
|
1196
1880
|
// Each violation names its repository-relative path, so a unit that is both a crate and a package reports a file once.
|
|
1197
1881
|
"secret-scan": async (s, ctx) => {
|
|
1198
1882
|
const scan = await ctx.deps.scanSecrets();
|
|
1883
|
+
// A committed export holds only tracked files. A live tree (--to dev|test: the content root is the checkout itself) also holds
|
|
1884
|
+
// git-ignored build output (.right-release/, DerivedData, a vendored checkout under it), which the gate never judges: the scanner
|
|
1885
|
+
// skips what `git ls-files --ignored` names there. Outside a git toplevel nothing is skipped (Pulse, 2026-10-27).
|
|
1886
|
+
const skip = await ignoredPathSkip(ctx);
|
|
1199
1887
|
const bad = new Set();
|
|
1200
1888
|
for (const dir of s.dirs) {
|
|
1201
1889
|
const abs = path.join(ctx.contentRoot, dir);
|
|
1202
1890
|
if (!ctx.deps.exists(abs)) { bad.add(`${dir}: directory not found`); continue; }
|
|
1203
|
-
for (const v of scan(abs, { envTemplates: true, repoRoot: ctx.contentRoot })) bad.add(v);
|
|
1891
|
+
for (const v of scan(abs, { envTemplates: true, repoRoot: ctx.contentRoot, skip })) bad.add(v);
|
|
1204
1892
|
}
|
|
1205
1893
|
return [...bad];
|
|
1206
1894
|
},
|
|
@@ -1225,14 +1913,18 @@ export async function runScans(plan, options = {}) {
|
|
|
1225
1913
|
const exclude = options.exclude ?? (() => false);
|
|
1226
1914
|
// An AbortSignal: once aborted, no further scan starts, running children are killed (process tree) and every unfinished scan is "not-run" with code CANCELLED.
|
|
1227
1915
|
const signal = options.signal; const sig = signal ? { signal } : {};
|
|
1228
|
-
const { contentRoot, liveRoot } = plan.roots;
|
|
1229
|
-
const rootDir = (r) => (r === "content" ? contentRoot : r === "parent" ? path.dirname(contentRoot) : liveRoot);
|
|
1916
|
+
const { contentRoot, liveRoot, cargoLiveRoot = null } = plan.roots;
|
|
1917
|
+
const rootDir = (r) => (r === "content" ? contentRoot : r === "parent" ? path.dirname(contentRoot) : r === "cargoLive" ? (cargoLiveRoot ?? liveRoot) : liveRoot);
|
|
1230
1918
|
const selected = plan.scans.filter((s) => classes.includes(s.class) && !exclude(s));
|
|
1231
1919
|
const ordered = [...selected.filter((s) => s.class === SCAN_CLASS.A), ...selected.filter((s) => s.class === SCAN_CLASS.B)];
|
|
1232
1920
|
const ctx = { deps, contentRoot, read: (f) => deps.readFile(path.join(contentRoot, f)) };
|
|
1233
1921
|
const toolVersions = {}; const probed = new Map(); const advisoryDbs = new Map();
|
|
1234
1922
|
const results = [];
|
|
1235
|
-
//
|
|
1923
|
+
// The gate-owned cargo ledger of a wrapped snapshot compile (options.cargoLedger = { dir }): every call the wrapper decided, filed
|
|
1924
|
+
// under its scan's token. `ledgerSeen` is the run's one-use record of carriers and request ids across every scan.
|
|
1925
|
+
const cargoLedger = options.cargoLedger?.dir ? options.cargoLedger : null;
|
|
1926
|
+
const claimedTokens = new Set(); const ledgerSeen = { nonces: new Set(), ids: new Set() };
|
|
1927
|
+
// options.cache ={ read(key), write(key, row), digest(scan) => string | null } (null: not cacheable, which is how the live class A checks opt out).
|
|
1236
1928
|
const cache = options.cache && typeof options.cache.read === "function" && typeof options.cache.write === "function" && typeof options.cache.digest === "function" ? options.cache : null;
|
|
1237
1929
|
const cacheHits = [];
|
|
1238
1930
|
let classAFailed = false;
|
|
@@ -1256,7 +1948,7 @@ export async function runScans(plan, options = {}) {
|
|
|
1256
1948
|
const classBBlocked = !inCallA && !priorClassAValid;
|
|
1257
1949
|
|
|
1258
1950
|
const toolCommand = (tool) => (tool === "node" ? deps.execPath : tool === "rustfmt" ? deps.findRustfmt({ env: deps.env, exists: deps.exists, readdir: deps.readdir, platform: deps.platform, spawn: deps.lookupSpawn }) : tool);
|
|
1259
|
-
async function probe(tool) {
|
|
1951
|
+
async function probe(tool, scanDir = null) {
|
|
1260
1952
|
if (probed.has(tool)) return probed.get(tool);
|
|
1261
1953
|
let outcome;
|
|
1262
1954
|
const command = toolCommand(tool);
|
|
@@ -1264,7 +1956,19 @@ export async function runScans(plan, options = {}) {
|
|
|
1264
1956
|
else {
|
|
1265
1957
|
// A version probe never runs inside the repository: a tool started there reads the repository's own configuration first
|
|
1266
1958
|
// (pnpm's .npmrc and pnpmfile, corepack's packageManager pin, cargo's .cargo/config), before any scan's protections apply.
|
|
1267
|
-
|
|
1959
|
+
// rustright is the one exception: the RustRight client refuses to run anywhere that is not a Cargo workspace inside a repository
|
|
1960
|
+
// (RK_NOT_PRIMARY_CHECKOUT, "workspace discovery failed"), so from a neutral directory every cargo scan read as TOOL_MISSING on
|
|
1961
|
+
// every broker host (rightkit#1215). Its probe runs where the scan itself will run; `cargo --version` reads no repository config.
|
|
1962
|
+
const cwd = (tool === "rustright" || (tool === "cargo" && cargoLiveRoot)) && scanDir ? scanDir : (deps.probeDir ?? probeDir());
|
|
1963
|
+
// A version probe is bounded (PROBE_TIMEOUT_MS): a hung tool reads as unavailable instead of holding a lane, or a quiet
|
|
1964
|
+
// lease, open.
|
|
1965
|
+
const probeMs = Number.isFinite(options.probeTimeoutMs) && options.probeTimeoutMs > 0 ? options.probeTimeoutMs : PROBE_TIMEOUT_MS;
|
|
1966
|
+
const probeSignal = signal ? AbortSignal.any([signal, AbortSignal.timeout(probeMs)]) : AbortSignal.timeout(probeMs);
|
|
1967
|
+
// The gate's own version probes reach the wrapper too (cargo is first on PATH): they are filed under the probe token, which the
|
|
1968
|
+
// gate claims and checks like a scan's.
|
|
1969
|
+
const probeLedgerEnv = cargoLedger ? { [SCAN_TOKEN_ENV]: PROBE_LEDGER_TOKEN } : {};
|
|
1970
|
+
if (cargoLedger) claimedTokens.add(PROBE_LEDGER_TOKEN);
|
|
1971
|
+
const r = await deps.spawn(command, PROBES[tool] ?? ["--version"], { cwd, env: { ...deps.env, ...PNPM_NO_HOOKS_ENV, COREPACK_ENABLE_AUTO_PIN: "0", ...probeLedgerEnv }, signal: probeSignal });
|
|
1268
1972
|
if (!r || r.error || r.status !== 0) outcome = { ok: false, why: r?.error?.message ?? `exit ${r?.status}` };
|
|
1269
1973
|
else {
|
|
1270
1974
|
const text = String(r.stdout || r.stderr || "");
|
|
@@ -1284,7 +1988,14 @@ export async function runScans(plan, options = {}) {
|
|
|
1284
1988
|
const quietLane = options.quietLane && typeof options.quietLane.acquire === "function" ? options.quietLane : null;
|
|
1285
1989
|
const onResult = typeof options.onResult === "function" ? options.onResult : () => {};
|
|
1286
1990
|
const total = ordered.length; let done = 0;
|
|
1991
|
+
// Every class A failure latches, whichever branch produced it (Astra, 2026-10-26: two early returns bypassed the latch below, so a
|
|
1992
|
+
// missing advisory database or a failed deny metadata could be followed by a class B compile). Class B reads the latch before it starts.
|
|
1287
1993
|
async function runOne(s, extraEnv = null) {
|
|
1994
|
+
const row = await runOneBody(s, extraEnv);
|
|
1995
|
+
if (row.status === "fail" && s.class === SCAN_CLASS.A) classAFailed = true;
|
|
1996
|
+
return row;
|
|
1997
|
+
}
|
|
1998
|
+
async function runOneBody(s, extraEnv = null) {
|
|
1288
1999
|
if (signal?.aborted) return { id: s.id, class: s.class, command: s.command, tool: s.tool ?? null, toolVersion: null, status: "not-run", code: "CANCELLED", detail: "cancelled before it started" };
|
|
1289
2000
|
const started = Date.now();
|
|
1290
2001
|
const row = await scanRow(s, extraEnv);
|
|
@@ -1292,33 +2003,93 @@ export async function runScans(plan, options = {}) {
|
|
|
1292
2003
|
try { onResult(row, { done, total, seconds: Math.round((Date.now() - started) / 1000) }); } catch { /* reporting never fails a scan */ }
|
|
1293
2004
|
return row;
|
|
1294
2005
|
}
|
|
1295
|
-
// Class A
|
|
1296
|
-
// lane (`testJobs`), then the quiet lane one at a time under the broker's quiet lease, so the tests that fail under load run on
|
|
1297
|
-
// an otherwise quiet machine, as CI's shards run tests after their build. With jobs = 1 and testJobs = 1 the order is the same.
|
|
2006
|
+
// Class A may run concurrently. Class B runs one scan per component at a time, `classBJobs` components at once (below).
|
|
1298
2007
|
const aScans = ordered.filter((s) => s.class === SCAN_CLASS.A);
|
|
1299
2008
|
const bScans = ordered.filter((s) => s.class === SCAN_CLASS.B);
|
|
1300
2009
|
// Class A scans are independent content checks (and short broker calls: metadata, package list, deny, audit): they run
|
|
1301
2010
|
// `classAJobs` at a time (default: `jobs`); the rows keep plan order. Class B starts only once every class A row is in.
|
|
1302
2011
|
const aJobs = Math.max(1, Number(options.classAJobs ?? jobs) || jobs);
|
|
1303
2012
|
{
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
2013
|
+
// Class A is everything mechanical and deterministic that does not compile (Adrian, 2026-10-27): the content checks first,
|
|
2014
|
+
// `classAJobs` at a time, then the class A tests (kind "test"), `testJobs` at a time, then the quiet ones alone under the
|
|
2015
|
+
// broker's quiet lease. A failed content check stops the tests: they run on content class A already judged.
|
|
2016
|
+
const slots = new Array(aScans.length);
|
|
2017
|
+
const content = []; const tests = []; const quietTests = [];
|
|
2018
|
+
aScans.forEach((s, i) => (s.kind === "test" ? (s.quiet === true ? quietTests : tests) : content).push(i));
|
|
2019
|
+
const drain = (queue, extraEnv = null) => async () => {
|
|
2020
|
+
while (queue.length) {
|
|
2021
|
+
const i = queue.shift();
|
|
2022
|
+
slots[i] = aScans[i].kind === "test" && classAFailed
|
|
2023
|
+
? { id: aScans[i].id, class: aScans[i].class, command: aScans[i].command, tool: aScans[i].tool ?? null, toolVersion: null, status: "not-run", detail: "a class A content check failed; the class A tests start only after it passes" }
|
|
2024
|
+
: await runOne(aScans[i], extraEnv);
|
|
2025
|
+
}
|
|
2026
|
+
};
|
|
2027
|
+
await Promise.all(Array.from({ length: aJobs }, drain(content)));
|
|
2028
|
+
await Promise.all(Array.from({ length: testJobs }, drain(tests)));
|
|
2029
|
+
if (quietTests.length) {
|
|
2030
|
+
// One broker quiet lease per quiet suite (the broker caps a lease at 30 min and has no renewal): each suite runs alone,
|
|
2031
|
+
// under its own lease, with a timeout shorter than the lease, so a lease never expires while its suite still runs.
|
|
2032
|
+
for (const i of quietTests) {
|
|
2033
|
+
if (classAFailed || signal?.aborted) {
|
|
2034
|
+
slots[i] = { id: aScans[i].id, class: aScans[i].class, command: aScans[i].command, tool: aScans[i].tool ?? null, toolVersion: null, status: "not-run", detail: signal?.aborted ? "cancelled" : "a class A check failed; the quiet suites start only on a green class A" };
|
|
2035
|
+
continue;
|
|
2036
|
+
}
|
|
2037
|
+
let lease = null;
|
|
2038
|
+
if (quietLane) { try { lease = (await quietLane.acquire({ ttlMs: QUIET_LEASE_MAX_MS })) ?? null; } catch { lease = null; } }
|
|
2039
|
+
if (!lease && options.requireQuietLease) {
|
|
2040
|
+
slots[i] = { id: aScans[i].id, class: aScans[i].class, command: aScans[i].command, tool: aScans[i].tool ?? null, toolVersion: null, status: "fail", code: "QUIET_LEASE_UNAVAILABLE", detail: "the broker's quiet lease could not be held; this conflict-prone suite does not run alongside other jobs on a managed host. Run again when the broker can grant the lease." };
|
|
2041
|
+
classAFailed = true; // a refused class A suite is a red class A: no class B scan starts after it
|
|
2042
|
+
continue;
|
|
2043
|
+
}
|
|
2044
|
+
try { slots[i] = await runOne({ ...aScans[i], timeoutMs: Math.min(scanTimeoutMs(aScans[i], options), QUIET_SCAN_MAX_MS) }, lease?.env ?? null); } finally {
|
|
2045
|
+
if (lease && typeof quietLane.release === "function") { try { await quietLane.release(lease.id); } catch { /* the lease expires on its own */ } }
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
1307
2049
|
results.push(...slots);
|
|
1308
2050
|
}
|
|
1309
2051
|
{
|
|
1310
2052
|
const slots = new Array(bScans.length);
|
|
1311
2053
|
const lanes = { compile: [], doc: [], tests: [], quiet: [] };
|
|
1312
2054
|
bScans.forEach((s, i) => lanes[laneOf(s)].push(i));
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
2055
|
+
// RightKit is many components, never one build (Adrian, 2026-10-29; rightkit#1285): class B scans of different components run
|
|
2056
|
+
// concurrently, `classBJobs` at a time (the broker queues beyond its build capacity). A component is the directory a scan runs in
|
|
2057
|
+
// (`cwd`, its own Cargo workspace and output directory); scans of one component stay in plan order, because they share its
|
|
2058
|
+
// output directory and cargo locks it.
|
|
2059
|
+
const bJobs = Math.max(1, Number(options.classBJobs ?? 1) || 1);
|
|
2060
|
+
const drain = async (queue, extraEnv = null) => {
|
|
2061
|
+
const busy = new Set(); const running = new Set();
|
|
2062
|
+
const keyOf = (i) => bScans[i].cwd ?? ".";
|
|
2063
|
+
while (queue.length || running.size) {
|
|
2064
|
+
const at = running.size < bJobs ? queue.findIndex((i) => !busy.has(keyOf(i))) : -1;
|
|
2065
|
+
if (at === -1) { await Promise.race(running); continue; }
|
|
2066
|
+
const [i] = queue.splice(at, 1); const key = keyOf(i); busy.add(key);
|
|
2067
|
+
// A scan that throws becomes a failed row: with several components in flight, a rejection must not abandon the others.
|
|
2068
|
+
const job = runOne(bScans[i], extraEnv)
|
|
2069
|
+
.catch((error) => ({ id: bScans[i].id, class: bScans[i].class, command: bScans[i].command, tool: bScans[i].tool ?? null, toolVersion: null, status: "fail", code: "SCAN_ERROR", detail: String(error?.message ?? error) }))
|
|
2070
|
+
.then((row) => { slots[i] = row; }).finally(() => { busy.delete(key); running.delete(job); });
|
|
2071
|
+
running.add(job);
|
|
2072
|
+
}
|
|
2073
|
+
};
|
|
2074
|
+
await drain(lanes.compile);
|
|
2075
|
+
await drain(lanes.doc);
|
|
2076
|
+
await drain(lanes.tests);
|
|
1316
2077
|
if (lanes.quiet.length) {
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
2078
|
+
// One lease per quiet suite, as in class A above.
|
|
2079
|
+
for (const i of lanes.quiet) {
|
|
2080
|
+
if (classAFailed || classBBlocked || signal?.aborted) {
|
|
2081
|
+
slots[i] = { id: bScans[i].id, class: bScans[i].class, command: bScans[i].command, tool: bScans[i].tool ?? null, toolVersion: null, status: "not-run", detail: signal?.aborted ? "cancelled" : classAFailed ? "class A is red; nothing in class B starts" : "class B is blocked" };
|
|
2082
|
+
continue;
|
|
2083
|
+
}
|
|
2084
|
+
let lease = null;
|
|
2085
|
+
if (quietLane) { try { lease = (await quietLane.acquire({ ttlMs: QUIET_LEASE_MAX_MS })) ?? null; } catch { lease = null; } }
|
|
2086
|
+
if (!lease && options.requireQuietLease) {
|
|
2087
|
+
slots[i] = { id: bScans[i].id, class: bScans[i].class, command: bScans[i].command, tool: bScans[i].tool ?? null, toolVersion: null, status: "fail", code: "QUIET_LEASE_UNAVAILABLE", detail: "the broker's quiet lease could not be held; this conflict-prone suite does not run alongside other jobs on a managed host. Run again when the broker can grant the lease." };
|
|
2088
|
+
continue;
|
|
2089
|
+
}
|
|
2090
|
+
try { slots[i] = await runOne({ ...bScans[i], timeoutMs: Math.min(scanTimeoutMs(bScans[i], options), QUIET_SCAN_MAX_MS) }, lease?.env ?? null); } finally {
|
|
2091
|
+
if (lease && typeof quietLane.release === "function") { try { await quietLane.release(lease.id); } catch { /* the lease expires on its own */ } }
|
|
2092
|
+
}
|
|
1322
2093
|
}
|
|
1323
2094
|
}
|
|
1324
2095
|
results.push(...slots);
|
|
@@ -1368,11 +2139,12 @@ export async function runScans(plan, options = {}) {
|
|
|
1368
2139
|
if (out && out.missing) { status = "fail"; code = "TOOL_MISSING"; detail = out.missing; }
|
|
1369
2140
|
else if (out.length) { status = "fail"; detail = out.join("; "); }
|
|
1370
2141
|
} else {
|
|
1371
|
-
const
|
|
2142
|
+
const scanDir = path.join(rootDir(s.root), s.cwd ?? "");
|
|
2143
|
+
const p = await probe(s.tool, scanDir);
|
|
1372
2144
|
row.toolVersion = p.ok ? p.version : null;
|
|
1373
2145
|
let missingDep = null; let depVersionProblem = null; const depVersions = [];
|
|
1374
2146
|
for (const dep of s.requires ?? []) {
|
|
1375
|
-
const d = await probe(dep);
|
|
2147
|
+
const d = await probe(dep, scanDir);
|
|
1376
2148
|
if (!d.ok) { missingDep = `${dep} (${d.why})`; break; }
|
|
1377
2149
|
if (d.pinProblem) { depVersionProblem = d.pinProblem; break; }
|
|
1378
2150
|
depVersions.push(d.version);
|
|
@@ -1383,7 +2155,10 @@ export async function runScans(plan, options = {}) {
|
|
|
1383
2155
|
else if (depVersionProblem) { status = "fail"; code = "TOOL_VERSION"; detail = `TOOL_VERSION: ${s.tool} runs ${depVersionProblem}`; }
|
|
1384
2156
|
else {
|
|
1385
2157
|
let cacheKey = null;
|
|
1386
|
-
|
|
2158
|
+
// A cargo request that builds the commit snapshot through the broker (options.snapshotBroker) is never served from or written
|
|
2159
|
+
// to the scan cache: each run must carry its own request and verify the broker's own signed evidence.
|
|
2160
|
+
const viaSnapshotBroker = Boolean(deps.snapshotBroker) && s.root === "cargoLive" && (s.tool === "rustright" || s.tool === "cargo");
|
|
2161
|
+
if (cache && !viaSnapshotBroker) {
|
|
1387
2162
|
try {
|
|
1388
2163
|
const digest = cache.digest(s);
|
|
1389
2164
|
// The analyzers a scan runs through (actionlint's shellcheck) judge it too, so their versions key it as well.
|
|
@@ -1391,25 +2166,44 @@ export async function runScans(plan, options = {}) {
|
|
|
1391
2166
|
if (digest) cacheKey = scanCacheKey({ id: s.id, command: s.command, toolVersion, env: s.env ?? {}, inheritedEnv: inheritedEnvDigest({ ...deps.env, ...s.env }), digest });
|
|
1392
2167
|
} catch { cacheKey = null; }
|
|
1393
2168
|
const hit = cacheKey ? cache.read(cacheKey) : null;
|
|
1394
|
-
|
|
2169
|
+
// A cached test pass counts only with the counts it recorded: without them its coverage is unknown, so it runs again.
|
|
2170
|
+
// ...and only if it passed at least one test: a cached all-skipped "pass" (written before D17) runs again (rightkit#1236).
|
|
2171
|
+
const testEvidence = s.kind !== "test" || testResultsProblem(hit?.row?.results) === null;
|
|
2172
|
+
if (hit && hit.key === cacheKey && hit.row?.status === "pass" && hit.row.id === s.id && hit.row.command === s.command && testEvidence) {
|
|
1395
2173
|
cacheHits.push(s.id);
|
|
1396
|
-
return { ...row, status: "pass", cached: true, detail: `cached ${String(hit.at ?? "").slice(0, 10) || "earlier"}
|
|
2174
|
+
return { ...row, status: "pass", cached: true, detail: `cached ${String(hit.at ?? "").slice(0, 10) || "earlier"}`, ...(s.kind === "test" ? { results: hit.row.results } : {}) };
|
|
1397
2175
|
}
|
|
1398
2176
|
}
|
|
1399
2177
|
let advisory = null; let args = s.args;
|
|
1400
2178
|
if (s.advisoryDb) {
|
|
1401
2179
|
try { advisory = prepareAdvisory(s); } catch (e) { return { ...row, status: "fail", code: "ADVISORY_DB", detail: e.message }; }
|
|
1402
|
-
// cargo-deny --offline must not fetch the advisory database, but its own `cargo metadata` would then be offline too and
|
|
2180
|
+
// cargo-deny --frozen (--offline + --locked: the nested `cargo fetch` it runs may not rewrite Cargo.lock, so the broker admits it as analysis) must not fetch the advisory database, but its own `cargo metadata` would then be offline too and
|
|
1403
2181
|
// fail on a runner whose cargo cache is cold. The workspace's metadata is resolved first, online and --locked (the
|
|
1404
2182
|
// lockfile decides every version), and handed to cargo-deny as a file.
|
|
1405
2183
|
if (s.advisoryDb === "deny") {
|
|
1406
2184
|
const at = args.indexOf("deny");
|
|
1407
2185
|
// The whole document is needed (the concurrent spawn keeps only the tail of stdout), so it streams to the file; a
|
|
1408
2186
|
// spawn that does not know `stdoutFile` returns stdout, which is written below.
|
|
2187
|
+
// cargo-deny --frozen reads crate sources only from the local cargo cache, and `cargo metadata` does not download them
|
|
2188
|
+
// (fullA-19: rightkit-qa 0.9.1 was missing). They are fetched first, online and --locked (bound to the lockfile, so the
|
|
2189
|
+
// broker admits it as analysis). A fetch that fails, is refused or times out fails the row before metadata or deny run:
|
|
2190
|
+
// an offline or unreachable registry is red, never green.
|
|
2191
|
+
const fetchLimit = scanTimeoutMs(s, options);
|
|
2192
|
+
const fetchTimer = new AbortController();
|
|
2193
|
+
const fetchClock = setTimeout(() => fetchTimer.abort(), fetchLimit);
|
|
2194
|
+
let fetched;
|
|
2195
|
+
try { fetched = await deps.spawn(p.command, [...args.slice(0, at), "fetch", "--locked"], { cwd: path.join(rootDir(s.root), s.cwd ?? ""), env: { ...deps.env, ...s.env }, signal: signal ? AbortSignal.any([signal, fetchTimer.signal]) : fetchTimer.signal, timeout: fetchLimit }); } finally { clearTimeout(fetchClock); }
|
|
2196
|
+
if (!fetched || fetched.error || fetched.status !== 0) {
|
|
2197
|
+
advisory.cleanup?.();
|
|
2198
|
+
if (signal?.aborted) return { ...row, status: "not-run", code: "CANCELLED", detail: "cancelled while running" };
|
|
2199
|
+
if (fetchTimer.signal.aborted || fetched?.error?.code === "ETIMEDOUT") return { ...row, status: "fail", code: "TIMEOUT", detail: `cargo fetch --locked for cargo-deny ran longer than ${Math.round(fetchLimit / 60000)} min and was killed with its process tree` };
|
|
2200
|
+
return { ...row, status: "fail", code: "FETCH", detail: `cargo fetch --locked for cargo-deny failed (crate sources must be local before deny): ${fetched?.error?.message ?? (tail(fetched) || `exit ${fetched?.status}`)}` };
|
|
2201
|
+
}
|
|
1409
2202
|
const metadataDir = fs.mkdtempSync(path.join(os.tmpdir(), "rk-deny-metadata-"));
|
|
1410
2203
|
const metadataFile = path.join(metadataDir, "metadata.json");
|
|
1411
2204
|
const metadata = await deps.spawn(p.command, [...args.slice(0, at), ...metadataArgs({ noDeps: false })], { cwd: path.join(rootDir(s.root), s.cwd ?? ""), env: { ...deps.env, ...s.env }, stdoutFile: metadataFile, ...sig });
|
|
1412
|
-
|
|
2205
|
+
// AUX_METADATA: the auxiliary metadata that feeds deny could not be produced; not a deny finding, so never a deterministic red (rightkit#1257).
|
|
2206
|
+
if (!metadata || metadata.error || metadata.status !== 0) { fs.rmSync(metadataDir, { recursive: true, force: true }); advisory.cleanup?.(); return { ...row, status: "fail", code: "AUX_METADATA", detail: `cargo metadata for cargo-deny failed: ${metadata?.error?.message ?? (tail(metadata) || `exit ${metadata?.status}`)}` }; }
|
|
1413
2207
|
if (!fs.existsSync(metadataFile)) fs.writeFileSync(metadataFile, String(metadata.stdout ?? ""));
|
|
1414
2208
|
advisory.metadataPath = metadataFile;
|
|
1415
2209
|
const overlayCleanup = advisory.cleanup;
|
|
@@ -1418,11 +2212,128 @@ export async function runScans(plan, options = {}) {
|
|
|
1418
2212
|
args = args.map((a) => (a === "{advisoryDb}" ? advisory.dbDir : a === "{advisoryDenyConfig}" ? advisory.configPath : a === "{advisoryMetadata}" ? advisory.metadataPath : a));
|
|
1419
2213
|
}
|
|
1420
2214
|
let r;
|
|
1421
|
-
|
|
2215
|
+
// actionlint 1.7.12 writes each run: script to shellcheck's stdin before starting it (rhysd/actionlint#702): a script over the
|
|
2216
|
+
// pipe buffer (~4 KB on Windows) deadlocks. Oversized scripts are refused here, before actionlint starts, so it cannot hang.
|
|
2217
|
+
if (s.runScriptLimit) {
|
|
2218
|
+
const over = s.runScriptLimit.files.flatMap((file) => { try { return oversizedRunScripts(ctx.read(file), file, s.runScriptLimit.bytes); } catch { return [`${file}: unreadable`]; } });
|
|
2219
|
+
if (over.length) return { ...row, status: "fail", code: "RUN_SCRIPT_TOO_LARGE", detail: `run: scripts over ${s.runScriptLimit.bytes} bytes (actionlint 1.7.12 deadlocks on them, rhysd/actionlint#702; split the step): ${over.join("; ")}` };
|
|
2220
|
+
}
|
|
2221
|
+
// Every scan has a time limit (fails closed, names the scan, kills the process tree): a hung tool is a check that never
|
|
2222
|
+
// fails, i.e. a run that never ends (actionlint on the Dell, 2026-10-27).
|
|
2223
|
+
// A tsc row that names TSC_PROJECT checks its package against the committed sources of its workspace siblings: the project is
|
|
2224
|
+
// generated outside the checked tree and removed after the run, so the check reads nothing a commit lacks and writes nothing.
|
|
2225
|
+
let tscProject = null;
|
|
2226
|
+
if (args.includes(TSC_PROJECT)) {
|
|
2227
|
+
try { tscProject = writeTscProject({ contentRoot, packageDir: path.join(rootDir(s.root), s.cwd ?? ""), workspace: s.tscWorkspace ?? [] }); }
|
|
2228
|
+
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}` }; }
|
|
2229
|
+
args = args.map((a) => (a === TSC_PROJECT ? tscProject.file : a));
|
|
2230
|
+
}
|
|
2231
|
+
const limitMs = scanTimeoutMs(s, options);
|
|
2232
|
+
const timer = new AbortController();
|
|
2233
|
+
const clock = setTimeout(() => timer.abort(), limitMs);
|
|
2234
|
+
const scanSignal = signal ? AbortSignal.any([signal, timer.signal]) : timer.signal;
|
|
2235
|
+
// A class A test row runs under the launch guard (class-a-launch-guard.mjs): its Node processes may not launch a compile.
|
|
2236
|
+
// Every class A row that executes repository code (tests, lint/typecheck with repository config, generators) runs under the
|
|
2237
|
+
// launch guard: class A runs before any compile, so none of them may start one (joint plan step 4; inventory gap 3).
|
|
2238
|
+
const guardEnv = s.class === SCAN_CLASS.A && (s.kind === "test" || s.executes === true) ? launchGuardEnv({ ...deps.env, ...s.env, ...(extraEnv ?? {}) }) : {};
|
|
2239
|
+
// The broker snapshot carrier (joint plan step 4d): this one cargo request names the commit, the snapshot, the ownership
|
|
2240
|
+
// handle and its own argv and working directory, under a fresh nonce; the ownership records the submit first.
|
|
2241
|
+
const finalArgs = args.map((a) => (a === "{contentRoot}" ? contentRoot : a));
|
|
2242
|
+
let carrier = null;
|
|
2243
|
+
if (viaSnapshotBroker) {
|
|
2244
|
+
const sb = deps.snapshotBroker;
|
|
2245
|
+
const cwd = path.join(rootDir(s.root), s.cwd ?? "");
|
|
2246
|
+
const refDir = fs.mkdtempSync(path.join(os.tmpdir(), "rk-snapreq-"));
|
|
2247
|
+
try {
|
|
2248
|
+
const cargoArgv = s.tool === "rustright" ? finalArgs.slice(1) : finalArgs;
|
|
2249
|
+
// The ownership must record the submit, under this gate's handle, before anything is launched: a missing hook or a
|
|
2250
|
+
// mark that did not land refuses the request (the reclaim proof reads broker.submitted).
|
|
2251
|
+
if (typeof sb.markBrokerSubmit !== "function") throw new Error("no build-ownership submit hook: the broker submit cannot be recorded");
|
|
2252
|
+
const marked = sb.markBrokerSubmit();
|
|
2253
|
+
if (marked?.broker?.submitted !== true || marked.handle !== sb.handle) throw new Error(`the build ownership did not record the broker submit under ${sb.handle}`);
|
|
2254
|
+
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") });
|
|
2255
|
+
carrier = { made, refDir, cargoArgv };
|
|
2256
|
+
} 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}` }; }
|
|
2257
|
+
}
|
|
2258
|
+
// From here to the end of its verification the carrier and the request reference are this request's: every exit (cancel,
|
|
2259
|
+
// timeout, a thrown spawn, a refused receipt) removes them.
|
|
2260
|
+
// A wrapped snapshot compile with several Cargo workspaces (options.cargoSentinel, item 7): a scan whose own env binds no
|
|
2261
|
+
// CARGO_TARGET_DIR gets an empty directory of its own as one. Every cargo reached through the wrapper is bound elsewhere; only
|
|
2262
|
+
// a cargo that bypassed it (an absolute path, `rustup run`, a reset PATH) can write here, and the scan then fails below.
|
|
2263
|
+
let sentinel = null;
|
|
2264
|
+
// (A scan that is itself the carried broker request is bound by the broker: no sentinel, so the client's observed environment is the scan's own.)
|
|
2265
|
+
if (options.cargoSentinel && !viaSnapshotBroker && !Object.hasOwn(s.env ?? {}, "CARGO_TARGET_DIR")) {
|
|
2266
|
+
sentinel = path.join(options.cargoSentinel, sha256(s.id).slice(0, 16));
|
|
2267
|
+
fs.rmSync(sentinel, { recursive: true, force: true });
|
|
2268
|
+
fs.mkdirSync(sentinel, { recursive: true });
|
|
2269
|
+
}
|
|
2270
|
+
const sentinelEnv = sentinel ? { CARGO_TARGET_DIR: sentinel, CARGO_BUILD_TARGET_DIR: sentinel } : {};
|
|
2271
|
+
// Every cargo call the wrapper decides during this scan is filed under this token in the gate's ledger.
|
|
2272
|
+
const ledgerToken = cargoLedger ? scanTokenOf(s.id) : null;
|
|
2273
|
+
if (ledgerToken) claimedTokens.add(ledgerToken);
|
|
2274
|
+
const ledgerEnv = ledgerToken ? { [SCAN_TOKEN_ENV]: ledgerToken } : {};
|
|
2275
|
+
try {
|
|
2276
|
+
try { r = await deps.spawn(p.command, finalArgs, { cwd: path.join(rootDir(s.root), s.cwd ?? ""), env: { ...deps.env, ...sentinelEnv, ...s.env, ...(extraEnv ?? {}), ...guardEnv, ...ledgerEnv, ...(carrier ? carrier.made.env : {}) }, signal: scanSignal, timeout: limitMs }); } finally { clearTimeout(clock); advisory?.cleanup?.(); }
|
|
1422
2277
|
if (signal?.aborted && (!r || r.error || r.status !== 0)) return { ...row, status: "not-run", code: "CANCELLED", detail: "cancelled while running" };
|
|
2278
|
+
if ((timer.signal.aborted || r?.error?.code === "ETIMEDOUT") && (!r || r.error || r.status !== 0)) return { ...row, status: "fail", code: "TIMEOUT", detail: `${s.id} ran longer than ${Math.round(limitMs / 60000)} min and was killed with its process tree; a scan that does not finish fails` };
|
|
1423
2279
|
if (!r || r.error) { status = "fail"; code = "SPAWN_ERROR"; detail = r?.error?.message ?? "no result"; }
|
|
1424
2280
|
else if (r.status !== 0) { status = "fail"; code = "EXIT"; detail = tail(r) || `exit ${r.status}`; }
|
|
1425
|
-
|
|
2281
|
+
// cargo-deny reports some failures only as an [ERROR] line and still exits 0 (under the broker, its nested offline
|
|
2282
|
+
// `cargo fetch` of crate sources is refused: "failed to fetch crates"). A deny row passes only with no [ERROR] line in
|
|
2283
|
+
// its complete output; output cut to the kept tail cannot prove that, so it fails too (RR, reviewer 2026-10-28).
|
|
2284
|
+
if (sentinel) {
|
|
2285
|
+
let written = [];
|
|
2286
|
+
try { written = fs.readdirSync(sentinel); } catch (e) { written = [`(unreadable: ${e.message})`]; }
|
|
2287
|
+
if (written.length) { status = "fail"; code = "RK_CARGO_UNBOUND"; detail = `${s.id}: a cargo that bypassed the gate's wrapper wrote ${written.slice(0, 5).join(", ")} into the unbound sentinel ${sentinel}; with several Cargo workspaces no output directory can be inferred for it, so the scan proves nothing about where its outputs went`; }
|
|
2288
|
+
}
|
|
2289
|
+
// The wrapper's ledger (rows 7.10 and 7.24): a refused, unverified or unfinished cargo call fails this scan even when the code
|
|
2290
|
+
// under test ignored cargo's exit status; on a broker host each call's signed receipt is verified again here, by the gate.
|
|
2291
|
+
if (ledgerToken) {
|
|
2292
|
+
let problems;
|
|
2293
|
+
try {
|
|
2294
|
+
const ledger = readLedger(cargoLedger.dir, ledgerToken);
|
|
2295
|
+
problems = [...ledger.problems, ...(deps.snapshotBroker ? await verifyBrokerLedger(ledger.entries, deps.snapshotBroker, { seen: ledgerSeen }) : [])];
|
|
2296
|
+
} catch (e) { problems = [`the cargo ledger cannot be read (${e.message})`]; }
|
|
2297
|
+
if (problems.length) {
|
|
2298
|
+
const text = `${s.id}: the gate's cargo ledger records ${problems.length} cargo call(s) that did not complete as a verified success: ${problems.slice(0, 5).join("; ")}`;
|
|
2299
|
+
if (status === "pass") { status = "fail"; code = "RK_CARGO_LEDGER"; detail = text; } else detail = `${detail ? `${detail}; ` : ""}${text}`;
|
|
2300
|
+
}
|
|
2301
|
+
}
|
|
2302
|
+
if (s.advisoryDb === "deny" && code !== "RK_CARGO_UNBOUND") {
|
|
2303
|
+
const denyError = `${r.stdout ?? ""}\n${r.stderr ?? ""}`.split(/\r?\n/).find((line) => /\[ERROR\]/.test(line));
|
|
2304
|
+
if (r.truncated) { status = "fail"; code = "DENY_ERROR"; detail = "cargo-deny output exceeded the kept tail, so the absence of an [ERROR] line cannot be shown"; }
|
|
2305
|
+
else if (denyError) { status = "fail"; code = "DENY_ERROR"; detail = `cargo-deny exited 0 but reported an error: ${denyError.trim().slice(0, 300)}`; }
|
|
2306
|
+
}
|
|
2307
|
+
// The broker's signed account of that request: the receipt verifies against this host's pins and its snapshotEvidence names
|
|
2308
|
+
// this commit, this snapshot unchanged from START to exit, this ownership and the output directory. Anything less fails the row.
|
|
2309
|
+
if (carrier) {
|
|
2310
|
+
try {
|
|
2311
|
+
const ref = readRequestRef(path.join(carrier.refDir, "request.json"));
|
|
2312
|
+
const receipt = await deps.snapshotBroker.receiptOf(ref.requestId);
|
|
2313
|
+
const { evidence } = verifySnapshotReceipt(receipt, { pins: deps.snapshotBroker.pins, jobId: ref.requestId, carrier: carrier.made.carrier, argv: carrier.cargoArgv, workspace: ref.workspace, observedEnv: observedEnvOf({ ...deps.env, ...s.env, ...(extraEnv ?? {}) }, deps.platform ?? process.platform), checkout: deps.snapshotBroker.checkout, outputRoots: deps.snapshotBroker.outputRoots, platform: deps.snapshotBroker.platform ?? deps.platform ?? process.platform });
|
|
2314
|
+
row.snapshotEvidence = { requestId: ref.requestId, outputDir: evidence.outputDir, treeAtStart: evidence.snapshot.treeAtStart, treeAtExit: evidence.snapshot.treeAtExit };
|
|
2315
|
+
} catch (e) {
|
|
2316
|
+
status = "fail"; code = "SNAPSHOT_EVIDENCE"; detail = `${s.id}: the broker's snapshot evidence did not verify (${e.code ?? "error"}: ${e.message})`;
|
|
2317
|
+
}
|
|
2318
|
+
}
|
|
2319
|
+
} finally {
|
|
2320
|
+
if (carrier) { fs.rmSync(carrier.made.file, { force: true }); fs.rmSync(carrier.refDir, { recursive: true, force: true }); }
|
|
2321
|
+
if (tscProject) fs.rmSync(tscProject.dir, { recursive: true, force: true });
|
|
2322
|
+
}
|
|
2323
|
+
if (status === "fail" && options.scanLogDir && r) {
|
|
2324
|
+
try { fs.mkdirSync(options.scanLogDir, { recursive: true }); const file = path.join(options.scanLogDir, `${s.id.replace(/[^A-Za-z0-9._-]+/g, "_")}.log`); fs.writeFileSync(file, `$ ${s.command ?? s.id}\nexit ${r.status}\n--- stdout\n${r.stdout ?? ""}\n--- stderr\n${r.stderr ?? ""}\n`); row.log = file; detail = `${detail} (full output: ${file})`; } catch { /* the tail in the row remains */ }
|
|
2325
|
+
}
|
|
2326
|
+
if (s.kind === "test" && r && !r.error && r.status === 0) {
|
|
2327
|
+
// What the suite executed and skipped is evidence, not decoration: a later stage reuses this row only with these counts.
|
|
2328
|
+
const counts = parseTestSummary(`${r.stdout ?? ""}\n${r.stderr ?? ""}`);
|
|
2329
|
+
if (counts) row.results = counts;
|
|
2330
|
+
else if (status === "pass") { status = "fail"; code = "NO_TEST_SUMMARY"; detail = "the test scan passed but printed no recognisable summary (node:test or vitest); its coverage cannot be recorded"; }
|
|
2331
|
+
else row.results = null;
|
|
2332
|
+
// One rule for fresh and cached rows (testResultsProblem): tests ran, none failed, at least one passed.
|
|
2333
|
+
const problem = status === "pass" && row.results ? testResultsProblem(row.results) : null;
|
|
2334
|
+
if (problem) { status = "fail"; code = problem.code; detail = problem.detail; }
|
|
2335
|
+
}
|
|
2336
|
+
if (status === "pass" && cacheKey && !signal?.aborted) cache.write(cacheKey, { id: s.id, class: s.class, command: s.command, tool: s.tool ?? null, toolVersion: row.toolVersion, status: "pass", ...(row.results ? { results: row.results } : {}) });
|
|
1426
2337
|
}
|
|
1427
2338
|
}
|
|
1428
2339
|
} catch (e) { status = "fail"; code = "SCAN_ERROR"; detail = e.message; }
|
|
@@ -1430,12 +2341,27 @@ export async function runScans(plan, options = {}) {
|
|
|
1430
2341
|
return { ...row, status, ...(code ? { code } : {}), ...(detail ? { detail } : {}) };
|
|
1431
2342
|
}
|
|
1432
2343
|
|
|
2344
|
+
// Cargo calls the gate cannot attribute to a scan (the code under test cleared or replaced the scan token): never skipped.
|
|
2345
|
+
if (cargoLedger) {
|
|
2346
|
+
// The gate's own version probes: anything but a completed call (a refused probe) fails the run too.
|
|
2347
|
+
try {
|
|
2348
|
+
const probeProblems = readLedger(cargoLedger.dir, PROBE_LEDGER_TOKEN).problems;
|
|
2349
|
+
if (probeProblems.length) results.push({ id: "cargo-ledger:tool-probe", class: SCAN_CLASS.B, command: "cargo ledger", tool: null, toolVersion: null, status: "fail", code: "RK_CARGO_LEDGER", detail: `the gate's tool version probes reached the wrapper and did not complete: ${probeProblems.slice(0, 5).join("; ")}` });
|
|
2350
|
+
} catch { /* an unreadable probe ledger is named by the unclaimed check only when it holds records */ }
|
|
2351
|
+
let unclaimed;
|
|
2352
|
+
try { unclaimed = unclaimedLedgers(cargoLedger.dir, claimedTokens); } catch (e) { unclaimed = [`unreadable (${e.message})`]; }
|
|
2353
|
+
if (unclaimed.length) {
|
|
2354
|
+
const calls = [];
|
|
2355
|
+
for (const token of unclaimed) { try { for (const e of readLedger(cargoLedger.dir, token).entries) calls.push(`cargo ${(e.argv ?? []).join(" ")} (${e.status}, from ${e.cwd})`.trim()); } catch { /* named by its token below */ } }
|
|
2356
|
+
results.push({ id: "cargo-ledger", class: SCAN_CLASS.B, command: "cargo ledger", tool: null, toolVersion: null, status: "fail", code: "RK_CARGO_LEDGER", detail: `cargo calls were filed under ${unclaimed.length} scan token(s) no scan owns (${unclaimed.slice(0, 3).join(", ")}), so they cannot be tied to a scan (${SCAN_TOKEN_ENV} was cleared, or the call came from outside every scan): ${calls.slice(0, 5).join("; ") || "unreadable"}` });
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
1433
2359
|
const cancelled = signal?.aborted === true;
|
|
1434
2360
|
return {
|
|
1435
2361
|
schema: SCAN_RESULT_SCHEMA, ok: !cancelled && results.every((r) => r.status === "pass"), complete: !cancelled && results.every((r) => r.status === "pass"), classes, checker, ...(cancelled ? { cancelled: true } : {}),
|
|
1436
2362
|
inputDigest: plan.inputDigest ?? null,
|
|
1437
2363
|
deferred: results.filter((r) => r.status === "deferred").map((r) => r.id),
|
|
1438
|
-
scans: results, toolVersions, cacheHits: [...cacheHits].sort(), notes: plan.notes ?? [],
|
|
2364
|
+
scans: results, toolVersions, cacheHits: [...cacheHits].sort(), notes: [...(plan.notes ?? []), ...(cargoLedger ? [UNOBSERVED_NESTED_CARGO_NOTE] : [])],
|
|
1439
2365
|
scanSetDigest: digestScanSet(results),
|
|
1440
2366
|
classDigests: { A: digestScanSet(results.filter((r) => r.class === "A")), B: digestScanSet(results.filter((r) => r.class === "B")) },
|
|
1441
2367
|
};
|