@rightkit/release 0.4.0 → 0.5.0
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 +59 -7
- package/adhoc-entitlements.mjs +8 -0
- package/adoption-evidence.mjs +650 -0
- package/advisory-exceptions.mjs +371 -0
- package/apple-profile-create.mjs +113 -10
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/rightkit-gate.mjs +1079 -0
- package/binary-license-notices.mjs +161 -0
- package/cargo-license-notices.mjs +111 -0
- package/cli/right-release.mjs +39 -0
- package/deterministic-scans.mjs +966 -0
- package/development-signing.mjs +2 -1
- package/host-upload-refusal.mjs +90 -0
- package/local-publish.mjs +142 -19
- package/local-qualify.mjs +728 -0
- package/local-runtime.mjs +319 -0
- package/lock-graph.mjs +51 -0
- package/managed-cargo.mjs +4 -1
- package/ownership-approval.mjs +23 -6
- package/ownership-dependency-allowlist.json +2 -0
- package/ownership-mandatory-set.json +158 -68
- package/ownership-mandatory-set.mjs +82 -6
- package/package.json +6 -2
- package/pipeline-admission.mjs +375 -0
- package/pipeline-config.mjs +100 -0
- package/pipeline-generators.json +4 -0
- package/pipeline-generators.mjs +137 -0
- package/pipeline-receipts.mjs +302 -0
- package/pipeline-scan.mjs +285 -0
- package/pipeline-tools.mjs +171 -0
- package/production-mode.mjs +42 -0
- package/publication-gate.mjs +94 -32
- package/publish-cargo.mjs +24 -7
- package/publish-swift.mjs +12 -3
- package/qa-control-scan.mjs +4 -0
- package/release-config.mjs +330 -0
- package/release-switch-scan.mjs +44 -5
- package/release.mjs +14 -4
- package/rightkit-ownership.json +57 -0
- package/rightkit-sync.mjs +8 -0
- package/rightkit-versions.json +191 -131
- package/runtime-budget-classes.json +8 -0
- package/runtime-budget-crates.mjs +14 -0
- package/runtime-budgets.mjs +128 -31
- package/scan-digest.mjs +107 -0
- package/sign-qa-bundle.mjs +210 -0
- package/stage-sidecar.mjs +99 -15
- package/swap-helper-bundle.mjs +7 -3
- package/workflow-yaml.mjs +177 -0
- package/bin/darwin/rightkit-swap.sign.json +0 -6
|
@@ -0,0 +1,966 @@
|
|
|
1
|
+
// The one deterministic-scan module (Adrian 2026-10-22, #833 #888 #824; architecture-v3 section 4):
|
|
2
|
+
// every scan that applies to a change runs here, before any artifact build, local or CI.
|
|
3
|
+
//
|
|
4
|
+
// Class A: compile-free, executes no tests and no repository-supplied plugin or generator.
|
|
5
|
+
// Class B: needs the compiler or runs tests or repository code (clippy, check, doc, tsc, eslint,
|
|
6
|
+
// package tests, the repository's own docs generator).
|
|
7
|
+
//
|
|
8
|
+
// A required tool that is missing FAILS (status "fail", code TOOL_MISSING); a scan is never skipped.
|
|
9
|
+
// The one allowed gap is status "deferred": a class B scan that needs a local compile grant when the
|
|
10
|
+
// repository has none. A deferred scan is listed by name (result.deferred, scanDeferrals), is never a
|
|
11
|
+
// pass (result.ok and result.complete are false while any scan is deferred) and cannot become a SCAN_B receipt.
|
|
12
|
+
// Content scans run on the exported HEAD (contentRoot). Only a scan that inherently needs the live
|
|
13
|
+
// checkout uses liveRoot: `rightkit cargo` scans (the broker binds to the registered checkout), the
|
|
14
|
+
// tsc, eslint and package tests (need installed node_modules).
|
|
15
|
+
// Class B runs only when every selected class A scan passed (SCAN_B is distinct from SCAN_A and
|
|
16
|
+
// follows it). Rust commands go through `rightkit cargo`, JavaScript tools through `pnpm exec`.
|
|
17
|
+
// The runner takes injectable spawn/exists/readFile/readdir so tests execute nothing real. The
|
|
18
|
+
// result is a plain object (per-scan status, tool versions, scanSetDigest) usable as receipt content
|
|
19
|
+
// (packages/release/pipeline-receipts.mjs); it carries no authority by itself.
|
|
20
|
+
import crypto from "node:crypto";
|
|
21
|
+
import fs from "node:fs";
|
|
22
|
+
import os from "node:os";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
25
|
+
import { spawn as spawnChild, spawnSync } from "./background-process.mjs";
|
|
26
|
+
import { checkAdvisoryExceptions } from "./advisory-exceptions.mjs";
|
|
27
|
+
import { canonicalJson, digestScanSet, requiredScanIds, rustCratePlan, rustScanId } from "./scan-digest.mjs";
|
|
28
|
+
import { reach } from "./lock-graph.mjs";
|
|
29
|
+
|
|
30
|
+
export const SCAN_RESULT_SCHEMA = "rightkit.deterministic-scans/1";
|
|
31
|
+
export const SCAN_CLASS = Object.freeze({ A: "A", B: "B" });
|
|
32
|
+
|
|
33
|
+
/** Scan id prefixes (the text before the first ":") that belong to each class. */
|
|
34
|
+
export const CLASS_A_SCAN_PREFIXES = Object.freeze([
|
|
35
|
+
"rustfmt", "node-check", "json-parse", "toml-parse", "yaml-parse", "ownership-catalog",
|
|
36
|
+
"actionlint", "pnpm-lockfile", "pnpm-audit", "cargo-metadata", "cargo-deny", "cargo-audit", "cargo-package-list",
|
|
37
|
+
"changelog-version", "license", "secret-scan", "path-policy", "advisory-exceptions",
|
|
38
|
+
]);
|
|
39
|
+
export const CLASS_B_SCAN_PREFIXES = Object.freeze([
|
|
40
|
+
"clippy", "check", "check-all-features", "check-features", "check-dependent", "doc", "tsc", "eslint", "swift-build", "test", "generated-docs",
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Rollout switch for the CI integration (ci/gate.config.json: { "deterministicScans": { "enabled": true } }).
|
|
45
|
+
* Absent or anything but boolean true means OFF. A disabled run is never scan success or qualification evidence.
|
|
46
|
+
*
|
|
47
|
+
* deferredBeforeEnable (these gate ENABLING the CI integration, not shipping the dormant code):
|
|
48
|
+
* - deferred doc:* scans have no CI path yet;
|
|
49
|
+
* - changed-path / base coverage for workflow-only and package-only changes in ci.yml (the hygiene job has no --base);
|
|
50
|
+
* - pinned provisioning of cargo-deny, cargo-audit and actionlint via right-git;
|
|
51
|
+
* - class B wiring: CI's compile and test steps do not yet run through the shared runner with class A evidence.
|
|
52
|
+
*/
|
|
53
|
+
export function deterministicScansEnabled(config) {
|
|
54
|
+
return config?.deterministicScans?.enabled === true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** "A", "B" or null for an id such as "clippy:rightkit-fs". */
|
|
58
|
+
export function scanClassOf(id) {
|
|
59
|
+
const prefix = String(id).split(":")[0];
|
|
60
|
+
if (CLASS_A_SCAN_PREFIXES.includes(prefix)) return SCAN_CLASS.A;
|
|
61
|
+
if (CLASS_B_SCAN_PREFIXES.includes(prefix)) return SCAN_CLASS.B;
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const sha256 = (value) => crypto.createHash("sha256").update(value).digest("hex");
|
|
66
|
+
|
|
67
|
+
// canonicalJson and digestScanSet live in the dependency-free scan-digest.mjs, so credentialed CI code that verifies
|
|
68
|
+
// scan receipts never loads this scanner.
|
|
69
|
+
export { canonicalJson, digestScanSet, requiredScanIds };
|
|
70
|
+
|
|
71
|
+
// ---- gate result cache --------------------------------------------------------------------------------
|
|
72
|
+
// A passing class B scan is reused on a later run only when every input is identical: the key is the sha256 of the
|
|
73
|
+
// canonical JSON of the scan id, its command text, the probed tool version, the environment the scan sets, a digest of the full environment its child inherits and an input
|
|
74
|
+
// digest the caller computes from the gated commit's object ids (bin/rightkit-gate.mjs). Only "pass" rows are stored
|
|
75
|
+
// (never fail, deferred or not-run). Class A scans are cached too, except the live checks whose answer depends on the day
|
|
76
|
+
// (cargo-deny, cargo-audit, pnpm-audit, pnpm-lockfile: the caller's digest returns null for them). A changed input changes the key, so
|
|
77
|
+
// there is no separate invalidation; a corrupt or foreign file is a miss.
|
|
78
|
+
|
|
79
|
+
// The environment names that can change what a build, lint, doc or test proves: compiler and cargo configuration, the toolchain,
|
|
80
|
+
// linkers and C toolchains, sccache, the RightKit/RustRight switches, CI, node and pnpm configuration. Nothing else the child
|
|
81
|
+
// inherits is part of the key: a session id, a socket path, a window handle or a temp directory differs from one process to the
|
|
82
|
+
// next and proves nothing, and keying on the whole environment made every cached result unreadable after a restart (2026-10-22).
|
|
83
|
+
// PATH is deliberately out: which tool runs is bound by the probed tool version in the key. RK_GATE_* (jobs, cache switches) and
|
|
84
|
+
// RUSTRIGHT_QUIET_LEASE (a lease id) do not change a scan's verdict.
|
|
85
|
+
const BUILD_ENV_NAMES = new Set(["CI", "GITHUB_ACTIONS", "CC", "CXX", "AR", "LD", "RANLIB", "RUSTC", "RUSTDOC", "RUSTFLAGS", "RUSTDOCFLAGS", "RUSTC_WRAPPER", "RUSTC_WORKSPACE_WRAPPER",
|
|
86
|
+
"RUSTUP_HOME", "RUSTUP_TOOLCHAIN", "CARGO_HOME", "CARGO", "TARGET", "HOST", "NODE_OPTIONS", "NODE_ENV", "SOURCE_DATE_EPOCH", "MACOSX_DEPLOYMENT_TARGET", "SDKROOT", "DEVELOPER_DIR",
|
|
87
|
+
"VCINSTALLDIR", "LIB", "INCLUDE", "LIBPATH", "CFLAGS", "CXXFLAGS", "LDFLAGS", "CPPFLAGS", "PKG_CONFIG_PATH", "PKG_CONFIG_LIBDIR", "OPENSSL_DIR", "OPENSSL_LIB_DIR", "OPENSSL_INCLUDE_DIR",
|
|
88
|
+
"LIBCLANG_PATH", "LLVM_CONFIG_PATH", "ORT_LIB_LOCATION", "ORT_STRATEGY", "PROTOC", "WEBVIEW2_", "TAURI_"]);
|
|
89
|
+
const BUILD_ENV_PREFIXES = ["CARGO_", "RUST", "SCCACHE_", "RK_", "RIGHTKIT_", "RUSTRIGHT_", "PNPM_", "NPM_CONFIG_", "CMAKE_", "CFLAGS_", "CXXFLAGS_", "OPENSSL_", "ORT_", "ONNX", "TAURI_", "WEBVIEW2_", "WINDOWS_SDK", "MSVC"];
|
|
90
|
+
const BUILD_ENV_EXCLUDED = /^(RK_GATE_|RUSTRIGHT_QUIET_LEASE$)/;
|
|
91
|
+
export const buildEnvName = (name) => {
|
|
92
|
+
const upper = String(name).toUpperCase();
|
|
93
|
+
if (BUILD_ENV_EXCLUDED.test(upper)) return false;
|
|
94
|
+
return BUILD_ENV_NAMES.has(upper) || BUILD_ENV_PREFIXES.some((prefix) => upper.startsWith(prefix));
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** sha256 of the build-relevant part (buildEnvName) of the environment a scan's child receives. Pass the merged `{ ...deps.env, ...scan.env }`. */
|
|
98
|
+
export function inheritedEnvDigest(env = {}) {
|
|
99
|
+
const rows = Object.entries(env).filter(([name, value]) => value !== undefined && value !== null && buildEnvName(name)).map(([name, value]) => [String(name).toUpperCase(), String(value)]);
|
|
100
|
+
rows.sort((x, y) => (x[0] < y[0] ? -1 : x[0] > y[0] ? 1 : 0));
|
|
101
|
+
return sha256(canonicalJson(rows));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The cache key for one scan; `digest` is the caller's input digest, `env` the scan's own environment and `inheritedEnv`
|
|
106
|
+
* the digest of the whole environment the child receives (inheritedEnvDigest). A key without the inherited digest is refused.
|
|
107
|
+
*/
|
|
108
|
+
export function scanCacheKey({ id, command, toolVersion, env = {}, inheritedEnv, digest }) {
|
|
109
|
+
if (typeof inheritedEnv !== "string" || !/^[0-9a-f]{64}$/.test(inheritedEnv)) throw new TypeError("scanCacheKey: inheritedEnv must be the inheritedEnvDigest of the child environment");
|
|
110
|
+
return sha256(canonicalJson({ id: String(id), command: String(command), toolVersion: String(toolVersion), env: { ...env }, inheritedEnv, digest: String(digest) }));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const CACHE_KEY_SHAPE = /^[0-9a-f]{64}$/;
|
|
114
|
+
|
|
115
|
+
/** A cache directory (`<git dir>/rightkit-gate-cache`): { read(key) => entry | null, write(key, row) }. Never throws. */
|
|
116
|
+
export function fileScanCache(dir, { now = () => new Date(), fsApi = fs } = {}) {
|
|
117
|
+
const file = (key) => (CACHE_KEY_SHAPE.test(key) ? path.join(dir, `${key}.json`) : null);
|
|
118
|
+
return {
|
|
119
|
+
read(key) {
|
|
120
|
+
try { const f = file(key); return f ? JSON.parse(fsApi.readFileSync(f, "utf8")) : null; } catch { return null; }
|
|
121
|
+
},
|
|
122
|
+
write(key, row) {
|
|
123
|
+
try {
|
|
124
|
+
const f = file(key); if (!f) return;
|
|
125
|
+
fsApi.mkdirSync(dir, { recursive: true });
|
|
126
|
+
const tmp = `${f}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`;
|
|
127
|
+
fsApi.writeFileSync(tmp, JSON.stringify({ key, at: now().toISOString(), row }));
|
|
128
|
+
fsApi.renameSync(tmp, f);
|
|
129
|
+
} catch { /* a cache that cannot be written is only a miss next time */ }
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---- command builders shared with scripts/ci/gate.mjs and packages/release/local-publish.mjs -------------
|
|
135
|
+
|
|
136
|
+
const packageFlags = (units) => [].concat(units).flatMap((unit) => ["-p", unit]);
|
|
137
|
+
|
|
138
|
+
/** `cargo clippy -p <units> [--message-format=...] --no-deps --all-targets <features> [--locked] -- -D warnings` (argv after `cargo`). */
|
|
139
|
+
export function clippyArgs(units, { features = [], messageFormat, noDeps = true, locked = false } = {}) {
|
|
140
|
+
return ["clippy", ...packageFlags(units), ...(messageFormat ? [`--message-format=${messageFormat}`] : []), ...(noDeps ? ["--no-deps"] : []), "--all-targets", ...features, ...(locked ? ["--locked"] : []), "--", "-D", "warnings"];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** `cargo check -p <unit> [--message-format=...] --all-targets <features> [--locked]`. */
|
|
144
|
+
export function checkArgs(unit, { features = [], messageFormat, locked = false } = {}) {
|
|
145
|
+
return ["check", "-p", unit, ...(messageFormat ? [`--message-format=${messageFormat}`] : []), "--all-targets", ...features, ...(locked ? ["--locked"] : [])];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** `cargo doc -p <unit> --no-deps --all-features --locked`; run it with RUSTDOCFLAGS=DOC_RUSTDOCFLAGS (CI). */
|
|
149
|
+
export function docArgs(unit) { return ["doc", "-p", unit, "--no-deps", "--all-features", "--locked"]; }
|
|
150
|
+
export const DOC_RUSTDOCFLAGS = "-D warnings";
|
|
151
|
+
/** The same check as rustdoc arguments (`cargo rustdoc ... -- -D warnings`), for the broker path. */
|
|
152
|
+
export const DOC_RUSTDOC_ARGS = Object.freeze(["-D", "warnings"]);
|
|
153
|
+
|
|
154
|
+
/** `cargo fmt --check` for one crate, or the whole workspace when `unit` is null. */
|
|
155
|
+
export function fmtCheckArgs(unit = null) { return unit ? ["fmt", "-p", unit, "--check"] : ["fmt", "--all", "--check"]; }
|
|
156
|
+
|
|
157
|
+
/** `cargo metadata` that validates manifests and the lock without resolving anything new. */
|
|
158
|
+
export function metadataArgs({ noDeps = true } = {}) { return ["metadata", ...(noDeps ? ["--no-deps"] : []), "--format-version", "1", "--locked"]; }
|
|
159
|
+
|
|
160
|
+
// ---- plan ---------------------------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
const posix = (p) => p.replaceAll("\\", "/");
|
|
163
|
+
// Crate and package directory names reach argv (and, on Windows, possibly a .cmd shim); accept only
|
|
164
|
+
// plain names. Any other name is refused: it becomes a failing path-policy scan and is never run.
|
|
165
|
+
const SAFE_UNIT_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
166
|
+
const ABSOLUTE_PATH = /^(?:[A-Za-z]:|[\\/])/;
|
|
167
|
+
/** Normalized posix form of a changed path, or { refused } when it is absolute, escapes the root, or is unusable. */
|
|
168
|
+
function containedPath(raw, contentRoot) {
|
|
169
|
+
const text = String(raw);
|
|
170
|
+
if (text === "" || text.includes("\0")) return { refused: "empty or contains a NUL" };
|
|
171
|
+
const unified = posix(text);
|
|
172
|
+
if (ABSOLUTE_PATH.test(unified) || path.isAbsolute(text) || path.win32.isAbsolute(text)) return { refused: "absolute path" };
|
|
173
|
+
const norm = path.posix.normalize(unified);
|
|
174
|
+
if (norm === ".." || norm.startsWith("../")) return { refused: "escapes the content root" };
|
|
175
|
+
const rel = path.relative(path.resolve(contentRoot), path.resolve(contentRoot, norm));
|
|
176
|
+
if (rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) return { refused: "escapes the content root" };
|
|
177
|
+
return { path: norm };
|
|
178
|
+
}
|
|
179
|
+
const PNPM_NO_HOOKS_ENV = Object.freeze({ npm_config_ignore_pnpmfile: "true", npm_config_ignore_scripts: "true", pnpm_config_ignore_pnpmfile: "true", pnpm_config_ignore_scripts: "true" });
|
|
180
|
+
const CONFIG_CRATE_FILE = /(^|\/)Cargo\.(toml|lock)$/;
|
|
181
|
+
const JS_TOOLING_FILE = /(^|\/)(package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml)$/;
|
|
182
|
+
// Root files that change how EVERY crate (or package) builds: a change to one puts every unit in scope,
|
|
183
|
+
// so a dependency or toolchain update never reaches a push without local qualification.
|
|
184
|
+
// `.cargo/audit.toml` is cargo-audit policy (an advisory ignore list), read by no compiler: it is ANCESTOR_DENY_FILE below.
|
|
185
|
+
export const WORKSPACE_RUST_FILE = /^(Cargo\.(toml|lock)|rust-toolchain(\.toml)?|\.?clippy\.toml|\.?rustfmt\.toml|\.cargo\/(?!audit\.toml$).+|vendor\/.+)$/;
|
|
186
|
+
// What cargo, clippy, rustfmt and cargo-deny read from EVERY ancestor directory of a workspace (cargo merges `.cargo/config*` and
|
|
187
|
+
// finds the toolchain file, clippy and rustfmt search upward): a change to one at any ancestor of a workspace, the workspace's own
|
|
188
|
+
// root included, puts that workspace's whole crate set in scope. Patterns are relative to the ancestor directory.
|
|
189
|
+
export const ANCESTOR_RUST_CONFIG_FILE = /^(rust-toolchain(\.toml)?|\.?clippy\.toml|\.?rustfmt\.toml|\.cargo\/(?!audit\.toml$).+)$/;
|
|
190
|
+
// cargo-deny reads deny.toml and cargo-audit reads .cargo/audit.toml from the workspace root and its ancestors, but neither changes
|
|
191
|
+
// compilation: a change anywhere above a workspace runs that workspace's metadata, cargo-deny and cargo-audit scans (the deny scan
|
|
192
|
+
// covers the workspace's whole crate set), not its compile or test scans.
|
|
193
|
+
export const ANCESTOR_DENY_FILE = /^(deny\.toml|\.cargo\/audit\.toml)$/;
|
|
194
|
+
/** The directories whose Rust configuration reaches workspace `ws`: "." (the repository root), every directory between, and `ws` itself. */
|
|
195
|
+
export const ancestorDirsOf = (ws) => {
|
|
196
|
+
const parts = ws === "." ? [] : String(ws).split("/").filter(Boolean);
|
|
197
|
+
return [".", ...parts.map((_, i) => parts.slice(0, i + 1).join("/"))];
|
|
198
|
+
};
|
|
199
|
+
/** True when a changed repo-relative file is Rust configuration of some ancestor directory (or the root) of workspace `ws`. */
|
|
200
|
+
export const ancestorConfigChanged = (ws, changed, pattern = ANCESTOR_RUST_CONFIG_FILE) => ancestorDirsOf(ws).some((dir) => changed.some((f) => {
|
|
201
|
+
const rel = dir === "." ? f : f.startsWith(`${dir}/`) ? f.slice(dir.length + 1) : null;
|
|
202
|
+
return rel !== null && pattern.test(rel);
|
|
203
|
+
}));
|
|
204
|
+
const WORKSPACE_JS_FILE = /^(package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml|\.npmrc)$/;
|
|
205
|
+
const ESLINT_FILES = ["eslint.config.js", "eslint.config.mjs", ".eslintrc.json"];
|
|
206
|
+
const ROOT_LINT_FILES = [...ESLINT_FILES, "biome.json"];
|
|
207
|
+
// Everything scripts/generate-index.mjs reads or writes (its header and loadInventory): crate and package manifests and
|
|
208
|
+
// READMEs, the root Cargo.toml (workspace version), the versions and ownership data, the ownership-availability helper it imports, itself, and its outputs.
|
|
209
|
+
const GENERATOR_INPUT = /^(crates\/[^/]+\/(Cargo\.toml|README\.md)|packages\/[^/]+\/(package\.json|README\.md)|Cargo\.toml|packages\/release\/(rightkit-versions\.json|rightkit-ownership\.json|ownership-availability\.mjs)|scripts\/generate-index\.mjs|INDEX\.md|docs\/(ADOPTION|CAPABILITY-MAP)\.md)$/;
|
|
210
|
+
const PROBES = Object.freeze({
|
|
211
|
+
rustfmt: ["--version"], node: ["--version"], rightkit: ["cargo", "--version"], pnpm: ["--version"],
|
|
212
|
+
actionlint: ["-version"], "right-git": ["--version"], swift: ["--version"],
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
function commandText(d) {
|
|
216
|
+
if (d.internal) return `internal:${d.internal}`;
|
|
217
|
+
const env = Object.keys(d.env ?? {}).sort().map((k) => `${k}=${d.env[k]} `).join("");
|
|
218
|
+
return `${env}${d.tool} ${d.args.join(" ")}`.trim();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The scan plan for a change: derived from the changed files plus the manifests they belong to.
|
|
223
|
+
* `contentRoot` is a clean export of the commit (pure content checks); `liveRoot` is the checkout the
|
|
224
|
+
* managed `rightkit cargo` and `pnpm` run in. A scan whose files did not change is not planned.
|
|
225
|
+
*/
|
|
226
|
+
export function planScans({
|
|
227
|
+
changed = [], contentRoot, liveRoot = contentRoot, exists = fs.existsSync, readFile = (f) => fs.readFileSync(f, "utf8"),
|
|
228
|
+
platform = process.platform, includeTests = true, localCompile = true,
|
|
229
|
+
// Where the quiet test scans (ci/gate.config.json `quietTests`) run: "local" plans them for the quiet lane; "ci" leaves them to the
|
|
230
|
+
// CI shards (one crate per runner, where the port, pipe, desktop and wall-clock conflicts that force the serial lane do not exist)
|
|
231
|
+
// and records a note naming them (Adrian 2026-10-22: compile locally on the warm cache, qualify the conflicting tests on VMs).
|
|
232
|
+
quietTests = "local",
|
|
233
|
+
// The command prefix every cargo scan runs through: `rightkit cargo` on a managed host, plain `cargo` in CI.
|
|
234
|
+
cargoCommand = ["rightkit", "cargo"],
|
|
235
|
+
// Physical containment: a changed path whose real path leaves the real content root (a symlink) is refused.
|
|
236
|
+
realpath = (p) => fs.realpathSync(p),
|
|
237
|
+
// Bytes of a changed file for the input digest (defaults to readFile; the gates pass the raw reader).
|
|
238
|
+
readBytes = readFile,
|
|
239
|
+
// Directory listing, for the every-unit scope a workspace file change brings in.
|
|
240
|
+
listDir = (d) => { try { return fs.readdirSync(d); } catch { return []; } },
|
|
241
|
+
// Per-crate feature sets, shaped like ci/gate.config.json `rust` ({ [crate]: { featureSets, checkFeatureSets } }).
|
|
242
|
+
crateFeatures = {},
|
|
243
|
+
// ci/gate.config.json `npm`: per-package gate settings (`quietTests`), read by the package's unit name (packages/<dir> → rightkit-<dir>).
|
|
244
|
+
packageFeatures = {},
|
|
245
|
+
// Crates a workspace-wide Rust change (Cargo.lock, vendor/) actually reaches, computed by the caller from
|
|
246
|
+
// the lock graph (scripts/ci/select.mjs). null keeps every crate in scope.
|
|
247
|
+
rustScope = null,
|
|
248
|
+
// Repository-specific units for any layout: [{ name, dir, publish? }], dir repo-relative ("." for a root unit). When given they
|
|
249
|
+
// replace the crates/<name> and packages/<name> layout (rightkit-gate). `cargoDir` is the Cargo workspace root the cargo scans run in.
|
|
250
|
+
// A rust unit may name its own workspace: { name, dir, cargoDir, crate, publish? } runs its scans with cwd cargoDir and `-p <crate>`;
|
|
251
|
+
// `name` is then the unit's identity in scan ids and feature config (rightkit-gate names it `<cargoDir>:<crate>` below the root), and
|
|
252
|
+
// `crateFeatures` is read by `name` first, then by `crate`. Units of different workspaces never share a lock, a deny/audit run or a
|
|
253
|
+
// `cargo metadata`; a unit that sets no cargoDir belongs to the global `cargoDir` above.
|
|
254
|
+
rustUnits = null, npmUnits = null, cargoDir = ".",
|
|
255
|
+
// Parsed Cargo.lock graph (lock-graph.mjs parseLock). With it, every workspace crate that depends on a crate in scope and is
|
|
256
|
+
// not itself in scope gets a `check-dependent` scan, as CI's rust-check rows do. null plans none.
|
|
257
|
+
rustLock = null,
|
|
258
|
+
// The same per Cargo workspace: { [cargoDir]: parsedLock }. A workspace without an entry here (and not the global `cargoDir`) plans
|
|
259
|
+
// no reverse dependents.
|
|
260
|
+
rustLocks = null,
|
|
261
|
+
} = {}) {
|
|
262
|
+
if (!contentRoot) throw new TypeError("planScans: contentRoot is required");
|
|
263
|
+
if (!Array.isArray(cargoCommand) || cargoCommand.length === 0 || !["rightkit", "cargo"].includes(cargoCommand[0]) || cargoCommand.slice(1).some((a) => a !== "cargo")) {
|
|
264
|
+
throw new TypeError('planScans: cargoCommand must be ["rightkit", "cargo"] or ["cargo"]');
|
|
265
|
+
}
|
|
266
|
+
const refusals = []; const accepted = [];
|
|
267
|
+
for (const raw of new Set(changed)) {
|
|
268
|
+
const c = containedPath(raw, contentRoot);
|
|
269
|
+
if (c.refused) refusals.push({ raw: String(raw), why: c.refused });
|
|
270
|
+
else accepted.push(c.path);
|
|
271
|
+
}
|
|
272
|
+
const files = [];
|
|
273
|
+
let realRoot = null; let realRootError = null;
|
|
274
|
+
try { realRoot = realpath(contentRoot); } catch (e) { realRootError = e; }
|
|
275
|
+
for (const f of new Set(accepted)) {
|
|
276
|
+
const abs = path.join(contentRoot, f);
|
|
277
|
+
if (!exists(abs)) continue;
|
|
278
|
+
let real = null;
|
|
279
|
+
try { if (!realRoot) throw realRootError ?? new Error("no real content root"); real = realpath(abs); } catch (e) { refusals.push({ raw: f, why: `real path cannot be resolved (${e.message})` }); continue; }
|
|
280
|
+
const rel = path.relative(realRoot, real);
|
|
281
|
+
if (rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) { refusals.push({ raw: f, why: "real path (symlink) leaves the content root" }); continue; }
|
|
282
|
+
files.push(f);
|
|
283
|
+
}
|
|
284
|
+
// Content digest of the input the scans cover: every accepted changed file's bytes plus every refused name.
|
|
285
|
+
const bytesDigest = (f) => { try { return sha256(readBytes(path.join(contentRoot, f))); } catch { return "unreadable"; } };
|
|
286
|
+
const inputDigest = sha256(canonicalJson({ files: [...files].sort().map((f) => [f, bytesDigest(f)]), refused: refusals.map((r) => r.raw).sort() }));
|
|
287
|
+
const under = (prefix) => files.filter((f) => f.startsWith(prefix));
|
|
288
|
+
let scans = []; const notes = [];
|
|
289
|
+
const add = (d) => {
|
|
290
|
+
const entry = { root: "live", env: {}, args: [], ...d };
|
|
291
|
+
entry.command = commandText(entry);
|
|
292
|
+
scans.push(entry);
|
|
293
|
+
};
|
|
294
|
+
const A = SCAN_CLASS.A; const B = SCAN_CLASS.B;
|
|
295
|
+
const refuse = (what, why) => add({ id: `path-policy:${sha256(`${what}\0${why}`).slice(0, 12)}`, class: A, internal: "refuse", detail: `${what}: ${why}; refused, never run` });
|
|
296
|
+
for (const r of refusals) refuse(`changed path ${JSON.stringify(r.raw)}`, r.why);
|
|
297
|
+
// A rust unit's name may be `<cargoDir>:<crate>`; the part that reaches argv (`-p`) is the crate, and the workspace directory is
|
|
298
|
+
// checked as a plain path where the unit is registered.
|
|
299
|
+
const safeUnits = (names, kind) => names.filter((n) => {
|
|
300
|
+
if (SAFE_UNIT_NAME.test(kind === "crate" ? crateOf(n) : n)) return true;
|
|
301
|
+
refuse(`${kind} directory name ${JSON.stringify(n)}`, "not a plain name");
|
|
302
|
+
return false;
|
|
303
|
+
});
|
|
304
|
+
const cleanDir = (d) => { const n = path.posix.normalize(posix(String(d))).replace(/\/+$/, ""); return n === "" ? "." : n; };
|
|
305
|
+
const SAFE_UNIT_DIR = /^(?:\.|[A-Za-z0-9_@+.-]+(?:\/[A-Za-z0-9_@+.-]+)*)$/;
|
|
306
|
+
const plainDir = (d) => !ABSOLUTE_PATH.test(d) && !d.split("/").includes("..") && SAFE_UNIT_DIR.test(d);
|
|
307
|
+
const cargoWs = cleanDir(cargoDir);
|
|
308
|
+
if (!plainDir(cargoWs)) throw new TypeError("planScans: cargoDir must be a plain repository-relative directory");
|
|
309
|
+
const cargoIn = (ws, ...args) => ({ tool: cargoCommand[0], args: [...cargoCommand.slice(1), ...args], ...(ws !== "." ? { cwd: ws } : {}) });
|
|
310
|
+
const unitMap = (units, kind) => {
|
|
311
|
+
if (units === null) return null;
|
|
312
|
+
if (!Array.isArray(units)) throw new TypeError(`planScans: ${kind}Units must be an array of { name, dir }`);
|
|
313
|
+
const map = new Map();
|
|
314
|
+
for (const u of units) {
|
|
315
|
+
if (typeof u?.name !== "string" || typeof u?.dir !== "string") throw new TypeError(`planScans: every ${kind} unit needs a string name and dir`);
|
|
316
|
+
if (map.has(u.name)) throw new TypeError(`planScans: duplicate ${kind} unit ${u.name}`);
|
|
317
|
+
const dir = cleanDir(u.dir);
|
|
318
|
+
if (!plainDir(dir)) { refuse(`${kind} unit ${JSON.stringify(u.name)} directory ${JSON.stringify(u.dir)}`, "not a plain repository-relative path"); continue; }
|
|
319
|
+
const entry = { dir, publish: u.publish !== false };
|
|
320
|
+
if (kind === "rust") {
|
|
321
|
+
if (u.crate !== undefined && typeof u.crate !== "string") throw new TypeError(`planScans: rust unit ${u.name} needs a string crate`);
|
|
322
|
+
entry.crate = u.crate ?? u.name;
|
|
323
|
+
entry.cargoDir = cargoWs;
|
|
324
|
+
if (u.cargoDir !== undefined) {
|
|
325
|
+
if (typeof u.cargoDir !== "string") throw new TypeError(`planScans: rust unit ${u.name} needs a string cargoDir`);
|
|
326
|
+
entry.cargoDir = cleanDir(u.cargoDir);
|
|
327
|
+
if (!plainDir(entry.cargoDir)) { refuse(`rust unit ${JSON.stringify(u.name)} cargoDir ${JSON.stringify(u.cargoDir)}`, "not a plain repository-relative path"); continue; }
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
map.set(u.name, entry);
|
|
331
|
+
}
|
|
332
|
+
return map;
|
|
333
|
+
};
|
|
334
|
+
const rustMap = unitMap(rustUnits, "rust"); const npmMap = unitMap(npmUnits, "npm");
|
|
335
|
+
// Units of an explicit-workspace layout (some unit names its cargoDir) get workspace-suffixed ids for the per-workspace scans.
|
|
336
|
+
const perWorkspaceIds = Array.isArray(rustUnits) && rustUnits.some((u) => u?.cargoDir !== undefined);
|
|
337
|
+
const wsIdSuffix = (ws) => (perWorkspaceIds && ws !== "." ? `:${ws}` : "");
|
|
338
|
+
const wsOf = (n) => (rustMap ? rustMap.get(n).cargoDir : cargoWs);
|
|
339
|
+
const crateOf = (n) => (rustMap?.get(n)?.crate ?? n);
|
|
340
|
+
const featuresOf = (n) => crateFeatures?.[n] ?? crateFeatures?.[crateOf(n)] ?? {};
|
|
341
|
+
const workspaces = rustMap && rustMap.size ? [...new Set([...rustMap.values()].map((u) => u.cargoDir))].sort() : [cargoWs];
|
|
342
|
+
const lockOf = (ws) => (rustLocks?.[ws] instanceof Map ? rustLocks[ws] : ws === cargoWs && rustLock instanceof Map ? rustLock : null);
|
|
343
|
+
const rustDir = (n) => (rustMap ? rustMap.get(n).dir : `crates/${n}`);
|
|
344
|
+
const npmDir = (n) => (npmMap ? npmMap.get(n).dir : `packages/${n}`);
|
|
345
|
+
const inDir = (dir, f) => (dir === "." ? f : `${dir}/${f}`);
|
|
346
|
+
// The unit whose directory holds `f`; the deepest directory wins and a root unit (".") only takes what no other unit does.
|
|
347
|
+
const unitOf = (map, f) => {
|
|
348
|
+
let best = null; let score = -1;
|
|
349
|
+
for (const [name, u] of map) {
|
|
350
|
+
const s = u.dir === "." ? 0 : u.dir.length;
|
|
351
|
+
if ((u.dir === "." || f === u.dir || f.startsWith(`${u.dir}/`)) && s > score) { best = name; score = s; }
|
|
352
|
+
}
|
|
353
|
+
return best;
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
// ---- class A: content checks on the exported commit
|
|
357
|
+
// A vendor/ file keeps upstream formatting only when vendor/upstream-files.json records it and its
|
|
358
|
+
// bytes still match that upstream digest; any other vendor/ .rs (new, patched) is rustfmt-checked.
|
|
359
|
+
let upstream = {};
|
|
360
|
+
try { upstream = JSON.parse(readFile(path.join(contentRoot, "vendor/upstream-files.json"))).files ?? {}; } catch { /* no manifest: nothing exempt */ }
|
|
361
|
+
const verbatimUpstream = (f) => f.startsWith("vendor/") && typeof upstream[f] === "string" && bytesDigest(f) === upstream[f];
|
|
362
|
+
const rustFiles = files.filter((f) => f.endsWith(".rs") && !verbatimUpstream(f));
|
|
363
|
+
const byEdition = new Map();
|
|
364
|
+
for (const f of rustFiles) {
|
|
365
|
+
let dir = path.dirname(path.join(contentRoot, f));
|
|
366
|
+
let edition = "2021";
|
|
367
|
+
while (dir.startsWith(path.normalize(contentRoot))) {
|
|
368
|
+
const manifest = path.join(dir, "Cargo.toml");
|
|
369
|
+
if (exists(manifest)) {
|
|
370
|
+
const m = readFile(manifest).match(/^edition\s*=\s*"(\d{4})"/m);
|
|
371
|
+
if (m) edition = m[1];
|
|
372
|
+
break;
|
|
373
|
+
}
|
|
374
|
+
const parent = path.dirname(dir);
|
|
375
|
+
if (parent === dir) break;
|
|
376
|
+
dir = parent;
|
|
377
|
+
}
|
|
378
|
+
byEdition.set(edition, [...(byEdition.get(edition) ?? []), f]);
|
|
379
|
+
}
|
|
380
|
+
for (const [edition, group] of [...byEdition].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
|
|
381
|
+
add({ id: `rustfmt:${edition}`, class: A, tool: "rustfmt", args: ["--check", "--edition", edition, ...group.sort()], root: "content" });
|
|
382
|
+
}
|
|
383
|
+
for (const f of files.filter((f) => /\.(mjs|cjs|js)$/.test(f) && !f.includes("node_modules")).sort()) {
|
|
384
|
+
add({ id: `node-check:${f}`, class: A, tool: "node", args: ["--check", f], root: "content" });
|
|
385
|
+
}
|
|
386
|
+
const jsonFiles = files.filter((f) => f.endsWith(".json") && !f.includes("node_modules")).sort();
|
|
387
|
+
if (jsonFiles.length) add({ id: "json-parse", class: A, internal: "json-parse", files: jsonFiles });
|
|
388
|
+
const tomlFiles = files.filter((f) => f.endsWith(".toml") && !f.includes("node_modules")).sort();
|
|
389
|
+
if (tomlFiles.length) add({ id: "toml-parse", class: A, internal: "toml-parse", files: tomlFiles });
|
|
390
|
+
// The strict workflow YAML parser covers the subset GitHub workflows and actions use; other YAML is not parsed with it.
|
|
391
|
+
const yamlFiles = files.filter((f) => /^\.github\/.+\.ya?ml$/.test(f)).sort();
|
|
392
|
+
if (yamlFiles.length) add({ id: "yaml-parse", class: A, internal: "yaml-parse", files: yamlFiles });
|
|
393
|
+
|
|
394
|
+
if (exists(path.join(contentRoot, "packages/release/rightkit-ownership.json")) && exists(path.join(contentRoot, "crates/rightkit-ownership/catalog/ownership-catalog.json"))) {
|
|
395
|
+
add({ id: "ownership-catalog", class: A, internal: "ownership-catalog", files: ["packages/release/rightkit-ownership.json", "crates/rightkit-ownership/catalog/ownership-catalog.json"] });
|
|
396
|
+
}
|
|
397
|
+
const wsRel = (ws, f) => (ws === "." ? f : f.startsWith(`${ws}/`) ? f.slice(ws.length + 1) : null);
|
|
398
|
+
// Workspaces whose own root files (lock, manifest, toolchain, .cargo, vendor) changed: every unit of such a workspace is in scope.
|
|
399
|
+
// A workspace is also wide when Rust configuration changed in ANY ancestor directory of it (a repository-level .cargo/config.toml,
|
|
400
|
+
// an intermediate apps/.cargo, a root rust-toolchain.toml, clippy.toml, rustfmt.toml or deny.toml reaches every nested workspace).
|
|
401
|
+
const wideWs = new Set(workspaces.filter((ws) => ancestorConfigChanged(ws, files) || files.some((f) => { const r = wsRel(ws, f); return r !== null && WORKSPACE_RUST_FILE.test(r); })));
|
|
402
|
+
const jsWide = files.some((f) => WORKSPACE_JS_FILE.test(f));
|
|
403
|
+
const wideCrates = wideWs.size ? (rustMap ? [...rustMap.keys()].filter((n) => wideWs.has(wsOf(n))) : listDir(path.join(contentRoot, "crates"))).filter((n) => !rustScope || rustScope.has(n)) : [];
|
|
404
|
+
if (wideWs.size && rustScope) notes.push(`workspace Rust change reaches ${wideCrates.length} crate(s) through the lock graph`);
|
|
405
|
+
const crateNames = safeUnits([...new Set([...(rustMap ? files.map((f) => unitOf(rustMap, f)).filter(Boolean) : under("crates/").map((f) => f.split("/")[1])), ...wideCrates])].filter((n) => exists(path.join(contentRoot, rustDir(n), "Cargo.toml"))).sort(), "crate");
|
|
406
|
+
// The workspace a changed file belongs to is the deepest one that holds it (a root workspace holds only what no nested one does).
|
|
407
|
+
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; };
|
|
408
|
+
const configWs = new Set();
|
|
409
|
+
for (const f of files) {
|
|
410
|
+
const isConfig = CONFIG_CRATE_FILE.test(f); const ws = wsOfFile(f);
|
|
411
|
+
if (ws !== null) { if (isConfig || ANCESTOR_DENY_FILE.test(wsRel(ws, f) ?? "")) configWs.add(ws); }
|
|
412
|
+
else if (isConfig && workspaces.length === 1) configWs.add(workspaces[0]);
|
|
413
|
+
}
|
|
414
|
+
// Ancestor configuration is configuration of the workspace too: its metadata, cargo-deny and cargo-audit scans run as well.
|
|
415
|
+
for (const ws of workspaces) if (ancestorConfigChanged(ws, files) || ancestorConfigChanged(ws, files, ANCESTOR_DENY_FILE)) configWs.add(ws);
|
|
416
|
+
const rustConfigChanged = configWs.size > 0;
|
|
417
|
+
const rustTouched = crateNames.length > 0 || rustConfigChanged;
|
|
418
|
+
const touchedWs = [...new Set([...crateNames.map(wsOf), ...configWs])].sort();
|
|
419
|
+
// A root npm package (dir ".") is every file's last resort, so a file inside a non-root Rust unit does not trigger it.
|
|
420
|
+
const npmOwner = (f) => {
|
|
421
|
+
const n = unitOf(npmMap, f);
|
|
422
|
+
if (n !== null && npmMap.get(n).dir === "." && rustMap) { const r = unitOf(rustMap, f); if (r !== null && rustMap.get(r).dir !== ".") return null; }
|
|
423
|
+
return n;
|
|
424
|
+
};
|
|
425
|
+
const pkgNames = safeUnits([...new Set([...(npmMap ? files.map(npmOwner).filter(Boolean) : under("packages/").map((f) => f.split("/")[1])), ...(jsWide ? (npmMap ? [...npmMap.keys()] : listDir(path.join(contentRoot, "packages"))) : [])])].filter((n) => exists(path.join(contentRoot, npmDir(n), "package.json"))).sort(), "package");
|
|
426
|
+
const jsTouched = pkgNames.length > 0 || files.some((f) => JS_TOOLING_FILE.test(f));
|
|
427
|
+
|
|
428
|
+
// Explicit files: the exported commit has no .git for actionlint's project discovery.
|
|
429
|
+
const workflowFiles = files.filter((f) => /^\.github\/workflows\/[^/]+\.ya?ml$/.test(f)).sort();
|
|
430
|
+
const actionlintConfig = exists(path.join(contentRoot, ".github/actionlint.yaml")) ? ["-config-file", ".github/actionlint.yaml"] : [];
|
|
431
|
+
if (workflowFiles.length) add({ id: "actionlint", class: A, tool: "actionlint", args: [...actionlintConfig, ...workflowFiles], root: "content" });
|
|
432
|
+
// No right-git drift scan: the one workflow right-git renders here, ci.yml, is hand-owned (its header;
|
|
433
|
+
// docs/CI.md) and held by scripts/ci/ci-scans-contract.mjs instead, so drift is always reported by design.
|
|
434
|
+
|
|
435
|
+
// Exceptions expire by date, not by change: checked on every run, whatever changed.
|
|
436
|
+
if (exists(path.join(contentRoot, "ci/advisory-exceptions.json"))) add({ id: "advisory-exceptions", class: A, internal: "advisory-exceptions" });
|
|
437
|
+
if (rustTouched) {
|
|
438
|
+
for (const ws of touchedWs) add({ id: `cargo-metadata${wsIdSuffix(ws)}`, class: A, ...cargoIn(ws, ...metadataArgs()) });
|
|
439
|
+
for (const name of crateNames.filter((n) => rustMap?.get(n).publish !== false)) add({ id: rustScanId.packageList(name), class: A, ...cargoIn(wsOf(name), "package", "-p", crateOf(name), "--list", "--allow-dirty", "--locked") });
|
|
440
|
+
}
|
|
441
|
+
for (const ws of [...configWs].sort()) {
|
|
442
|
+
add({ id: `cargo-deny${wsIdSuffix(ws)}`, class: A, ...cargoIn(ws, "deny", "check") });
|
|
443
|
+
add({ id: `cargo-audit${wsIdSuffix(ws)}`, class: A, ...cargoIn(ws, "audit") });
|
|
444
|
+
}
|
|
445
|
+
if (jsTouched) {
|
|
446
|
+
// Class A runs no repository hooks: `install` accepts --ignore-pnpmfile and --ignore-scripts; `audit` accepts
|
|
447
|
+
// neither flag, so every class A pnpm scan also sets the same two settings through the environment.
|
|
448
|
+
add({ id: "pnpm-lockfile", class: A, tool: "pnpm", args: ["install", "--frozen-lockfile", "--lockfile-only", "--ignore-pnpmfile", "--ignore-scripts"], env: PNPM_NO_HOOKS_ENV, root: "content" });
|
|
449
|
+
add({ id: "pnpm-audit", class: A, tool: "pnpm", args: ["audit", "--prod", "--audit-level", "high"], env: PNPM_NO_HOOKS_ENV, root: "content" });
|
|
450
|
+
}
|
|
451
|
+
const crateDirs = crateNames.map(rustDir); const pkgDirs = pkgNames.map(npmDir);
|
|
452
|
+
const withChangelog = crateNames.filter((n) => exists(path.join(contentRoot, rustDir(n), "CHANGELOG.md")) && exists(path.join(contentRoot, rustDir(n), "Cargo.toml")));
|
|
453
|
+
for (const name of withChangelog) add({ id: rustScanId.changelog(name), class: A, internal: "changelog-version", files: [inDir(rustDir(name), "Cargo.toml"), inDir(rustDir(name), "CHANGELOG.md")] });
|
|
454
|
+
if (crateDirs.length || pkgDirs.length) {
|
|
455
|
+
add({ id: "license", class: A, internal: "license", files: [...crateDirs.map((d) => inDir(d, "Cargo.toml")), ...pkgDirs.map((d) => inDir(d, "package.json"))] });
|
|
456
|
+
add({ id: "secret-scan", class: A, internal: "secret-scan", dirs: [...crateDirs, ...pkgDirs] });
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ---- class B: needs the compiler or runs tests or repository code
|
|
460
|
+
// The docs generator is code. It runs only from the TRUSTED checkout (liveRoot), with the exported commit
|
|
461
|
+
// handed to it as data (`--root`); the content root's own copy is never executed. When no separate
|
|
462
|
+
// trusted checkout exists (liveRoot is contentRoot) or it lacks the generator, the scan is marked as
|
|
463
|
+
// requiring a trusted-root run: deferred (never a pass), not run from submitted code.
|
|
464
|
+
// Planned only when a changed file is one of the generator's inputs (GENERATOR_INPUT).
|
|
465
|
+
if (exists(path.join(contentRoot, "scripts/generate-index.mjs")) && files.some((f) => GENERATOR_INPUT.test(f))) {
|
|
466
|
+
const trusted = path.resolve(liveRoot) !== path.resolve(contentRoot) && exists(path.join(liveRoot, "scripts/generate-index.mjs"));
|
|
467
|
+
add({
|
|
468
|
+
id: "generated-docs", class: B, tool: "node", args: ["scripts/generate-index.mjs", "--check", "--root", "{contentRoot}"],
|
|
469
|
+
...(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" }),
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
for (const name of crateNames) {
|
|
473
|
+
// Feature sets exactly as CI's gate runs them (scripts/ci/gate.mjs, ci/gate.config.json rust.<crate>):
|
|
474
|
+
// clippy per `featureSets` (default features when absent); check per `checkFeatureSets` (default
|
|
475
|
+
// --all-features), none when a lint set already uses --all-features. Some crates' features cannot
|
|
476
|
+
// all be on together (rightkit-db sqlcipher vs sqlcipher-system; qa-driver wv2 generations).
|
|
477
|
+
const own = featuresOf(name);
|
|
478
|
+
const { lintSets, defaultCheck, checkSets } = rustCratePlan(own);
|
|
479
|
+
const ws = wsOf(name); const crate = crateOf(name);
|
|
480
|
+
// `cargo clippy --all-targets` type-checks exactly what `cargo check --all-targets` does: the default
|
|
481
|
+
// check runs only when no clippy set covers default features.
|
|
482
|
+
if (defaultCheck) add({ id: rustScanId.check(name), class: B, ...cargoIn(ws, ...checkArgs(crate, { locked: true })) });
|
|
483
|
+
checkSets.forEach((set, i) => add({ id: rustScanId.checkFeatures(name, i), class: B, ...cargoIn(ws, ...checkArgs(crate, { features: set, locked: true })) }));
|
|
484
|
+
lintSets.forEach((set, i) => add({ id: rustScanId.clippy(name, lintSets.length, i), class: B, ...cargoIn(ws, ...clippyArgs(crate, { features: set, locked: true })) }));
|
|
485
|
+
// rustdoc flags go after `--` for this crate's library only: the broker owns RUSTDOCFLAGS and refuses
|
|
486
|
+
// a request that sets it. A crate with no library has no API docs to check.
|
|
487
|
+
if (exists(path.join(contentRoot, rustDir(name), "src", "lib.rs"))) {
|
|
488
|
+
add({ id: rustScanId.doc(name), class: B, ...cargoIn(ws, "rustdoc", "-p", crate, "--lib", ...lintSets[0], "--locked", "--", ...DOC_RUSTDOC_ARGS) });
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
// Reverse dependents: a workspace crate that is not in scope but whose lock closure reaches one that is must still compile.
|
|
492
|
+
// A unit only depends on units of its own Cargo workspace (its own lock), so each workspace is judged by its own lock.
|
|
493
|
+
const inScope = new Set(crateNames);
|
|
494
|
+
for (const ws of [...new Set(crateNames.map(wsOf))].sort()) {
|
|
495
|
+
const lock = lockOf(ws);
|
|
496
|
+
if (!lock) continue;
|
|
497
|
+
const memo = new Map();
|
|
498
|
+
const scoped = crateNames.filter((n) => wsOf(n) === ws).map(crateOf);
|
|
499
|
+
const workspace = (rustMap ? [...rustMap.keys()] : listDir(path.join(contentRoot, "crates"))).filter((n) => wsOf(n) === ws);
|
|
500
|
+
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");
|
|
501
|
+
for (const name of dependents) add({ id: `check-dependent:${name}`, class: B, ...cargoIn(ws, ...checkArgs(crateOf(name), { locked: true })) });
|
|
502
|
+
}
|
|
503
|
+
const rootLint = ROOT_LINT_FILES.some((f) => exists(path.join(liveRoot, f)));
|
|
504
|
+
for (const name of pkgNames) {
|
|
505
|
+
const dir = npmDir(name);
|
|
506
|
+
if (exists(path.join(liveRoot, dir, "tsconfig.json"))) add({ id: `tsc:${name}`, class: B, tool: "pnpm", args: ["exec", "tsc", "--noEmit", "-p", "tsconfig.json"], cwd: dir });
|
|
507
|
+
if (rootLint || ESLINT_FILES.some((f) => exists(path.join(liveRoot, dir, f)))) add({ id: `eslint:${name}`, class: B, tool: "pnpm", args: ["exec", "eslint", "."], cwd: dir });
|
|
508
|
+
}
|
|
509
|
+
if (files.some((f) => f.startsWith("swift/") || f === "Package.swift")) {
|
|
510
|
+
if (platform === "darwin") add({ id: "swift-build", class: B, tool: "swift", args: ["build"] });
|
|
511
|
+
else notes.push("swift files changed; `swift build` is required on the Mac host before its push");
|
|
512
|
+
}
|
|
513
|
+
if (includeTests) {
|
|
514
|
+
const deferredToCi = [];
|
|
515
|
+
// Tests per declared feature set, serially too where the crate asks for it, as CI's gate runs them. `quietTests: true` marks
|
|
516
|
+
// every test scan of the crate for the quiet lane (laneOf): run alone, under the broker's quiet lease.
|
|
517
|
+
for (const name of crateNames) {
|
|
518
|
+
const own = featuresOf(name);
|
|
519
|
+
const sets = rustCratePlan(own).lintSets;
|
|
520
|
+
const quiet = own.quietTests === true ? { quiet: true } : {};
|
|
521
|
+
if (own.quietTests === true && quietTests === "ci") {
|
|
522
|
+
deferredToCi.push(...sets.map((set, i) => rustScanId.testRust(name, sets.length, i)), ...(own.alsoSerial ? sets.map((set, i) => rustScanId.testRustSerial(name, sets.length, i)) : []));
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
525
|
+
sets.forEach((set, i) => {
|
|
526
|
+
add({ id: rustScanId.testRust(name, sets.length, i), class: B, ...quiet, ...cargoIn(wsOf(name), "test", "-p", crateOf(name), ...set, "--locked", "--no-fail-fast") });
|
|
527
|
+
if (own.alsoSerial) add({ id: rustScanId.testRustSerial(name, sets.length, i), class: B, ...quiet, ...cargoIn(wsOf(name), "test", "-p", crateOf(name), ...set, "--locked", "--no-fail-fast", "--", "--test-threads=1") });
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
for (const name of pkgNames) {
|
|
531
|
+
let manifest = null;
|
|
532
|
+
try { manifest = JSON.parse(readFile(path.join(liveRoot, npmDir(name), "package.json"))); } catch { /* unreadable manifest: no test row */ }
|
|
533
|
+
if (!manifest?.scripts?.test) continue;
|
|
534
|
+
// `npm.<package>.quietTests: true` (ci/gate.config.json) marks a package whose tests are wall-clock bound (vitest timeouts,
|
|
535
|
+
// Chrome for Testing shutdown): under "ci" they ride the CI shards, under "local" the quiet lane, never the 4-wide test lane.
|
|
536
|
+
const own = packageFeatures?.[name] ?? {};
|
|
537
|
+
if (own.quietTests === true && quietTests === "ci") { deferredToCi.push(`test:npm:${name}`); continue; }
|
|
538
|
+
// A gate proves the repository's own content: sibling checkouts beside it (RightKit's suite-contract tests read the app
|
|
539
|
+
// repositories when present) are not inputs, so the package tests run as an isolated checkout, as they do in CI.
|
|
540
|
+
add({ id: `test:npm:${name}`, class: B, ...(own.quietTests === true ? { quiet: true } : {}), tool: "pnpm", args: ["--dir", npmDir(name), "run", "test"], env: { RIGHTKIT_ISOLATED_CHECKOUT: "1" } });
|
|
541
|
+
}
|
|
542
|
+
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(", ")}`);
|
|
543
|
+
// scripts/ci tests run when scripts/, ci/ or .github/ changed, or a file the scripts/ci sources reference (imports and
|
|
544
|
+
// relative path literals, followed through packages/*.mjs) changed. Where unreadable, they run.
|
|
545
|
+
const ciDir = exists(path.join(liveRoot, "scripts/ci"));
|
|
546
|
+
const scriptsCiTouched = () => {
|
|
547
|
+
if (files.some((f) => /^(scripts|ci|\.github)\//.test(f))) return true;
|
|
548
|
+
try {
|
|
549
|
+
const sources = listDir(path.join(liveRoot, "scripts/ci")).filter((n) => /\.mjs$/.test(n));
|
|
550
|
+
if (!sources.length) return true;
|
|
551
|
+
const inputs = new Set(); const seen = new Set();
|
|
552
|
+
const visit = (rel) => {
|
|
553
|
+
if (seen.has(rel)) return;
|
|
554
|
+
seen.add(rel);
|
|
555
|
+
let text; try { text = readFile(path.join(liveRoot, rel)); } catch { return; }
|
|
556
|
+
for (const m of text.matchAll(/["'`]((?:\.\.?\/)+[^"'`\s]+)["'`]/g)) {
|
|
557
|
+
const target = path.posix.normalize(path.posix.join(path.posix.dirname(rel), m[1])).replace(/\/+$/, "");
|
|
558
|
+
if (!target || target === "." || target.startsWith("..")) continue;
|
|
559
|
+
inputs.add(target);
|
|
560
|
+
if (target.startsWith("packages/") && target.endsWith(".mjs")) visit(target);
|
|
561
|
+
}
|
|
562
|
+
};
|
|
563
|
+
for (const n of sources) visit(`scripts/ci/${n}`);
|
|
564
|
+
return files.some((f) => [...inputs].some((i) => f === i || f.startsWith(`${i}/`)));
|
|
565
|
+
} catch { return true; }
|
|
566
|
+
};
|
|
567
|
+
if (ciDir ? scriptsCiTouched() : (!(rustMap || npmMap) && files.some((f) => f.startsWith("scripts/")))) {
|
|
568
|
+
// The suite is the exported commit's test files, named explicitly: an untracked WIP test in the live
|
|
569
|
+
// checkout is never run and can neither block nor pass a push.
|
|
570
|
+
const suite = listDir(path.join(contentRoot, "scripts", "ci")).filter((n) => n.endsWith(".test.mjs")).sort().map((n) => `scripts/ci/${n}`);
|
|
571
|
+
add({ id: "test:scripts-ci", class: B, tool: "node", args: ["--test", ...(suite.length ? suite : ["scripts/ci/*.test.mjs"])] });
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Without a local compile grant the managed broker path is closed: every broker scan (class A
|
|
576
|
+
// deny/audit/package list included) stays in the plan marked `deferred`, never dropped, never a pass
|
|
577
|
+
// and never run outside the broker. A local gate treats any deferral as a failure (#888).
|
|
578
|
+
const deferred = [];
|
|
579
|
+
if (!localCompile) {
|
|
580
|
+
scans = scans.map((x) => {
|
|
581
|
+
if (x.tool !== "rightkit" && !(x.tool === "cargo" && cargoCommand.length === 1)) return x;
|
|
582
|
+
deferred.push(x.id);
|
|
583
|
+
return { ...x, deferred: "no local compile grant; the broker refuses this scan" };
|
|
584
|
+
});
|
|
585
|
+
if (deferred.length) notes.push(`deferred, not passed (no local compile grant): ${deferred.join(", ")}`);
|
|
586
|
+
}
|
|
587
|
+
const ids = scans.map((s) => s.id);
|
|
588
|
+
const dup = ids.find((id, i) => ids.indexOf(id) !== i);
|
|
589
|
+
if (dup) throw new Error(`planScans: duplicate scan id ${dup}`);
|
|
590
|
+
const classDigest = (cls) => digestScanSet(scans.filter((s) => s.class === cls));
|
|
591
|
+
return {
|
|
592
|
+
schema: SCAN_RESULT_SCHEMA, roots: { contentRoot, liveRoot }, changed: files, inputDigest, scans, notes, deferred,
|
|
593
|
+
scanSetDigest: digestScanSet(scans), classDigests: { A: classDigest(A), B: classDigest(B) },
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// ---- runner -------------------------------------------------------------------------------------------
|
|
598
|
+
|
|
599
|
+
// An argument that may reach a .cmd/.bat shim (which cmd.exe parses) must be plain; anything else is rejected, never escaped.
|
|
600
|
+
const SAFE_SHIM_ARG = /^[A-Za-z0-9_.,:=@+\/\\-]+$/;
|
|
601
|
+
const SAFE_SHIM_PATH = /^[A-Za-z0-9_.,:=@+ \/\\()-]+$/;
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* How to start `command` without a shell. POSIX: as given. Windows: resolve it on PATH here (never the
|
|
605
|
+
* current directory), prefer .exe/.com, run an npm-style pnpm.cmd through node and its pnpm.cjs, and
|
|
606
|
+
* run any other .cmd/.bat shim only when its path and every argument pass a strict allowlist.
|
|
607
|
+
* Returns { file, args, verbatim } or { error }.
|
|
608
|
+
*/
|
|
609
|
+
export function resolveInvocation(command, args, { platform = process.platform, env = process.env, exists = fs.existsSync, execPath = process.execPath } = {}) {
|
|
610
|
+
if (platform !== "win32") return { file: command, args, verbatim: false };
|
|
611
|
+
const hasExt = /\.[A-Za-z0-9]{1,4}$/.test(command);
|
|
612
|
+
const pathKey = Object.keys(env).find((k) => k.toLowerCase() === "path");
|
|
613
|
+
const dirs = /[\\/]/.test(command) ? [""] : String(env[pathKey] ?? "").split(";").filter(Boolean);
|
|
614
|
+
const exts = String(env.PATHEXT || ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean).map((e) => e.toLowerCase());
|
|
615
|
+
let found = null;
|
|
616
|
+
for (const dir of dirs) {
|
|
617
|
+
const base = dir ? path.win32.join(dir, command) : command;
|
|
618
|
+
for (const ext of hasExt ? [""] : exts) if (!found && exists(base + ext)) found = base + ext;
|
|
619
|
+
}
|
|
620
|
+
if (!found) return { error: Object.assign(new Error(`spawn ${command} ENOENT`), { code: "ENOENT" }) };
|
|
621
|
+
if (!/\.(cmd|bat)$/i.test(found)) return { file: found, args, verbatim: false };
|
|
622
|
+
const cjs = path.win32.join(path.win32.dirname(found), "node_modules", "pnpm", "bin", "pnpm.cjs");
|
|
623
|
+
if (/^pnpm\.cmd$/i.test(path.win32.basename(found)) && exists(cjs)) return { file: execPath, args: [cjs, ...args], verbatim: false };
|
|
624
|
+
const bad = args.find((a) => !SAFE_SHIM_ARG.test(a));
|
|
625
|
+
if (bad !== undefined) return { error: new Error(`refusing to pass ${JSON.stringify(bad)} to the shell shim ${found}`) };
|
|
626
|
+
if (!SAFE_SHIM_PATH.test(found)) return { error: new Error(`refusing to run shell shim ${found}: unsafe path`) };
|
|
627
|
+
// Outer quotes around the whole line: with /s cmd strips exactly one leading and one trailing quote.
|
|
628
|
+
return { file: env.ComSpec || "cmd.exe", args: ["/d", "/s", "/c", `""${found}"${args.map((a) => ` ${a}`).join("")}"`], verbatim: true };
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/** Default spawn: synchronous, no console window, never `shell: true`. */
|
|
632
|
+
export function defaultSpawn(command, args, options = {}) {
|
|
633
|
+
const inv = resolveInvocation(command, args, { env: options.env ?? process.env });
|
|
634
|
+
if (inv.error) return { status: null, stdout: "", stderr: "", error: inv.error };
|
|
635
|
+
return spawnSync(inv.file, inv.args, { encoding: "utf8", windowsHide: true, maxBuffer: 64 * 1024 * 1024, windowsVerbatimArguments: inv.verbatim, ...options, shell: false });
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const OUTPUT_KEEP = 4 * 1024 * 1024;
|
|
639
|
+
/** 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). */
|
|
640
|
+
export function killProcessTree(child, { platform = process.platform, run = spawnSync, kill = (pid, sig) => process.kill(pid, sig) } = {}) {
|
|
641
|
+
if (!child?.pid) return;
|
|
642
|
+
if (platform === "win32") {
|
|
643
|
+
try { run("taskkill", ["/T", "/F", "/PID", String(child.pid)], { windowsHide: true, stdio: "ignore" }); } catch { /* already gone */ }
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
try { kill(-child.pid, "SIGKILL"); } catch { try { child.kill("SIGKILL"); } catch { /* already gone */ } }
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* The same invocation as defaultSpawn without blocking, so scans can run side by side. Keeps the last 4 MiB of each stream.
|
|
651
|
+
* With `options.signal` (an AbortSignal), an abort kills the whole process tree and the result carries `cancelled: true`.
|
|
652
|
+
*/
|
|
653
|
+
export function defaultSpawnAsync(command, args, options = {}) {
|
|
654
|
+
const { signal, ...rest } = options;
|
|
655
|
+
const inv = resolveInvocation(command, args, { env: rest.env ?? process.env });
|
|
656
|
+
if (inv.error) return Promise.resolve({ status: null, stdout: "", stderr: "", error: inv.error });
|
|
657
|
+
if (signal?.aborted) return Promise.resolve({ status: null, stdout: "", stderr: "", error: new Error("cancelled"), cancelled: true });
|
|
658
|
+
return new Promise((resolve) => {
|
|
659
|
+
let stdout = ""; let stderr = ""; let error = null;
|
|
660
|
+
const keep = (buf, chunk) => { const next = buf + chunk; return next.length > OUTPUT_KEEP ? next.slice(-OUTPUT_KEEP) : next; };
|
|
661
|
+
let child;
|
|
662
|
+
try {
|
|
663
|
+
child = spawnChild(inv.file, inv.args, { windowsHide: true, windowsVerbatimArguments: inv.verbatim, ...(signal && process.platform !== "win32" ? { detached: true } : {}), ...rest, shell: false, stdio: ["ignore", "pipe", "pipe"] });
|
|
664
|
+
} catch (e) { resolve({ status: null, stdout: "", stderr: "", error: e }); return; }
|
|
665
|
+
const onAbort = () => killProcessTree(child);
|
|
666
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
667
|
+
child.stdout.setEncoding("utf8"); child.stderr.setEncoding("utf8");
|
|
668
|
+
child.stdout.on("data", (c) => { stdout = keep(stdout, c); });
|
|
669
|
+
child.stderr.on("data", (c) => { stderr = keep(stderr, c); });
|
|
670
|
+
child.on("error", (e) => { error = e; });
|
|
671
|
+
child.on("close", (status) => {
|
|
672
|
+
signal?.removeEventListener("abort", onAbort);
|
|
673
|
+
const cancelled = signal?.aborted === true;
|
|
674
|
+
resolve({ status, stdout, stderr, ...(error ? { error } : cancelled ? { error: new Error("cancelled") } : {}), ...(cancelled ? { cancelled: true } : {}) });
|
|
675
|
+
});
|
|
676
|
+
if (signal?.aborted) onAbort();
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// Lanes for concurrent class B runs. The broker admits check and clippy side by side; rustdoc and
|
|
681
|
+
// package are serialized by the broker anyway. Tests and repository code run after the compile lanes drain, `testJobs` at a
|
|
682
|
+
// time; a test scan marked `quiet` (ci/gate.config.json `rust.<crate>.quietTests`: the crate's tests share ports, devices,
|
|
683
|
+
// 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
|
|
684
|
+
// lease when the gate can take one (runScans `quietLane`).
|
|
685
|
+
export function laneOf(scan) {
|
|
686
|
+
if (/^(clippy|check|check-features|check-dependent):/.test(scan.id)) return "compile";
|
|
687
|
+
if (/^doc:/.test(scan.id)) return "doc";
|
|
688
|
+
return scan.quiet === true ? "quiet" : "tests";
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// The lines a reader needs from a failed scan: every line that names a failure (a failed test, a panic, a compiler or tool error,
|
|
692
|
+
// cargo test's `failures:` block header, pnpm's ERR_ codes), at most twelve of them, then the last four lines of stdout and of
|
|
693
|
+
// stderr. cargo test writes results to stdout and "Running ..." to stderr, so a plain tail showed only harness chatter.
|
|
694
|
+
const FAILURE_LINE = /(^|\s)(FAILED|panicked at|error(\[E\d+\])?:|error\[|^failures:|ERR_PNPM_\w+|Error:|assertion `?left|^thread '.*' panicked)/;
|
|
695
|
+
export function failureDetail(r) {
|
|
696
|
+
const lines = (text) => String(text ?? "").split(/\r?\n/).map((l) => l.trimEnd()).filter((l) => l.trim());
|
|
697
|
+
const out = lines(r?.stdout); const err = lines(r?.stderr);
|
|
698
|
+
const named = [...out, ...err].filter((l) => FAILURE_LINE.test(l)).slice(0, 12);
|
|
699
|
+
const ends = [...out.slice(-4), ...err.slice(-4)];
|
|
700
|
+
return [...new Set([...named, ...ends])].join(" | ");
|
|
701
|
+
}
|
|
702
|
+
const tail = failureDetail;
|
|
703
|
+
|
|
704
|
+
/** rustfmt: $RUSTFMT, else the newest installed rustup toolchain, else PATH (null when none). */
|
|
705
|
+
export function findRustfmt({ env = process.env, exists = fs.existsSync, readdir = fs.readdirSync, platform = process.platform, home = os.homedir(), spawn = defaultSpawn } = {}) {
|
|
706
|
+
if (env.RUSTFMT && exists(env.RUSTFMT)) return env.RUSTFMT;
|
|
707
|
+
const exe = platform === "win32" ? "rustfmt.exe" : "rustfmt";
|
|
708
|
+
const toolchains = path.join(env.RUSTUP_HOME || path.join(home, ".rustup"), "toolchains");
|
|
709
|
+
if (exists(toolchains)) {
|
|
710
|
+
const found = readdir(toolchains)
|
|
711
|
+
.map((d) => ({ d, v: (d.match(/^(\d+)\.(\d+)(?:\.(\d+))?-/) || []).slice(1).map((n) => Number(n || 0)) }))
|
|
712
|
+
.filter((t) => t.v.length && exists(path.join(toolchains, t.d, "bin", exe)))
|
|
713
|
+
.sort((a, b) => b.v[0] - a.v[0] || b.v[1] - a.v[1] || b.v[2] - a.v[2]);
|
|
714
|
+
if (found.length) return path.join(toolchains, found[0].d, "bin", exe);
|
|
715
|
+
}
|
|
716
|
+
const which = spawn(platform === "win32" ? "where" : "which", [exe]);
|
|
717
|
+
return which && which.status === 0 ? String(which.stdout).split(/\r?\n/)[0].trim() : null;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
async function defaultLoadToml() {
|
|
721
|
+
const mod = await import("smol-toml");
|
|
722
|
+
return mod.parse;
|
|
723
|
+
}
|
|
724
|
+
// Parsers come from this package's own trusted location (the runner's copy), never from the content root,
|
|
725
|
+
// which is submitted code. `smol-toml` resolves through this package's own dependencies.
|
|
726
|
+
const TRUSTED_YAML_PARSER = fileURLToPath(new URL("./workflow-yaml.mjs", import.meta.url));
|
|
727
|
+
async function defaultLoadYaml() {
|
|
728
|
+
if (!fs.existsSync(TRUSTED_YAML_PARSER)) throw new Error(`strict YAML parser not found at ${TRUSTED_YAML_PARSER}`);
|
|
729
|
+
return (await import(pathToFileURL(TRUSTED_YAML_PARSER).href)).parseYaml;
|
|
730
|
+
}
|
|
731
|
+
async function defaultScanSecrets() {
|
|
732
|
+
return (await import("./publish-cargo.mjs")).scanCrateForSecrets;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
const INTERNAL = {
|
|
736
|
+
"json-parse": (s, ctx) => {
|
|
737
|
+
const bad = [];
|
|
738
|
+
for (const f of s.files) {
|
|
739
|
+
try { JSON.parse(ctx.read(f).replace(/^/, "")); } catch (e) { bad.push(`${f}: ${e.message}`); }
|
|
740
|
+
}
|
|
741
|
+
return bad;
|
|
742
|
+
},
|
|
743
|
+
"toml-parse": async (s, ctx) => {
|
|
744
|
+
let parse;
|
|
745
|
+
try { parse = await ctx.deps.loadToml(); } catch (e) { return { missing: `TOML parser unavailable: ${e.message}` }; }
|
|
746
|
+
const bad = [];
|
|
747
|
+
for (const f of s.files) { try { parse(ctx.read(f)); } catch (e) { bad.push(`${f}: ${String(e.message).split("\n")[0]}`); } }
|
|
748
|
+
return bad;
|
|
749
|
+
},
|
|
750
|
+
"yaml-parse": async (s, ctx) => {
|
|
751
|
+
let parse;
|
|
752
|
+
try { parse = await ctx.deps.loadYaml(); } catch (e) { return { missing: `YAML parser unavailable: ${e.message}` }; }
|
|
753
|
+
const bad = [];
|
|
754
|
+
for (const f of s.files) { try { parse(ctx.read(f)); } catch (e) { bad.push(`${f}: ${e.message}`); } }
|
|
755
|
+
return bad;
|
|
756
|
+
},
|
|
757
|
+
refuse: (s) => [s.detail],
|
|
758
|
+
// Every RustSec exception is dated, scoped to the locked versions and identical in deny.toml and
|
|
759
|
+
// .cargo/audit.toml (ci/advisory-exceptions.json; Astra 2026-10-22). Expiry fails every run.
|
|
760
|
+
"advisory-exceptions": (s, ctx) => checkAdvisoryExceptions({ root: ctx.contentRoot, readFile: ctx.read }),
|
|
761
|
+
"ownership-catalog": (s, ctx) => (ctx.read(s.files[0]) === ctx.read(s.files[1]) ? [] : ["copy packages/release/rightkit-ownership.json to crates/rightkit-ownership/catalog/ownership-catalog.json"]),
|
|
762
|
+
"changelog-version": (s, ctx) => {
|
|
763
|
+
const version = (ctx.read(s.files[0]).match(/^version\s*=\s*"([^"]+)"/m) || [])[1];
|
|
764
|
+
if (!version) return [];
|
|
765
|
+
const heading = new RegExp(`^##\\s+\\[?${version.replace(/\./g, "\\.")}\\]?(\\s|$)`, "m");
|
|
766
|
+
return heading.test(ctx.read(s.files[1])) ? [] : [`${s.files[1]} has no "## ${version}" heading`];
|
|
767
|
+
},
|
|
768
|
+
license: (s, ctx) => {
|
|
769
|
+
const bad = [];
|
|
770
|
+
for (const f of s.files) {
|
|
771
|
+
const text = ctx.read(f);
|
|
772
|
+
if (f.endsWith("Cargo.toml")) {
|
|
773
|
+
if (!/^\s*(license|license-file)\s*(=|\.workspace)/m.test(text)) bad.push(`${f}: no license or license-file`);
|
|
774
|
+
} else {
|
|
775
|
+
try { const m = JSON.parse(text); if (m.private !== true && !(typeof m.license === "string" && m.license.trim()) && !m.licenses) bad.push(`${f}: no license field`); } catch (e) { bad.push(`${f}: ${e.message}`); }
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
return bad;
|
|
779
|
+
},
|
|
780
|
+
"secret-scan": async (s, ctx) => {
|
|
781
|
+
const scan = await ctx.deps.scanSecrets();
|
|
782
|
+
const bad = [];
|
|
783
|
+
for (const dir of s.dirs) {
|
|
784
|
+
const abs = path.join(ctx.contentRoot, dir);
|
|
785
|
+
if (!ctx.deps.exists(abs)) { bad.push(`${dir}: directory not found`); continue; }
|
|
786
|
+
for (const v of scan(abs)) bad.push(`${dir}: ${v}`);
|
|
787
|
+
}
|
|
788
|
+
return bad;
|
|
789
|
+
},
|
|
790
|
+
};
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* Run a plan. Options: classes (default ["A","B"]), exclude(scan) => boolean (an explicit, recorded
|
|
794
|
+
* omission such as a caller that runs its own tests), plus the injectable spawn/exists/readFile/readdir/env.
|
|
795
|
+
* Class B runs only when every selected class A scan passed; otherwise each B scan is "not-run".
|
|
796
|
+
*/
|
|
797
|
+
export async function runScans(plan, options = {}) {
|
|
798
|
+
const deps = {
|
|
799
|
+
// Concurrent runs need a spawn that does not block the event loop.
|
|
800
|
+
spawn: Number(options.jobs ?? 1) > 1 || options.signal ? defaultSpawnAsync : defaultSpawn, exists: fs.existsSync, readFile: (f) => fs.readFileSync(f, "utf8"), readdir: fs.readdirSync,
|
|
801
|
+
env: process.env, platform: process.platform, execPath: process.execPath,
|
|
802
|
+
loadToml: defaultLoadToml, loadYaml: defaultLoadYaml, scanSecrets: defaultScanSecrets, findRustfmt,
|
|
803
|
+
...options,
|
|
804
|
+
};
|
|
805
|
+
const classes = options.classes ?? [SCAN_CLASS.A, SCAN_CLASS.B];
|
|
806
|
+
const exclude = options.exclude ?? (() => false);
|
|
807
|
+
// An AbortSignal: once aborted, no further scan starts, running children are killed (process tree) and every unfinished scan is "not-run" with code CANCELLED.
|
|
808
|
+
const signal = options.signal; const sig = signal ? { signal } : {};
|
|
809
|
+
const { contentRoot, liveRoot } = plan.roots;
|
|
810
|
+
const rootDir = (r) => (r === "content" ? contentRoot : r === "parent" ? path.dirname(contentRoot) : liveRoot);
|
|
811
|
+
const selected = plan.scans.filter((s) => classes.includes(s.class) && !exclude(s));
|
|
812
|
+
const ordered = [...selected.filter((s) => s.class === SCAN_CLASS.A), ...selected.filter((s) => s.class === SCAN_CLASS.B)];
|
|
813
|
+
const ctx = { deps, contentRoot, read: (f) => deps.readFile(path.join(contentRoot, f)) };
|
|
814
|
+
const toolVersions = {}; const probed = new Map();
|
|
815
|
+
const results = [];
|
|
816
|
+
// options.cache = { read(key), write(key, row), digest(scan) => string | null } (null: not cacheable, which is how the live class A checks opt out).
|
|
817
|
+
const cache = options.cache && typeof options.cache.read === "function" && typeof options.cache.write === "function" && typeof options.cache.digest === "function" ? options.cache : null;
|
|
818
|
+
const cacheHits = [];
|
|
819
|
+
let classAFailed = false;
|
|
820
|
+
// Class B needs class A first: in this call (classes includes A) or valid prior class A evidence for the
|
|
821
|
+
// same class A scan set (options.priorClassA: an earlier runScans result whose class A rows all passed).
|
|
822
|
+
const priorA = options.priorClassA;
|
|
823
|
+
const priorRows = Array.isArray(priorA?.scans) ? priorA.scans.filter((r) => r.class === SCAN_CLASS.A) : [];
|
|
824
|
+
// Prior evidence binds to the input content (inputDigest of the changed files), the class A scan set, and a
|
|
825
|
+
// class A run that actually included class A.
|
|
826
|
+
const priorClassAValid = Boolean(priorA) && priorA.schema === SCAN_RESULT_SCHEMA && Boolean(plan.classDigests?.A) && Boolean(plan.inputDigest)
|
|
827
|
+
&& priorA.inputDigest === plan.inputDigest && Array.isArray(priorA.classes) && priorA.classes.includes(SCAN_CLASS.A)
|
|
828
|
+
&& priorA.classDigests?.A === plan.classDigests.A && digestScanSet(priorRows) === plan.classDigests.A && priorRows.every((r) => r.status === "pass");
|
|
829
|
+
// Class A ran "in this call" only when it was selected, the plan has class A scans, and none was excluded:
|
|
830
|
+
// excluding every class A scan (or a plan with none) never unlocks class B; explicit prior evidence is needed.
|
|
831
|
+
const planA = plan.scans.filter((x) => x.class === SCAN_CLASS.A);
|
|
832
|
+
const inCallA = classes.includes(SCAN_CLASS.A) && planA.length > 0 && selected.filter((x) => x.class === SCAN_CLASS.A).length === planA.length;
|
|
833
|
+
const classBBlocked = !inCallA && !priorClassAValid;
|
|
834
|
+
|
|
835
|
+
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.spawn }) : tool);
|
|
836
|
+
async function probe(tool) {
|
|
837
|
+
if (probed.has(tool)) return probed.get(tool);
|
|
838
|
+
let outcome;
|
|
839
|
+
const command = toolCommand(tool);
|
|
840
|
+
if (!command) outcome = { ok: false, why: "not found" };
|
|
841
|
+
else {
|
|
842
|
+
const r = await deps.spawn(command, PROBES[tool] ?? ["--version"], { cwd: liveRoot, env: deps.env, ...sig });
|
|
843
|
+
if (!r || r.error || r.status !== 0) outcome = { ok: false, why: r?.error?.message ?? `exit ${r?.status}` };
|
|
844
|
+
else outcome = { ok: true, command, version: String(r.stdout || r.stderr || "").split(/\r?\n/).find((l) => l.trim())?.trim() ?? "unknown" };
|
|
845
|
+
}
|
|
846
|
+
toolVersions[tool] = outcome.ok ? outcome.version : null;
|
|
847
|
+
probed.set(tool, outcome);
|
|
848
|
+
return outcome;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
const jobs = Math.max(1, Number(options.jobs ?? 1) || 1);
|
|
852
|
+
const testJobs = Math.max(1, Number(options.testJobs ?? jobs) || jobs);
|
|
853
|
+
// { acquire: async () => ({ id, env }) | null, release: async (id) => {} }: the broker's quiet lease around the quiet lane. A
|
|
854
|
+
// failed acquire runs the lane without one (the caller reports it); the lease is released however the lane ends.
|
|
855
|
+
const quietLane = options.quietLane && typeof options.quietLane.acquire === "function" ? options.quietLane : null;
|
|
856
|
+
const onResult = typeof options.onResult === "function" ? options.onResult : () => {};
|
|
857
|
+
const total = ordered.length; let done = 0;
|
|
858
|
+
async function runOne(s, extraEnv = null) {
|
|
859
|
+
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" };
|
|
860
|
+
const started = Date.now();
|
|
861
|
+
const row = await scanRow(s, extraEnv);
|
|
862
|
+
done += 1;
|
|
863
|
+
try { onResult(row, { done, total, seconds: Math.round((Date.now() - started) / 1000) }); } catch { /* reporting never fails a scan */ }
|
|
864
|
+
return row;
|
|
865
|
+
}
|
|
866
|
+
// Class A in order (a failure stops class B); then class B by lane: compile lanes (`jobs`) beside the doc lane, then the test
|
|
867
|
+
// 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
|
|
868
|
+
// an otherwise quiet machine, as CI's shards run tests after their build. With jobs = 1 and testJobs = 1 the order is the same.
|
|
869
|
+
const aScans = ordered.filter((s) => s.class === SCAN_CLASS.A);
|
|
870
|
+
const bScans = ordered.filter((s) => s.class === SCAN_CLASS.B);
|
|
871
|
+
// Class A scans are independent content checks (and short broker calls: metadata, package list, deny, audit): they run
|
|
872
|
+
// `classAJobs` at a time (default: `jobs`); the rows keep plan order. Class B starts only once every class A row is in.
|
|
873
|
+
const aJobs = Math.max(1, Number(options.classAJobs ?? jobs) || jobs);
|
|
874
|
+
{
|
|
875
|
+
const slots = new Array(aScans.length); const queue = aScans.map((_, i) => i);
|
|
876
|
+
const drain = async () => { while (queue.length) { const i = queue.shift(); slots[i] = await runOne(aScans[i]); } };
|
|
877
|
+
await Promise.all(Array.from({ length: aJobs }, drain));
|
|
878
|
+
results.push(...slots);
|
|
879
|
+
}
|
|
880
|
+
{
|
|
881
|
+
const slots = new Array(bScans.length);
|
|
882
|
+
const lanes = { compile: [], doc: [], tests: [], quiet: [] };
|
|
883
|
+
bScans.forEach((s, i) => lanes[laneOf(s)].push(i));
|
|
884
|
+
const drain = async (queue, extraEnv = null) => { while (queue.length) { const i = queue.shift(); slots[i] = await runOne(bScans[i], extraEnv); } };
|
|
885
|
+
await Promise.all([...Array.from({ length: jobs }, () => drain(lanes.compile)), drain(lanes.doc)]);
|
|
886
|
+
await Promise.all(Array.from({ length: testJobs }, () => drain(lanes.tests)));
|
|
887
|
+
if (lanes.quiet.length) {
|
|
888
|
+
let lease = null;
|
|
889
|
+
// No lease when the lane cannot run anyway (class A failed or was not supplied): its rows are not-run without one.
|
|
890
|
+
if (quietLane && !signal?.aborted && !classAFailed && !classBBlocked) { try { lease = (await quietLane.acquire()) ?? null; } catch { lease = null; } }
|
|
891
|
+
try { await drain(lanes.quiet, lease?.env ?? null); } finally {
|
|
892
|
+
if (lease && typeof quietLane.release === "function") { try { await quietLane.release(lease.id); } catch { /* the lease expires on its own */ } }
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
results.push(...slots);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// `extraEnv` (the quiet lease id) reaches the child but never the cache key: a lease changes nothing a scan proves.
|
|
899
|
+
async function scanRow(s, extraEnv = null) {
|
|
900
|
+
const row = { id: s.id, class: s.class, command: s.command, tool: s.tool ?? null, toolVersion: null, status: "fail" };
|
|
901
|
+
if (s.class === SCAN_CLASS.B && classBBlocked) {
|
|
902
|
+
return { ...row, status: "not-run", code: "CLASS_A_REQUIRED", detail: "class B refused: class A did not run in this call and no valid prior class A evidence for the same scan set was supplied" };
|
|
903
|
+
}
|
|
904
|
+
if (s.class === SCAN_CLASS.B && classAFailed) {
|
|
905
|
+
return { ...row, status: "not-run", detail: "a class A scan failed; class B starts only after class A passes" };
|
|
906
|
+
}
|
|
907
|
+
let status = "pass"; let detail = ""; let code;
|
|
908
|
+
if (s.deferred) {
|
|
909
|
+
// A class A scan that did not run is not a class A pass: class B waits.
|
|
910
|
+
if (s.class === SCAN_CLASS.A) classAFailed = true;
|
|
911
|
+
return { ...row, status: "deferred", detail: s.deferred };
|
|
912
|
+
}
|
|
913
|
+
try {
|
|
914
|
+
if (s.internal) {
|
|
915
|
+
const out = await INTERNAL[s.internal](s, ctx);
|
|
916
|
+
if (out && out.missing) { status = "fail"; code = "TOOL_MISSING"; detail = out.missing; }
|
|
917
|
+
else if (out.length) { status = "fail"; detail = out.join("; "); }
|
|
918
|
+
} else {
|
|
919
|
+
const p = await probe(s.tool);
|
|
920
|
+
row.toolVersion = p.ok ? p.version : null;
|
|
921
|
+
if (!p.ok) { status = "fail"; code = "TOOL_MISSING"; detail = `required tool ${s.tool} is missing or not runnable (${p.why}); a required scan is never skipped`; }
|
|
922
|
+
else {
|
|
923
|
+
let cacheKey = null;
|
|
924
|
+
if (cache) {
|
|
925
|
+
try {
|
|
926
|
+
const digest = cache.digest(s);
|
|
927
|
+
if (digest) cacheKey = scanCacheKey({ id: s.id, command: s.command, toolVersion: p.version, env: s.env ?? {}, inheritedEnv: inheritedEnvDigest({ ...deps.env, ...s.env }), digest });
|
|
928
|
+
} catch { cacheKey = null; }
|
|
929
|
+
const hit = cacheKey ? cache.read(cacheKey) : null;
|
|
930
|
+
if (hit && hit.key === cacheKey && hit.row?.status === "pass" && hit.row.id === s.id && hit.row.command === s.command) {
|
|
931
|
+
cacheHits.push(s.id);
|
|
932
|
+
return { ...row, status: "pass", cached: true, detail: `cached ${String(hit.at ?? "").slice(0, 10) || "earlier"}` };
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
const r = await deps.spawn(p.command, s.args.map((a) => (a === "{contentRoot}" ? contentRoot : a)), { cwd: path.join(rootDir(s.root), s.cwd ?? ""), env: { ...deps.env, ...s.env, ...(extraEnv ?? {}) }, ...sig });
|
|
936
|
+
if (signal?.aborted && (!r || r.error || r.status !== 0)) return { ...row, status: "not-run", code: "CANCELLED", detail: "cancelled while running" };
|
|
937
|
+
if (!r || r.error) { status = "fail"; code = "SPAWN_ERROR"; detail = r?.error?.message ?? "no result"; }
|
|
938
|
+
else if (r.status !== 0) { status = "fail"; code = "EXIT"; detail = tail(r) || `exit ${r.status}`; }
|
|
939
|
+
else if (cacheKey && !signal?.aborted) cache.write(cacheKey, { id: s.id, class: s.class, command: s.command, tool: s.tool ?? null, toolVersion: row.toolVersion, status: "pass" });
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
} catch (e) { status = "fail"; code = "SCAN_ERROR"; detail = e.message; }
|
|
943
|
+
if (status === "fail" && s.class === SCAN_CLASS.A) classAFailed = true;
|
|
944
|
+
return { ...row, status, ...(code ? { code } : {}), ...(detail ? { detail } : {}) };
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
const cancelled = signal?.aborted === true;
|
|
948
|
+
return {
|
|
949
|
+
schema: SCAN_RESULT_SCHEMA, ok: !cancelled && results.every((r) => r.status === "pass"), complete: !cancelled && results.every((r) => r.status === "pass"), classes, ...(cancelled ? { cancelled: true } : {}),
|
|
950
|
+
inputDigest: plan.inputDigest ?? null,
|
|
951
|
+
deferred: results.filter((r) => r.status === "deferred").map((r) => r.id),
|
|
952
|
+
scans: results, toolVersions, cacheHits: [...cacheHits].sort(), notes: plan.notes ?? [],
|
|
953
|
+
scanSetDigest: digestScanSet(results),
|
|
954
|
+
classDigests: { A: digestScanSet(results.filter((r) => r.class === "A")), B: digestScanSet(results.filter((r) => r.class === "B")) },
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/** One line per scan that failed or did not run, for a gate's failure list. A deferred scan is not a failure; see scanDeferrals. */
|
|
959
|
+
export function scanFailures(result) {
|
|
960
|
+
return result.scans.filter((r) => r.status !== "pass" && r.status !== "deferred").map((r) => `${r.id}${r.status === "not-run" ? " (not run)" : ""}: ${r.detail ?? r.status}`);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/** One line per deferred scan, by name. A gate prints these; they are never reported as passed. */
|
|
964
|
+
export function scanDeferrals(result) {
|
|
965
|
+
return result.scans.filter((r) => r.status === "deferred").map((r) => `${r.id}: deferred, not passed (${r.detail})`);
|
|
966
|
+
}
|