@rightkit/release 0.4.1 → 0.5.1
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 +11 -2
- package/adhoc-entitlements.mjs +8 -0
- package/advisory-exceptions.mjs +371 -0
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/rightkit-gate.mjs +1079 -0
- package/cli/right-release.mjs +29 -0
- package/deterministic-scans.mjs +966 -0
- package/development-signing.mjs +2 -1
- package/host-upload-refusal.mjs +90 -0
- package/local-publish.mjs +9 -0
- 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-mandatory-set.json +53 -13
- package/ownership-mandatory-set.mjs +10 -4
- package/package.json +6 -2
- package/pipeline-admission.mjs +1 -1
- package/pipeline-receipts.mjs +302 -0
- package/publication-gate.mjs +12 -1
- package/publish-cargo.mjs +13 -1
- package/rightkit-ownership.json +57 -0
- package/rightkit-versions.json +103 -80
- package/runtime-budget-classes.json +8 -0
- package/scan-digest.mjs +107 -0
- package/sign-qa-bundle.mjs +210 -0
- package/stage-sidecar.mjs +54 -9
- package/workflow-yaml.mjs +177 -0
|
@@ -0,0 +1,1079 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// rightkit-gate: the mandatory local deterministic gate for any repository that uses RightKit (Adrian 2026-10-22:
|
|
3
|
+
// "local deterministic checks ... mandatory for all builds"). Runs from the pre-push hook that `rightkit-gate install-hook`
|
|
4
|
+
// writes, so it never depends on an agent remembering. Every applicable scan for the pushed change runs through the one
|
|
5
|
+
// scan module (../deterministic-scans.mjs); Rust units come from `cargo metadata` (one run per Cargo workspace), npm units from
|
|
6
|
+
// pnpm-workspace.yaml, so the layout is the repository's own. CI still compiles, lints and tests everything.
|
|
7
|
+
//
|
|
8
|
+
// Layout it covers (nothing is partly covered silently):
|
|
9
|
+
// - Rust: every tracked Cargo.toml that is a workspace root or a standalone package not inside another workspace is its own Cargo
|
|
10
|
+
// workspace (`cargo metadata --no-deps --locked` there, through the broker). A unit of a workspace below the repository root is
|
|
11
|
+
// named `<cargoDir>:<crate>`; its scans run with that directory as cwd, its reverse dependents and cache keys come from that
|
|
12
|
+
// workspace's own Cargo.lock, and ci/gate.config.json `rust` may key its feature sets by `<cargoDir>:<crate>` or by the plain crate
|
|
13
|
+
// name. A tracked Cargo.toml that is not a member and cannot stand alone, a member that is untracked or ignored, and an untracked
|
|
14
|
+
// Cargo.toml are skipped with a note on the first lines of the run, never silently.
|
|
15
|
+
// - npm: the pnpm-workspace.yaml globs over tracked package.json files; when that file is absent, has no `packages:` or declares ".",
|
|
16
|
+
// the root package.json is a unit too (tsc, eslint and test rows like any package).
|
|
17
|
+
//
|
|
18
|
+
// Release evidence for a PRODUCT repository (an app): the pin's "a release qualified locally on both hosts" is the app's own
|
|
19
|
+
// `right-release` release QA run on both hosts (docs/CI.md "Release evidence for an app"). `right-release qualify cargo` qualifies
|
|
20
|
+
// CRATES for a registry upload (`.rightkit-gate.json` configures it for a repository other than RightKit); it is not an app's evidence.
|
|
21
|
+
//
|
|
22
|
+
// Content checks run on an export of HEAD (exactly what is pushed), never on uncommitted work in the checkout.
|
|
23
|
+
//
|
|
24
|
+
// rightkit-gate [--base <ref>] [--plan] [--no-cache]
|
|
25
|
+
// run the gate (default base origin/main); --plan prints the scans and runs none;
|
|
26
|
+
// --no-cache (or RK_GATE_CACHE=0) reruns every scan
|
|
27
|
+
// rightkit-gate install-hook [--force] write .githooks/pre-push and set core.hooksPath. core.hooksPath hides .git/hooks, so the
|
|
28
|
+
// pre-push hook runs the repository's own .git/hooks/pre-push first and every other hook
|
|
29
|
+
// present in .git/hooks gets a .githooks/<name> trampoline that runs it; the hook finds
|
|
30
|
+
// the gate binary in the root node_modules/.bin, then any workspace package's, then pnpm exec
|
|
31
|
+
//
|
|
32
|
+
// From the pre-push hook, git's ref lines arrive on stdin: the commit being pushed must be the checked-out HEAD (broker scans
|
|
33
|
+
// read the live checkout), and the remote's current commit is the diff base. To push an earlier commit of the local stack,
|
|
34
|
+
// check it out first (a detached HEAD is fine), push HEAD:<branch>, return.
|
|
35
|
+
// ci/gate.config.json `rust` (optional) sets per-crate feature sets, exactly as CI's gate reads it.
|
|
36
|
+
// Result cache: a passing class B scan is reused only when its scan id, command, tool version, environment and input digest
|
|
37
|
+
// (the gated commit's git object ids for the crate's workspace closure, the shared build inputs, ci/gate.config.json and every
|
|
38
|
+
// path the closure's sources include from outside their crate; the whole HEAD tree for Rust tests unless ci/gate.config.json
|
|
39
|
+
// rust.<crate>.testInputs lists the paths they read, for npm, node and docs scans, and whenever a reference cannot be resolved
|
|
40
|
+
// statically) are all identical. Stored in <git common dir>/rightkit-gate-cache; a hit prints
|
|
41
|
+
// `cached` and is listed in the result's cacheHits. Failures, deferrals and class A scans are never cached.
|
|
42
|
+
// RK_GATE_JOBS (default 4) sets how many compile scans run side by side; RK_GATE_TEST_JOBS (default RK_GATE_JOBS) how many test
|
|
43
|
+
// scans, after the compile lanes drain. A crate whose ci/gate.config.json entry says `quietTests: true` has its test scans run last,
|
|
44
|
+
// one at a time, under the RustRight quiet lease (`rustright quiet acquire`, RUSTRIGHT_QUIET_LEASE in their environment).
|
|
45
|
+
// SIGINT/SIGTERM stop scheduling, kill every running scan's process tree and exit non-zero listing the scans not run.
|
|
46
|
+
import fs from "node:fs";
|
|
47
|
+
import os from "node:os";
|
|
48
|
+
import path from "node:path";
|
|
49
|
+
import { fileURLToPath } from "node:url";
|
|
50
|
+
import { WORKSPACE_RUST_FILE, ancestorConfigChanged, ancestorDirsOf, canonicalJson, defaultSpawn, digestScanSet, fileScanCache, metadataArgs, planScans, runScans, scanDeferrals, scanFailures } from "../deterministic-scans.mjs";
|
|
51
|
+
import { changedLockNames, parseLock, reach } from "../lock-graph.mjs";
|
|
52
|
+
|
|
53
|
+
const ZERO = /^0+$/;
|
|
54
|
+
const GRANT_FILE = ".rightkit-local-build.json";
|
|
55
|
+
const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
56
|
+
const posix = (p) => String(p).replaceAll("\\", "/");
|
|
57
|
+
const text = (r) => String(r?.stdout ?? "");
|
|
58
|
+
const failed = (r) => !r || Boolean(r.error) || r.status !== 0;
|
|
59
|
+
const why = (r) => String(r?.error?.message ?? r?.stderr ?? "").trim().split(/\r?\n/).slice(-3).join(" | ");
|
|
60
|
+
|
|
61
|
+
const USAGE = `usage: rightkit-gate [--base <ref>] [--plan] [--no-cache] [--pre-push] | rightkit-gate install-hook [--force] | --version`;
|
|
62
|
+
|
|
63
|
+
// ---- pure helpers (exported for tests) ----------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
/** Repository pre-push policy only. Full gates & publication qualification retain native checks. */
|
|
66
|
+
export function prePushPlan(plan) {
|
|
67
|
+
const scans = plan.scans.filter((s) => s.class === "A" || (s.class === "B" && ["test:scripts-ci", "generated-docs"].includes(s.id)));
|
|
68
|
+
const ids = new Set(scans.map((s) => s.id));
|
|
69
|
+
return {
|
|
70
|
+
...plan, scans, deferred: plan.deferred.filter((id) => ids.has(id)),
|
|
71
|
+
scanSetDigest: digestScanSet(scans),
|
|
72
|
+
classDigests: { A: digestScanSet(scans.filter((s) => s.class === "A")), B: digestScanSet(scans.filter((s) => s.class === "B")) },
|
|
73
|
+
notes: [...plan.notes, "pre-push mode: class A, focused scripts/ci tests & generated docs only; native checks remain required for publication qualification"],
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The `packages:` globs of a pnpm-workspace.yaml (block or flow list; the subset pnpm repositories use). */
|
|
78
|
+
export function parseWorkspaceGlobs(source) {
|
|
79
|
+
const out = []; let inPackages = false;
|
|
80
|
+
for (const raw of String(source).split(/\r?\n/)) {
|
|
81
|
+
const line = raw.replace(/\s+#.*$/, "");
|
|
82
|
+
const flow = /^packages\s*:\s*\[(.*)\]\s*$/.exec(line);
|
|
83
|
+
if (flow) { for (const item of flow[1].split(",")) { const v = item.trim().replace(/^["']|["']$/g, ""); if (v) out.push(v); } inPackages = false; continue; }
|
|
84
|
+
if (/^packages\s*:/.test(line)) { inPackages = true; continue; }
|
|
85
|
+
if (/^[^\s#-]/.test(line)) { inPackages = false; continue; }
|
|
86
|
+
if (!inPackages) continue;
|
|
87
|
+
const m = /^\s*-\s*(?:"([^"]*)"|'([^']*)'|(\S+))\s*$/.exec(line);
|
|
88
|
+
if (m) out.push(m[1] ?? m[2] ?? m[3]);
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** A pnpm workspace glob as a RegExp over a repo-relative posix directory: `*` one segment, `**` any depth. */
|
|
94
|
+
export function globToRegExp(glob) {
|
|
95
|
+
const g = posix(glob).replace(/^\.\//, "").replace(/\/+$/, "");
|
|
96
|
+
let re = "";
|
|
97
|
+
for (let i = 0; i < g.length;) {
|
|
98
|
+
if (g.startsWith("**/", i)) { re += "(?:.+/)?"; i += 3; }
|
|
99
|
+
else if (g.startsWith("**", i)) { re += ".*"; i += 2; }
|
|
100
|
+
else if (g[i] === "*") { re += "[^/]*"; i += 1; }
|
|
101
|
+
else { re += g[i].replace(/[.+?^${}()|[\]\\]/g, "\\$&"); i += 1; }
|
|
102
|
+
}
|
|
103
|
+
return new RegExp(`^${re}$`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** npm unit name from a package name: `@scope/x` -> `scope-x`; anything still unsafe falls back to the directory. */
|
|
107
|
+
const unitName = (pkgName, dir) => {
|
|
108
|
+
const n = String(pkgName ?? "").replace(/^@/, "").replace(/\//g, "-");
|
|
109
|
+
return SAFE_NAME.test(n) ? n : dir === "." ? "root" : dir.replace(/\//g, "-");
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* npm units: pnpm-workspace.yaml globs over tracked package.json files, else the root package.json. The root package.json is a
|
|
114
|
+
* unit as well when the workspace file is absent, has no `packages:` (or an empty one) or declares "." (or "./"); with a
|
|
115
|
+
* `packages:` list that does not name the root it is only the workspace's own manifest and is not a unit.
|
|
116
|
+
*/
|
|
117
|
+
export function discoverNpmUnits({ root, tracked, readFile, exists }) {
|
|
118
|
+
let dirs;
|
|
119
|
+
const workspaceFile = path.join(root, "pnpm-workspace.yaml");
|
|
120
|
+
if (exists(workspaceFile)) {
|
|
121
|
+
const globs = parseWorkspaceGlobs(readFile(workspaceFile));
|
|
122
|
+
const inc = globs.filter((g) => !g.startsWith("!")).map(globToRegExp);
|
|
123
|
+
const exc = globs.filter((g) => g.startsWith("!")).map((g) => globToRegExp(g.slice(1)));
|
|
124
|
+
const positive = globs.filter((g) => !g.startsWith("!"));
|
|
125
|
+
const rootDeclared = positive.length === 0 || positive.some((g) => g === "." || g === "./");
|
|
126
|
+
dirs = tracked("package.json").map((p) => path.posix.dirname(p)).filter((d) => (d === "." ? rootDeclared : inc.some((r) => r.test(d))) && !exc.some((r) => r.test(d)));
|
|
127
|
+
} else dirs = exists(path.join(root, "package.json")) ? ["."] : [];
|
|
128
|
+
dirs = [...new Set(dirs)].sort();
|
|
129
|
+
const rows = dirs.map((dir) => {
|
|
130
|
+
let manifest;
|
|
131
|
+
try { manifest = JSON.parse(readFile(path.join(root, dir, "package.json")).replace(/^/, "")); } catch (e) { throw new Error(`${dir}/package.json: ${e.message}`); }
|
|
132
|
+
return { dir, name: unitName(manifest.name, dir) };
|
|
133
|
+
});
|
|
134
|
+
const count = new Map();
|
|
135
|
+
for (const r of rows) count.set(r.name, (count.get(r.name) ?? 0) + 1);
|
|
136
|
+
return rows.map((r) => ({ name: count.get(r.name) > 1 ? unitName("", r.dir === "." ? "root" : r.dir) : r.name, dir: r.dir }));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const dirOfManifest = (m) => path.posix.dirname(m);
|
|
140
|
+
const byDepthThenName = (a, b) => dirOfManifest(a).split("/").length - dirOfManifest(b).split("/").length || (a < b ? -1 : 1);
|
|
141
|
+
|
|
142
|
+
// `cargo metadata --no-deps --format-version 1 --locked` in one directory, through the resolved cargo command. Returns
|
|
143
|
+
// { cargoDir, members: [{ name, dir, manifest, publish }] } with every path repository-relative.
|
|
144
|
+
function workspaceMetadata({ root, spawn, cargoCommand, dir, realpath }) {
|
|
145
|
+
const r = spawn(cargoCommand[0], [...cargoCommand.slice(1), ...metadataArgs()], { cwd: path.join(root, dir) });
|
|
146
|
+
if (failed(r)) throw new Error(`cargo metadata failed: ${why(r) || `exit ${r?.status}`}${dir === "." ? "" : ` (in ${dir})`}`);
|
|
147
|
+
let meta;
|
|
148
|
+
try { meta = JSON.parse(text(r)); } catch (e) { throw new Error(`cargo metadata output is not JSON: ${e.message}${dir === "." ? "" : ` (in ${dir})`}`); }
|
|
149
|
+
const real = (p) => { try { return realpath(p); } catch { return path.resolve(p); } };
|
|
150
|
+
const rootReal = real(root);
|
|
151
|
+
const rel = (abs) => posix(path.relative(rootReal, real(abs))) || ".";
|
|
152
|
+
const cargoDir = rel(meta.workspace_root);
|
|
153
|
+
if (cargoDir === ".." || cargoDir.startsWith("../")) throw new Error(`the Cargo workspace root ${meta.workspace_root} is outside the repository`);
|
|
154
|
+
const members = (meta.packages ?? []).map((p) => {
|
|
155
|
+
const pkgDir = rel(path.dirname(p.manifest_path));
|
|
156
|
+
if (pkgDir === ".." || pkgDir.startsWith("../")) throw new Error(`workspace member ${p.name} lives outside the repository (${pkgDir})`);
|
|
157
|
+
return { name: p.name, dir: pkgDir, manifest: pkgDir === "." ? "Cargo.toml" : `${pkgDir}/Cargo.toml`, publish: !(Array.isArray(p.publish) && p.publish.length === 0) };
|
|
158
|
+
}).sort((a, b) => (a.name < b.name ? -1 : 1));
|
|
159
|
+
return { cargoDir, members };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Rust units of ONE Cargo workspace from `cargo metadata --no-deps --format-version 1 --locked`: several workspaces are refused
|
|
164
|
+
* here. The gate itself uses discoverRustWorkspaces, which handles every workspace of the repository. Returns
|
|
165
|
+
* { present, units: [{ name, dir, publish }], cargoDir, uncovered }.
|
|
166
|
+
*/
|
|
167
|
+
export function discoverRustUnits({ root, spawn, cargoCommand, tracked, readFile, realpath = fs.realpathSync }) {
|
|
168
|
+
const manifests = tracked("Cargo.toml");
|
|
169
|
+
if (!manifests.length) return { present: false, units: [], cargoDir: ".", uncovered: [] };
|
|
170
|
+
const roots = [];
|
|
171
|
+
for (const m of [...manifests].sort(byDepthThenName)) {
|
|
172
|
+
const d = dirOfManifest(m);
|
|
173
|
+
if (!roots.some((r) => r === "." || d === r || d.startsWith(`${r}/`))) roots.push(d);
|
|
174
|
+
}
|
|
175
|
+
if (roots.length > 1) throw new Error(`more than one Cargo workspace (${roots.join(", ")}); discoverRustUnits covers one, so none is partly covered (rightkit-gate uses discoverRustWorkspaces)`);
|
|
176
|
+
const { cargoDir, members } = workspaceMetadata({ root, spawn, cargoCommand, dir: roots[0], realpath });
|
|
177
|
+
const units = members.map(({ name, dir, publish }) => ({ name, dir, publish }));
|
|
178
|
+
const covered = new Set(members.map((m) => m.manifest));
|
|
179
|
+
const uncovered = manifests.filter((m) => !covered.has(m) && /^\s*\[package\]/m.test(readFile(path.join(root, m))));
|
|
180
|
+
return { present: true, units, cargoDir, uncovered };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The unit name in scan ids and ci/gate.config.json: the plain crate in a root workspace, `<cargoDir>:<crate>` below it. */
|
|
184
|
+
export const rustUnitName = (cargoDir, crate) => (cargoDir === "." ? crate : `${cargoDir}:${crate}`);
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The top-level TOML tables of a Cargo manifest: name -> body text (the lines under `[name]` up to the next header, trimmed).
|
|
188
|
+
* `preamble` holds the lines before the first header. Enough to compare manifests table by table; no value parsing.
|
|
189
|
+
*/
|
|
190
|
+
export function manifestTables(text) {
|
|
191
|
+
const tables = new Map(); let current = "preamble"; let body = [];
|
|
192
|
+
const flush = () => { tables.set(current, (tables.get(current) ?? "") + body.join("\n").trim()); body = []; };
|
|
193
|
+
for (const line of String(text ?? "").replace(/\r\n/g, "\n").split("\n")) {
|
|
194
|
+
const header = /^\s*\[\[?([^\]]+)\]\]?\s*(#.*)?$/.exec(line);
|
|
195
|
+
if (header) { flush(); current = header[1].trim(); continue; }
|
|
196
|
+
body.push(line);
|
|
197
|
+
}
|
|
198
|
+
flush();
|
|
199
|
+
return tables;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** The repository-relative directories a workspace manifest `exclude`s (`[workspace] exclude = [...]`); [] when none or unreadable. */
|
|
203
|
+
export function workspaceExcludes(readFile, root, cargoDir) {
|
|
204
|
+
let text;
|
|
205
|
+
try { text = readFile(path.join(root, cargoDir === "." ? "Cargo.toml" : `${cargoDir}/Cargo.toml`)); } catch { return []; }
|
|
206
|
+
const body = manifestTables(text).get("workspace") ?? "";
|
|
207
|
+
const list = /^\s*exclude\s*=\s*\[([\s\S]*?)\]/m.exec(body)?.[1] ?? "";
|
|
208
|
+
return [...list.matchAll(/"([^"]+)"/g)].map((m) => posix(m[1]).replace(/\/+$/, "")).map((d) => (cargoDir === "." ? d : `${cargoDir}/${d}`));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// The manifest tables whose every effect on a build is also written into Cargo.lock: a changed source ([patch.*]) and workspace
|
|
212
|
+
// membership ([workspace] members/exclude: a new member is a new lock package, a removed one disappears). A [workspace.dependencies]
|
|
213
|
+
// change is lock-reflected only when it is a version change (the lock records the resolved version): a feature list reaches every
|
|
214
|
+
// `workspace = true` dependent without touching the lock. Anything else in the root manifest ([profile.*], [workspace.package],
|
|
215
|
+
// [workspace.lints], [workspace.metadata] read by build scripts) can change what every crate compiles to, so it keeps every crate in scope.
|
|
216
|
+
const LOCK_REFLECTED_TABLE = /^patch\..+$/;
|
|
217
|
+
const WORKSPACE_MEMBERSHIP_KEYS = /^\s*(members|exclude|default-members)\s*=\s*\[[\s\S]*?\]\s*(#.*)?$/gm;
|
|
218
|
+
// `serde = "1.0.2"` and `serde = { version = "1.0.2", ... }` with the version neutralised, so two dependency tables compare equal when
|
|
219
|
+
// only versions differ.
|
|
220
|
+
const versionNeutral = (t) => String(t ?? "").replace(/(\bversion\s*=\s*)"[^"]*"/g, '$1"*"').replace(/^(\s*[\w.-]+\s*=\s*)"[^"]*"/gm, '$1"*"');
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* True when every difference between two root manifests lives in a lock-reflected table (above), so the lock graph already says
|
|
224
|
+
* which crates the change reaches. False for any other difference, a malformed manifest, or no difference at all.
|
|
225
|
+
*/
|
|
226
|
+
export function manifestChangeIsLockReflected(before, after) {
|
|
227
|
+
if (typeof before !== "string" || typeof after !== "string" || before === after) return false;
|
|
228
|
+
const a = manifestTables(before); const b = manifestTables(after);
|
|
229
|
+
// A comment-only line and trailing blank lines change no build; a trailing `# ...` on a value line is kept (a `#` may sit in a string).
|
|
230
|
+
const text = (t) => String(t ?? "").split("\n").filter((l) => !/^\s*(#.*)?$/.test(l)).join("\n");
|
|
231
|
+
const strip = (t) => text(t).replace(WORKSPACE_MEMBERSHIP_KEYS, "").trim();
|
|
232
|
+
for (const name of new Set([...a.keys(), ...b.keys()])) {
|
|
233
|
+
if (text(a.get(name)) === text(b.get(name))) continue;
|
|
234
|
+
if (LOCK_REFLECTED_TABLE.test(name)) continue;
|
|
235
|
+
if (name === "workspace" && strip(a.get(name)) === strip(b.get(name))) continue;
|
|
236
|
+
if (name === "workspace.dependencies" && versionNeutral(text(a.get(name))) === versionNeutral(text(b.get(name)))) continue;
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Every Cargo workspace of the repository. A tracked Cargo.toml is a workspace root, or a standalone package not inside another
|
|
244
|
+
* workspace, unless an earlier (shallower) workspace already lists it as a member; each root is read with its own
|
|
245
|
+
* `cargo metadata --no-deps --locked` (cwd = its directory, through the broker). A manifest below another workspace that is not
|
|
246
|
+
* a member is tried the same way (an `exclude`d or self-contained crate is a real workspace); when cargo refuses it there it is a
|
|
247
|
+
* fixture or vendored crate, reported in `skipped` and not scanned. A manifest outside every workspace that cargo refuses throws.
|
|
248
|
+
* A member whose Cargo.toml is not tracked (untracked or ignored) is dropped into `skipped`, as are `untracked()` manifests.
|
|
249
|
+
* Returns { present, workspaces: [{ cargoDir, units }], units, cargoDir, skipped: [{ manifest, why }] }; `units` is every workspace's
|
|
250
|
+
* [{ name, crate, dir, publish, cargoDir }] and `cargoDir` the first workspace's (the repository root's when it has one).
|
|
251
|
+
*/
|
|
252
|
+
export function discoverRustWorkspaces({ root, spawn, cargoCommand, tracked, untracked = () => [], realpath = fs.realpathSync, fixtures = [], readFile = (f) => fs.readFileSync(f, "utf8") }) {
|
|
253
|
+
const manifests = tracked("Cargo.toml");
|
|
254
|
+
const trackedSet = new Set(manifests);
|
|
255
|
+
const locks = new Set(tracked("Cargo.lock"));
|
|
256
|
+
const skipped = [];
|
|
257
|
+
for (const m of untracked()) if (!trackedSet.has(m)) skipped.push({ manifest: m, why: "untracked (not committed, so not part of the gated commit)" });
|
|
258
|
+
if (!manifests.length) return { present: false, workspaces: [], units: [], cargoDir: ".", skipped };
|
|
259
|
+
const fixtureDirs = fixtures.map((d) => posix(String(d)).replace(/\/+$/, "")).filter(Boolean);
|
|
260
|
+
const underFixture = (dir) => fixtureDirs.find((f) => dir === f || dir.startsWith(`${f}/`));
|
|
261
|
+
const workspaces = []; const covered = new Set();
|
|
262
|
+
for (const m of [...manifests].sort(byDepthThenName)) {
|
|
263
|
+
if (covered.has(m)) continue;
|
|
264
|
+
const dir = dirOfManifest(m);
|
|
265
|
+
const parent = workspaces.find((w) => w.cargoDir === "." || dir === w.cargoDir || dir.startsWith(`${w.cargoDir}/`));
|
|
266
|
+
// A manifest under a declared fixture directory (ci/gate.config.json `fixtures`) is a consumer or sample crate: it is gated by the
|
|
267
|
+
// consumer rows of the crate it exercises, never as a unit of its own. Nothing under it is asked of cargo.
|
|
268
|
+
const fixture = underFixture(dir);
|
|
269
|
+
if (fixture) { skipped.push({ manifest: m, why: `under the fixture directory ${fixture} (ci/gate.config.json fixtures); gated through the consumer rows of the crate it exercises` }); continue; }
|
|
270
|
+
// A manifest the parent workspace's own manifest `exclude`s (a vendored or patched crate) is compiled only as a dependency of the
|
|
271
|
+
// parent's members: the parent's lock closure gates it, so it is no unit of its own.
|
|
272
|
+
if (parent) {
|
|
273
|
+
const excluded = workspaceExcludes(readFile, root, parent.cargoDir).find((x) => dir === x || dir.startsWith(`${x}/`));
|
|
274
|
+
if (excluded) { skipped.push({ manifest: m, why: `excluded from the Cargo workspace ${parent.cargoDir} by its manifest (${excluded}); it compiles only inside that workspace's lock closure, which gates it` }); continue; }
|
|
275
|
+
// A nested manifest with no committed Cargo.lock has no reproducible build to gate: its dependency resolution is whatever the
|
|
276
|
+
// registry answers today. It is a fixture or sample; the crate it exercises carries the consumer row that compiles it.
|
|
277
|
+
if (!locks.has(`${dir}/Cargo.lock`)) { skipped.push({ manifest: m, why: `below the Cargo workspace ${parent.cargoDir} with no committed Cargo.lock, so its build is not reproducible; gated through the consumer rows of the crate it exercises` }); continue; }
|
|
278
|
+
}
|
|
279
|
+
let found;
|
|
280
|
+
try { found = workspaceMetadata({ root, spawn, cargoCommand, dir, realpath }); } catch (e) {
|
|
281
|
+
if (!parent) throw e;
|
|
282
|
+
skipped.push({ manifest: m, why: `below the Cargo workspace ${parent.cargoDir} but not one of its members, and cargo refuses it on its own (${String(e.message).replace(/^cargo metadata failed: /, "").replace(/ \(in [^)]*\)$/, "")})` });
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (workspaces.some((w) => w.cargoDir === found.cargoDir)) { covered.add(m); continue; }
|
|
286
|
+
const units = [];
|
|
287
|
+
// The workspace's own root manifest counts as covered even when it is virtual.
|
|
288
|
+
covered.add(found.cargoDir === "." ? "Cargo.toml" : `${found.cargoDir}/Cargo.toml`);
|
|
289
|
+
for (const member of found.members) {
|
|
290
|
+
covered.add(member.manifest);
|
|
291
|
+
if (!trackedSet.has(member.manifest)) { skipped.push({ manifest: member.manifest, why: "a workspace member that is untracked or ignored, so it is not part of the gated commit" }); continue; }
|
|
292
|
+
units.push({ name: rustUnitName(found.cargoDir, member.name), crate: member.name, dir: member.dir, publish: member.publish, cargoDir: found.cargoDir });
|
|
293
|
+
}
|
|
294
|
+
workspaces.push({ cargoDir: found.cargoDir, units });
|
|
295
|
+
}
|
|
296
|
+
workspaces.sort((a, b) => (a.cargoDir === "." ? -1 : b.cargoDir === "." ? 1 : a.cargoDir < b.cargoDir ? -1 : 1));
|
|
297
|
+
const units = workspaces.flatMap((w) => w.units);
|
|
298
|
+
const names = new Map();
|
|
299
|
+
for (const u of units) { if (names.has(u.name)) throw new Error(`two Cargo workspaces both define the unit ${u.name}`); names.set(u.name, true); }
|
|
300
|
+
return { present: true, workspaces, units, cargoDir: workspaces[0]?.cargoDir ?? ".", skipped };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* The crates a lock-reflected workspace change reaches (the lock graph CI's selection uses): a Cargo.lock change, a vendor/ change
|
|
305
|
+
* (the vendored crate is a lock package), and a workspace manifest change confined to the tables the lock also records
|
|
306
|
+
* (manifestChangeIsLockReflected: [patch.*], [workspace.dependencies], membership). null when any other workspace Rust file or
|
|
307
|
+
* manifest table changed or the base has no lock: every crate stays in scope. `readManifest` reads the gated commit's manifest.
|
|
308
|
+
*/
|
|
309
|
+
export function lockScope({ changed, cargoDir = ".", unitNames, showBase, readLock, readManifest = null }) {
|
|
310
|
+
// Rust configuration of any ancestor directory (repository .cargo, intermediate .cargo, toolchain, clippy, rustfmt) reaches
|
|
311
|
+
// every crate of the workspace, whatever the lock graph says.
|
|
312
|
+
if (ancestorConfigChanged(cargoDir, changed)) return null;
|
|
313
|
+
const rel = (f) => (cargoDir === "." ? f : f.startsWith(`${cargoDir}/`) ? f.slice(cargoDir.length + 1) : null);
|
|
314
|
+
const wide = changed.map((f) => [f, rel(f)]).filter(([, r]) => r !== null && WORKSPACE_RUST_FILE.test(r));
|
|
315
|
+
if (!wide.length || wide.some(([, r]) => r !== "Cargo.lock" && r !== "Cargo.toml" && !r.startsWith("vendor/"))) return null;
|
|
316
|
+
const inWs = (f) => (cargoDir === "." ? f : `${cargoDir}/${f}`);
|
|
317
|
+
if (wide.some(([, r]) => r === "Cargo.toml")) {
|
|
318
|
+
if (typeof readManifest !== "function") return null;
|
|
319
|
+
let after;
|
|
320
|
+
try { after = readManifest(); } catch { return null; }
|
|
321
|
+
if (!manifestChangeIsLockReflected(showBase(inWs("Cargo.toml")), after)) return null;
|
|
322
|
+
}
|
|
323
|
+
const before = showBase(inWs("Cargo.lock"));
|
|
324
|
+
if (before === null) return null;
|
|
325
|
+
let nextText;
|
|
326
|
+
try { nextText = readLock(); } catch { return null; }
|
|
327
|
+
const next = parseLock(nextText);
|
|
328
|
+
const touched = changedLockNames(parseLock(before), next);
|
|
329
|
+
// A member the lock no longer lists was removed: nothing left to scan for it. A package present before and absent now reaches
|
|
330
|
+
// nothing; a package new now is in `touched` already.
|
|
331
|
+
for (const [, r] of wide) if (r.startsWith("vendor/")) touched.add(r.split("/")[1]);
|
|
332
|
+
const memo = new Map(); const scope = new Set();
|
|
333
|
+
for (const [name] of next) {
|
|
334
|
+
if (!unitNames.has(name)) continue;
|
|
335
|
+
if (touched.has(name) || [...touched].some((t) => reach(next, name, memo).has(t))) scope.add(name);
|
|
336
|
+
}
|
|
337
|
+
return scope;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* The pre-push hook. git's ref lines arrive on stdin and the gate consumes them, so stdin is buffered once and fed to both the
|
|
342
|
+
* repository's own .git/hooks/pre-push (which core.hooksPath=.githooks would otherwise hide; it runs first and a failure stops the
|
|
343
|
+
* push) and the gate. The gate binary is the root node_modules/.bin/rightkit-gate, else any workspace package's (up to three
|
|
344
|
+
* levels down: tauri-app-next, apps/*, packages/*), else `pnpm exec rightkit-gate`, else node with a package-relative path when
|
|
345
|
+
* pnpm is absent; the line it chose is printed. No absolute path is written: everything is resolved from git at run time.
|
|
346
|
+
*/
|
|
347
|
+
export function hookText(nodePath) {
|
|
348
|
+
return `#!/bin/sh
|
|
349
|
+
# Mandatory local gate before every push (rightkit-gate install-hook). Never bypass with --no-verify.
|
|
350
|
+
cd "$(git rev-parse --show-toplevel)" || exit 1
|
|
351
|
+
stdin=$(mktemp "\${TMPDIR:-/tmp}/rightkit-gate.XXXXXX") || exit 1
|
|
352
|
+
trap 'rm -f "$stdin"' EXIT
|
|
353
|
+
cat > "$stdin"
|
|
354
|
+
# core.hooksPath hides .git/hooks: run the repository's own pre-push hook first.
|
|
355
|
+
own="$(git rev-parse --git-dir)/hooks/pre-push"
|
|
356
|
+
if [ -x "$own" ]; then
|
|
357
|
+
echo "rightkit-gate: running $own" >&2
|
|
358
|
+
"$own" "$@" < "$stdin" || exit $?
|
|
359
|
+
fi
|
|
360
|
+
# The gate binary: the root install, any workspace package's install, then pnpm exec.
|
|
361
|
+
for bin in node_modules/.bin/rightkit-gate */node_modules/.bin/rightkit-gate */*/node_modules/.bin/rightkit-gate */*/*/node_modules/.bin/rightkit-gate; do
|
|
362
|
+
if [ -x "$bin" ]; then
|
|
363
|
+
echo "rightkit-gate: using $bin" >&2
|
|
364
|
+
("$bin" --base origin/main) < "$stdin"; exit $?
|
|
365
|
+
fi
|
|
366
|
+
done
|
|
367
|
+
if command -v pnpm >/dev/null 2>&1; then
|
|
368
|
+
echo "rightkit-gate: using pnpm exec rightkit-gate" >&2
|
|
369
|
+
(exec pnpm exec rightkit-gate --base origin/main) < "$stdin"; exit $?
|
|
370
|
+
fi
|
|
371
|
+
echo "rightkit-gate: using node ${nodePath}" >&2
|
|
372
|
+
(exec node "${nodePath}" --base origin/main) < "$stdin"; exit $?
|
|
373
|
+
`;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** A trampoline for a hook other than pre-push: core.hooksPath hides .git/hooks/<name>, so run that one when it exists. */
|
|
377
|
+
export function trampolineText(name) {
|
|
378
|
+
return `#!/bin/sh
|
|
379
|
+
# rightkit-gate install-hook: core.hooksPath=.githooks hides .git/hooks, so run this repository's own ${name} hook from there.
|
|
380
|
+
hook="$(git rev-parse --git-dir)/hooks/${name}"
|
|
381
|
+
if [ -x "$hook" ]; then exec "$hook" "$@"; fi
|
|
382
|
+
exit 0
|
|
383
|
+
`;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// EVERY file present in .git/hooks is chained, whatever git version or hook family it belongs to (pre-receive, update and
|
|
387
|
+
// post-receive included), except git's own `.sample` templates. The name goes into a committed shell script, so only plain file
|
|
388
|
+
// names are chained; anything else is reported and skipped, never interpolated.
|
|
389
|
+
const HOOK_FILE_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
|
390
|
+
const chainableHook = (name) => !name.endsWith(".sample") && HOOK_FILE_NAME.test(name);
|
|
391
|
+
|
|
392
|
+
// ---- result cache inputs ------------------------------------------------------------------------------------
|
|
393
|
+
|
|
394
|
+
// The build inputs that change how every crate compiles, relative to the Cargo workspace root, plus the gate's own config.
|
|
395
|
+
const SHARED_RUST_INPUTS = ["Cargo.lock", "Cargo.toml", "rust-toolchain", "rust-toolchain.toml", ".cargo", "deny.toml", "clippy.toml", ".clippy.toml", "rustfmt.toml", ".rustfmt.toml", "vendor"];
|
|
396
|
+
const GATE_CONFIG = "ci/gate.config.json";
|
|
397
|
+
// What cargo, clippy, rustfmt and cargo-deny also read from EVERY ancestor directory of the workspace (the repository root and each
|
|
398
|
+
// directory between it and the workspace: they search upward), relative to that directory.
|
|
399
|
+
const ANCESTOR_RUST_INPUTS = [".cargo", "rust-toolchain", "rust-toolchain.toml", "clippy.toml", ".clippy.toml", "rustfmt.toml", ".rustfmt.toml", "deny.toml"];
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* The unit a cargo scan id belongs to (clippy:x, check:x, check-features:x:0, check-dependent:x, doc:x, test:rust:x,
|
|
403
|
+
* test:rust-serial:x:1), else null. A unit below the root is named `<cargoDir>:<crate>`, so the colon no longer ends the name:
|
|
404
|
+
* pass `known` (the set of unit names) and the longest known name wins, with or without the trailing feature-set index. Without
|
|
405
|
+
* `known` the name is the first segment, as for a repository with one root workspace.
|
|
406
|
+
*/
|
|
407
|
+
export function rustUnitOfScanId(id, known = null) {
|
|
408
|
+
const parts = String(id).split(":");
|
|
409
|
+
let rest;
|
|
410
|
+
if (["clippy", "check", "check-features", "check-dependent", "doc"].includes(parts[0])) rest = parts.slice(1);
|
|
411
|
+
else if (parts[0] === "test" && (parts[1] === "rust" || parts[1] === "rust-serial")) rest = parts.slice(2);
|
|
412
|
+
else return null;
|
|
413
|
+
if (!(known instanceof Set)) return rest[0] ?? null;
|
|
414
|
+
for (let take = rest.length; take >= 1; take -= 1) { const name = rest.slice(0, take).join(":"); if (known.has(name)) return name; }
|
|
415
|
+
return null;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Compile scans (clippy, check, check-features, check-dependent, doc) read the crate's closure plus whatever its sources pull
|
|
419
|
+
// in from outside it: include!, include_str!, include_bytes!, a path literal with "../", and a build.rs that reads files.
|
|
420
|
+
const COMPILE_SCAN_PREFIXES = new Set(["clippy", "check", "check-features", "check-dependent", "doc"]);
|
|
421
|
+
const INCLUDE_MACRO = /\binclude(?:_str|_bytes)?\s*!\s*\(/g;
|
|
422
|
+
const STRING_BODY = String.raw`((?:[^"\\\n]|\\.)*)`;
|
|
423
|
+
const INCLUDE_LITERAL = new RegExp(String.raw`^\s*"` + STRING_BODY + String.raw`"\s*[,)]`);
|
|
424
|
+
const INCLUDE_MANIFEST = new RegExp(String.raw`^\s*concat!\s*\(\s*env!\s*\(\s*"CARGO_MANIFEST_DIR"\s*\)\s*,\s*"` + STRING_BODY + String.raw`"\s*\)\s*[,)]`);
|
|
425
|
+
const INCLUDE_OUT_DIR = /^\s*concat!\s*\(\s*env!\s*\(\s*"OUT_DIR"\s*\)/;
|
|
426
|
+
// Any path-like token holding a "../" segment, inside a string literal or not: a stray quote cannot hide one.
|
|
427
|
+
const PARENT_PATH = /(?:[\w@.-]+[\\/])*\.\.(?:[\\/][\w@.-]*)+/g;
|
|
428
|
+
const BUILD_READS_FILES = /\b(?:fs::(?:read|read_to_string|read_dir|metadata|File)|File::open|OpenOptions|read_to_string|read_dir|walkdir|glob)\b/;
|
|
429
|
+
const BUILD_LEAVES_CRATE = /\.(?:parent|ancestors|pop)\s*\(|CARGO_WORKSPACE_DIR|workspace_root|current_dir/;
|
|
430
|
+
const RERUN_LITERAL = /cargo::?rerun-if-changed=([^"\\{}\n]+)/g;
|
|
431
|
+
// `#[path = "x.rs"]` on a module, and a plain `mod x;` declaration (no body): both name another source file.
|
|
432
|
+
const PATH_ATTRIBUTE = new RegExp(String.raw`#\s*!?\s*\[\s*path\s*=\s*"` + STRING_BODY + String.raw`"\s*\]`, "g");
|
|
433
|
+
const MOD_DECLARATION = /(?:^|[;{}\s\]])(?:pub(?:\s*\([^)]*\))?\s+)?mod\s+([A-Za-z_][A-Za-z0-9_]*)\s*;/g;
|
|
434
|
+
// The most source files one crate's traversal reads before it gives up and keys the scan on the whole tree.
|
|
435
|
+
export const EXTERNAL_INPUT_FILE_LIMIT = 10_000;
|
|
436
|
+
|
|
437
|
+
const within = (dir, p) => dir === "." || p === dir || p.startsWith(`${dir}/`);
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Repository paths a crate's sources reach OUTSIDE their own directory, found statically: the literal targets of include!,
|
|
441
|
+
* include_str! and include_bytes! (relative to the including file, or to CARGO_MANIFEST_DIR through concat!), every string
|
|
442
|
+
* literal containing "../" (tried against the including file's and the crate's directory; only paths that exist count), and the
|
|
443
|
+
* rerun-if-changed literals of a build.rs that reads files. `unresolved` names the first reference that cannot be resolved
|
|
444
|
+
* statically (a computed include path, a build.rs that reads files and walks out of its crate, a path that leaves the
|
|
445
|
+
* repository, an unreadable file, more than EXTERNAL_INPUT_FILE_LIMIT files); the caller then keys on the whole tree. `exists(path)`
|
|
446
|
+
* says whether a repo path exists.
|
|
447
|
+
*
|
|
448
|
+
* The traversal runs to a fixed point: every out-of-crate .rs file reached is itself scanned for the same references (include!,
|
|
449
|
+
* include_str!, include_bytes!, `#[path = ".."]`, `mod x;` declarations of an out-of-crate file, "../" literals), whatever the
|
|
450
|
+
* depth (lib.rs -> shared/one.rs -> shared/two.rs -> shared/three.rs reaches three.rs), with a visited set so cycles end.
|
|
451
|
+
*/
|
|
452
|
+
// A source (a test, usually) that navigates out of its crate at run time without naming the path: the workspace root, the
|
|
453
|
+
// current directory, a bare ".." segment, or the manifest directory's parent. Its tests may read anything in the repository.
|
|
454
|
+
const TEST_LEAVES_CRATE = /CARGO_WORKSPACE_DIR|workspace_root|current_dir\s*\(|"\.\.\/?"|CARGO_MANIFEST_DIR[^;\n]{0,120}\.parent\s*\(|\.ancestors\s*\(/;
|
|
455
|
+
|
|
456
|
+
export function externalInputs({ dir, files, readBlob, exists, includeRuntimePaths = true }) {
|
|
457
|
+
const paths = new Set(); let unresolved = null; let leavesCrate = null;
|
|
458
|
+
const stop = (why) => { unresolved ??= why; };
|
|
459
|
+
// Resolve `rel` against each base in turn; the path counts when it exists inside the repository and outside the crate. A
|
|
460
|
+
// reference that leaves the repository from EVERY base is unresolvable (a path literal is tried against two bases, only
|
|
461
|
+
// one of which is the one the code means).
|
|
462
|
+
const consider = (from, bases, rel) => {
|
|
463
|
+
const raw = String(rel).replaceAll("\\", "/").replace(/^\/+/, "");
|
|
464
|
+
if (!raw || /^[A-Za-z]:/.test(raw)) return;
|
|
465
|
+
let escapes = 0;
|
|
466
|
+
for (const base of bases) {
|
|
467
|
+
const resolved = path.posix.normalize(path.posix.join(base, raw)).replace(/\/+$/, "");
|
|
468
|
+
if (resolved === ".." || resolved.startsWith("../")) { escapes += 1; continue; }
|
|
469
|
+
if (within(dir, resolved) || paths.has(resolved)) continue;
|
|
470
|
+
if (exists(resolved)) { paths.add(resolved); if (resolved.endsWith(".rs")) enqueue(resolved, { external: true }); }
|
|
471
|
+
}
|
|
472
|
+
if (escapes === bases.length) stop(`${from}: ${JSON.stringify(rel)} leaves the repository`);
|
|
473
|
+
};
|
|
474
|
+
const seen = new Set(); const queue = [];
|
|
475
|
+
const enqueue = (file, options = {}) => {
|
|
476
|
+
if (seen.has(file)) return;
|
|
477
|
+
if (seen.size >= EXTERNAL_INPUT_FILE_LIMIT) { stop(`more than ${EXTERNAL_INPUT_FILE_LIMIT} source files are reachable`); return; }
|
|
478
|
+
seen.add(file); queue.push([file, options]);
|
|
479
|
+
};
|
|
480
|
+
const scan = (file, { build = false, external = false } = {}) => {
|
|
481
|
+
let text;
|
|
482
|
+
try { text = readBlob(file); } catch (e) { stop(`${file}: unreadable (${e.message})`); return; }
|
|
483
|
+
if (typeof text !== "string") { stop(`${file}: unreadable`); return; }
|
|
484
|
+
const fileDir = path.posix.dirname(file);
|
|
485
|
+
for (const m of text.matchAll(INCLUDE_MACRO)) {
|
|
486
|
+
const rest = text.slice(m.index + m[0].length, m.index + m[0].length + 600);
|
|
487
|
+
let hit;
|
|
488
|
+
if ((hit = INCLUDE_LITERAL.exec(rest))) consider(file, [fileDir], hit[1]);
|
|
489
|
+
else if ((hit = INCLUDE_MANIFEST.exec(rest))) consider(file, [dir], hit[1]);
|
|
490
|
+
else if (INCLUDE_OUT_DIR.test(rest)) continue;
|
|
491
|
+
else stop(`${file}: include macro with a computed path`);
|
|
492
|
+
}
|
|
493
|
+
// Publication content follows compile inputs; cache callers also retain conservative runtime-path reach.
|
|
494
|
+
if (includeRuntimePaths || build) for (const m of text.matchAll(PARENT_PATH)) {
|
|
495
|
+
consider(file, [fileDir, dir], m[0]);
|
|
496
|
+
}
|
|
497
|
+
// A module attribute names its file relative to the including file's directory (tried against the crate directory too).
|
|
498
|
+
for (const m of text.matchAll(PATH_ATTRIBUTE)) consider(file, [fileDir, dir], m[1]);
|
|
499
|
+
// An out-of-crate file's own `mod x;` declarations: x.rs / x/mod.rs beside a mod-rs file (mod.rs, lib.rs, main.rs), else under the file's own directory.
|
|
500
|
+
if (external) {
|
|
501
|
+
const base = path.posix.basename(file); const stem = base.slice(0, -3);
|
|
502
|
+
const home = ["mod.rs", "lib.rs", "main.rs"].includes(base) ? fileDir : path.posix.join(fileDir, stem);
|
|
503
|
+
for (const m of text.matchAll(MOD_DECLARATION)) for (const candidate of [`${m[1]}.rs`, `${m[1]}/mod.rs`]) consider(file, [home], candidate);
|
|
504
|
+
}
|
|
505
|
+
if (build && BUILD_READS_FILES.test(text)) {
|
|
506
|
+
for (const m of text.matchAll(RERUN_LITERAL)) consider(file, [dir], m[1].trim());
|
|
507
|
+
if (BUILD_LEAVES_CRATE.test(text)) stop(`${file}: a build script that reads files and navigates out of its crate`);
|
|
508
|
+
}
|
|
509
|
+
if (includeRuntimePaths && !build && !external && leavesCrate === null && TEST_LEAVES_CRATE.test(text)) leavesCrate = file;
|
|
510
|
+
};
|
|
511
|
+
for (const file of files) if (file.endsWith(".rs")) enqueue(file, { build: path.posix.basename(file) === "build.rs" && path.posix.dirname(file) === dir });
|
|
512
|
+
// Fixed point: an out-of-crate .rs file that is reached is itself a source, so its own references are followed, and theirs.
|
|
513
|
+
while (queue.length) { const [file, options] = queue.pop(); scan(file, options); }
|
|
514
|
+
// `leavesCrate`: the first in-crate source that navigates out of the crate at run time without a literal path (tests key on
|
|
515
|
+
// the whole tree then, unless ci/gate.config.json testInputs names what they read). Compile scans ignore it.
|
|
516
|
+
return { paths: [...paths].sort(), unresolved, leavesCrate };
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* The root manifest as a compile input: every table except `[patch.*]` (a patch changes a lock package's source, which the lock
|
|
521
|
+
* projection records) and the membership arrays of `[workspace]` (a member is a lock package); comment-only lines dropped.
|
|
522
|
+
* `[workspace.dependencies]` stays whole: its feature lists reach every `workspace = true` dependent without touching the lock.
|
|
523
|
+
*/
|
|
524
|
+
export function manifestCompileProjection(text) {
|
|
525
|
+
const out = {};
|
|
526
|
+
const strip = (t) => String(t ?? "").split("\n").filter((l) => !/^\s*(#.*)?$/.test(l)).join("\n");
|
|
527
|
+
for (const [name, body] of manifestTables(text)) {
|
|
528
|
+
if (/^patch\./.test(name)) continue;
|
|
529
|
+
const kept = (name === "workspace" ? strip(body).replace(WORKSPACE_MEMBERSHIP_KEYS, "") : strip(body)).trim();
|
|
530
|
+
if (kept) out[name] = kept;
|
|
531
|
+
}
|
|
532
|
+
return out;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const plainRelative = (p) => typeof p === "string" && p !== "" && !path.posix.isAbsolute(p) && !/^[A-Za-z]:/.test(p) && !p.includes("\\") && path.posix.normalize(p) === p && p !== "." && p !== ".." && !p.startsWith("../");
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* The input digest function for the result cache: scan => string, or null when the scan must not be cached.
|
|
539
|
+
* `objectId(path)` is the git blob/tree id of a repo-relative path in the gated commit, or null when the path is absent
|
|
540
|
+
* (it throws when git cannot answer, which makes the scan uncacheable); `headTree` is that commit's tree id.
|
|
541
|
+
* `listFiles(dir)` lists the gated commit's files under a directory and `readBlob(path)` reads one (both are required for any
|
|
542
|
+
* per-crate key: without them a crate's reach outside its own directory is unknown and the whole tree is used).
|
|
543
|
+
*
|
|
544
|
+
* - A compile scan (clippy, check, check-features, check-dependent, doc) of crate X digests the object ids of X and of every
|
|
545
|
+
* workspace member in X's lock closure, the shared build inputs projected to what X consumes (the lock entries of X's closure,
|
|
546
|
+
* the root manifest without its [patch] tables and membership arrays, the `.cargo` files except audit.toml, the toolchain,
|
|
547
|
+
* clippy and rustfmt files of the workspace and every ancestor, the vendored crates in the closure, X's own ci/gate.config.json
|
|
548
|
+
* entry) and every repository path those members' sources reach outside their own directories (externalInputs). A reference
|
|
549
|
+
* that cannot be resolved statically keys the scan on the whole tree. deny.toml is no compile input (it feeds class A, never cached).
|
|
550
|
+
* - A Rust test scan (test:rust*, test:rust-serial*) is keyed the same way, plus ci/gate.config.json `rust.<crate>.testInputs`
|
|
551
|
+
* when declared. Without a declaration, a closure source that navigates out of its crate at run time without a literal path
|
|
552
|
+
* (externalInputs `leavesCrate`: workspace root, current_dir, a bare "..", the manifest directory's parent) keys the scan on the
|
|
553
|
+
* whole HEAD tree, since its tests may read anything.
|
|
554
|
+
* - An npm package scan (test:npm, tsc, eslint) keys on the package's workspace closure (its `workspace:` dependencies,
|
|
555
|
+
* transitively, by package name) and the root npm inputs; test:scripts-ci on scripts/, ci/, .github/, packages/release and the
|
|
556
|
+
* root install graph. Every other class B scan (generated-docs, swift-build) uses the whole tree.
|
|
557
|
+
* - Class A: cargo-deny, cargo-audit, pnpm-audit, pnpm-lockfile and advisory-exceptions are never cached (live answers); rustfmt,
|
|
558
|
+
* node-check, actionlint and the parse scans key on their files' blob ids; cargo-package-list on the crate, the manifest projection
|
|
559
|
+
* and its lock entry; the rest on the whole tree.
|
|
560
|
+
*/
|
|
561
|
+
// Root npm inputs every package scan reads: the install graph and the shared tool configuration.
|
|
562
|
+
const SHARED_NPM_INPUTS = ["package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml", ".npmrc", "tsconfig.json", "tsconfig.base.json", "eslint.config.js", "eslint.config.mjs", ".eslintrc.json", "biome.json", "vitest.config.ts", "vitest.config.mjs", "vitest.workspace.ts"];
|
|
563
|
+
// The gate's own test suite reads the CI scripts, the gate config and the release package it imports from.
|
|
564
|
+
const SCRIPTS_CI_INPUTS = ["scripts", "ci", ".github", "packages/release", "package.json", "pnpm-lock.yaml"];
|
|
565
|
+
|
|
566
|
+
export function buildCacheDigest({ objectId, headTree, rustUnits = [], npmUnits = [], cargoDir = ".", rustLock = null, rustLocks = null, crateFeatures = {}, listFiles = null, readBlob = null }) {
|
|
567
|
+
const memo = new Map(); const reachMemos = new Map(); const externalMemo = new Map();
|
|
568
|
+
const idOf = (p) => { if (!memo.has(p)) memo.set(p, p === "." ? headTree : objectId(p)); return memo.get(p); };
|
|
569
|
+
// npm: a package's test, tsc and eslint scans read the package, every workspace package it depends on (package.json
|
|
570
|
+
// `workspace:` dependencies, transitively) and the root npm inputs. Package names come from the manifests themselves.
|
|
571
|
+
const npmByUnit = new Map(npmUnits.map((u) => [u.name, u]));
|
|
572
|
+
const npmManifestMemo = new Map();
|
|
573
|
+
const npmManifest = (u) => {
|
|
574
|
+
if (!npmManifestMemo.has(u.dir)) {
|
|
575
|
+
let m = null;
|
|
576
|
+
try { m = JSON.parse(String(readBlob(u.dir === "." ? "package.json" : `${u.dir}/package.json`)).replace(/^/, "")); } catch { m = null; }
|
|
577
|
+
npmManifestMemo.set(u.dir, m);
|
|
578
|
+
}
|
|
579
|
+
return npmManifestMemo.get(u.dir);
|
|
580
|
+
};
|
|
581
|
+
let npmByPkgName = null;
|
|
582
|
+
const npmClosure = (unit) => {
|
|
583
|
+
if (npmByPkgName === null) { npmByPkgName = new Map(); for (const u of npmUnits) { const m = npmManifest(u); if (m?.name) npmByPkgName.set(m.name, u); } }
|
|
584
|
+
const seen = new Map([[unit.name, unit]]); const stack = [unit]; let unresolved = false;
|
|
585
|
+
while (stack.length) {
|
|
586
|
+
const u = stack.pop(); const m = npmManifest(u);
|
|
587
|
+
if (!m) { unresolved = true; continue; }
|
|
588
|
+
for (const section of ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"]) {
|
|
589
|
+
for (const [name, spec] of Object.entries(m[section] ?? {})) {
|
|
590
|
+
if (!/^(workspace:|link:|file:)/.test(String(spec))) continue;
|
|
591
|
+
const dep = npmByPkgName.get(name);
|
|
592
|
+
if (!dep) { unresolved = true; continue; }
|
|
593
|
+
if (!seen.has(dep.name)) { seen.set(dep.name, dep); stack.push(dep); }
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
return { members: [...seen.values()].sort((a, b) => (a.name < b.name ? -1 : 1)), unresolved };
|
|
598
|
+
};
|
|
599
|
+
// A unit belongs to the Cargo workspace it names (rustUnits[].cargoDir, else the global one) and is a package of that workspace's
|
|
600
|
+
// own lock under its crate name (rustUnits[].crate, else its name); its closure never crosses into another workspace.
|
|
601
|
+
const wsOf = (u) => u.cargoDir ?? cargoDir;
|
|
602
|
+
const crateOf = (u) => u.crate ?? u.name;
|
|
603
|
+
const lockFor = (ws) => (rustLocks?.[ws] instanceof Map ? rustLocks[ws] : ws === cargoDir && rustLock instanceof Map ? rustLock : null);
|
|
604
|
+
const inWs = (ws, f) => (ws === "." ? f : `${ws}/${f}`);
|
|
605
|
+
const byName = new Map(rustUnits.map((u) => [u.name, u]));
|
|
606
|
+
const known = new Set(byName.keys());
|
|
607
|
+
const byCrate = new Map(rustUnits.map((u) => [`${wsOf(u)}\0${crateOf(u)}`, u]));
|
|
608
|
+
const whole = () => canonicalJson({ kind: "tree", tree: headTree });
|
|
609
|
+
const externalOf = (member) => {
|
|
610
|
+
if (!externalMemo.has(member.dir)) {
|
|
611
|
+
externalMemo.set(member.dir, externalInputs({ dir: member.dir, files: listFiles(member.dir), readBlob, exists: (p) => { const id = idOf(p); return id !== null && id !== undefined; } }));
|
|
612
|
+
}
|
|
613
|
+
return externalMemo.get(member.dir);
|
|
614
|
+
};
|
|
615
|
+
// Class A: the live checks are never cached (their answer depends on the advisory databases and the registry of the day); a
|
|
616
|
+
// content scan with an explicit file list is keyed on those files' blob ids; a crate's package list on the crate, its manifest
|
|
617
|
+
// projection and its own lock entry; anything else (cargo metadata, licence, ownership catalog, secret scan) on the whole tree,
|
|
618
|
+
// so it is reused for a rerun of the same commit and nothing else.
|
|
619
|
+
const liveScan = (id) => /^(cargo-deny|cargo-audit|pnpm-audit|pnpm-lockfile|advisory-exceptions)(:|$)/.test(id);
|
|
620
|
+
const fileList = (scan) => {
|
|
621
|
+
if (Array.isArray(scan.files) && scan.files.length) return scan.files;
|
|
622
|
+
const args = Array.isArray(scan.args) ? scan.args : [];
|
|
623
|
+
if (/^rustfmt:/.test(scan.id)) return args.filter((x) => /\.rs$/.test(x));
|
|
624
|
+
if (/^node-check:/.test(scan.id)) return [scan.id.slice("node-check:".length)];
|
|
625
|
+
if (scan.id === "actionlint") return [...args.filter((x) => /\.ya?ml$/.test(x)), ".github/actionlint.yaml"];
|
|
626
|
+
return null;
|
|
627
|
+
};
|
|
628
|
+
return (scan) => {
|
|
629
|
+
if (scan.class === "A") {
|
|
630
|
+
if (liveScan(scan.id)) return null;
|
|
631
|
+
try {
|
|
632
|
+
const files = fileList(scan);
|
|
633
|
+
if (files) return canonicalJson({ kind: "files", id: scan.id, files: [...new Set(files)].sort().map((p) => [p, idOf(p)]) });
|
|
634
|
+
if (/^cargo-package-list:/.test(scan.id)) {
|
|
635
|
+
const unit = scan.id.slice("cargo-package-list:".length);
|
|
636
|
+
const head = byName.get(unit);
|
|
637
|
+
const ws = head ? wsOf(head) : null; const lock = ws === null ? null : lockFor(ws);
|
|
638
|
+
if (!head || !lock || typeof readBlob !== "function") return whole();
|
|
639
|
+
const manifestPath = inWs(ws, "Cargo.toml");
|
|
640
|
+
let manifest;
|
|
641
|
+
try { manifest = manifestCompileProjection(readBlob(manifestPath)); } catch { manifest = ["raw", manifestPath, idOf(manifestPath)]; }
|
|
642
|
+
return canonicalJson({ kind: "package-list", unit, dir: [head.dir, idOf(head.dir)], manifest, lock: [[crateOf(head), lock.get(crateOf(head))?.signatures ?? []]] });
|
|
643
|
+
}
|
|
644
|
+
return whole();
|
|
645
|
+
} catch { return null; }
|
|
646
|
+
}
|
|
647
|
+
if (scan.class !== "B") return null;
|
|
648
|
+
try {
|
|
649
|
+
const unit = rustUnitOfScanId(scan.id, known);
|
|
650
|
+
if (!unit) {
|
|
651
|
+
if (typeof readBlob !== "function") return whole();
|
|
652
|
+
// npm package scans: the package's workspace closure plus the root npm inputs.
|
|
653
|
+
const npmMatch = /^(test:npm|tsc|eslint):(.+)$/.exec(scan.id);
|
|
654
|
+
if (npmMatch && npmByUnit.has(npmMatch[2])) {
|
|
655
|
+
const { members, unresolved } = npmClosure(npmByUnit.get(npmMatch[2]));
|
|
656
|
+
if (unresolved) return whole();
|
|
657
|
+
return canonicalJson({ kind: "npm", id: scan.id, members: members.map((m) => [m.name, m.dir, idOf(m.dir)]), shared: SHARED_NPM_INPUTS.map((p) => [p, idOf(p)]) });
|
|
658
|
+
}
|
|
659
|
+
if (scan.id === "test:scripts-ci") return canonicalJson({ kind: "dirs", id: scan.id, inputs: SCRIPTS_CI_INPUTS.map((p) => [p, idOf(p)]) });
|
|
660
|
+
return whole();
|
|
661
|
+
}
|
|
662
|
+
const head = byName.get(unit);
|
|
663
|
+
const ws = wsOf(head); const lock = lockFor(ws);
|
|
664
|
+
if (!lock || typeof listFiles !== "function" || typeof readBlob !== "function") return whole();
|
|
665
|
+
const testScan = /^test:rust(?:-serial)?:/.test(scan.id);
|
|
666
|
+
const ownConfig = crateFeatures?.[unit] ?? crateFeatures?.[crateOf(head)] ?? null;
|
|
667
|
+
let testInputs = []; let declaredInputs = false;
|
|
668
|
+
if (testScan) {
|
|
669
|
+
const declared = ownConfig?.testInputs;
|
|
670
|
+
if (declared !== undefined) {
|
|
671
|
+
if (!Array.isArray(declared) || declared.length === 0 || !declared.every(plainRelative)) return whole();
|
|
672
|
+
testInputs = [...new Set(declared)].sort(); declaredInputs = true;
|
|
673
|
+
}
|
|
674
|
+
} else if (!COMPILE_SCAN_PREFIXES.has(String(scan.id).split(":")[0])) return whole();
|
|
675
|
+
if (!reachMemos.has(ws)) reachMemos.set(ws, new Map());
|
|
676
|
+
const reached = [...reach(lock, crateOf(head), reachMemos.get(ws))];
|
|
677
|
+
const closure = [head, ...reached.map((n) => byCrate.get(`${ws}\0${n}`)).filter(Boolean)].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
678
|
+
const extra = new Set(testInputs);
|
|
679
|
+
for (const member of closure) {
|
|
680
|
+
const found = externalOf(member);
|
|
681
|
+
if (found.unresolved) return whole();
|
|
682
|
+
if (testScan && !declaredInputs && found.leavesCrate) return whole();
|
|
683
|
+
for (const p of found.paths) extra.add(p);
|
|
684
|
+
}
|
|
685
|
+
// The shared inputs, projected to what this crate consumes.
|
|
686
|
+
const dirs = ancestorDirsOf(ws);
|
|
687
|
+
const ancestors = dirs.filter((dir) => dir !== ws);
|
|
688
|
+
const lockNames = [crateOf(head), ...reached].sort();
|
|
689
|
+
const lockEntries = lockNames.map((n) => [n, lock.get(n)?.signatures ?? []]);
|
|
690
|
+
const vendored = lockNames.map((n) => `vendor/${n}`).filter((p) => idOf(p) !== null && idOf(p) !== undefined).map((p) => [p, idOf(p)]);
|
|
691
|
+
const manifestPath = inWs(ws, "Cargo.toml");
|
|
692
|
+
let manifest;
|
|
693
|
+
try { manifest = manifestCompileProjection(readBlob(manifestPath)); } catch { manifest = ["raw", manifestPath, idOf(manifestPath)]; }
|
|
694
|
+
const cargoConfig = dirs.flatMap((dir) => {
|
|
695
|
+
const cfg = inWs(dir, ".cargo");
|
|
696
|
+
if (idOf(cfg) === null || idOf(cfg) === undefined) return [];
|
|
697
|
+
try { return listFiles(cfg).filter((f) => !f.endsWith("/audit.toml")).sort().map((f) => [f, idOf(f)]); } catch { return [[cfg, idOf(cfg)]]; }
|
|
698
|
+
});
|
|
699
|
+
const toolFiles = [...SHARED_RUST_INPUTS.filter((p) => !["Cargo.lock", "Cargo.toml", ".cargo", "deny.toml", "vendor"].includes(p)).map((p) => inWs(ws, p)),
|
|
700
|
+
...ancestors.flatMap((dir) => ANCESTOR_RUST_INPUTS.filter((p) => ![".cargo", "deny.toml"].includes(p)).map((p) => inWs(dir, p)))];
|
|
701
|
+
return canonicalJson({
|
|
702
|
+
kind: testScan ? "rust-test" : "rust", unit, cargoDir: ws,
|
|
703
|
+
members: closure.map((m) => [m.name, m.dir, idOf(m.dir)]),
|
|
704
|
+
lock: lockEntries, manifest, cargoConfig, vendored, config: ownConfig,
|
|
705
|
+
shared: [...new Set(toolFiles)].sort().map((p) => [p, idOf(p)]),
|
|
706
|
+
external: [...extra].sort().map((p) => [p, idOf(p)]),
|
|
707
|
+
});
|
|
708
|
+
} catch { return null; }
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// ---- quiet lease ------------------------------------------------------------------------------------------------
|
|
713
|
+
|
|
714
|
+
// The broker's quiet lease TTL ceiling is 30 minutes (RustRight, monotonic clock); an expired lease is not refused, the lane just
|
|
715
|
+
// loses its protection, so a quiet lane longer than this is reported by the gate when it happens, never hidden.
|
|
716
|
+
export const QUIET_LEASE_TTL_MS = 30 * 60 * 1000;
|
|
717
|
+
export const QUIET_LEASE_WAIT_DRAIN_MS = 2 * 60 * 1000;
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* The runScans `quietLane` for a host with RustRight (the reason carries no spaces: a .cmd shim on Windows re-splits its arguments): `rustright quiet acquire` before the quiet test scans (waits up to two
|
|
721
|
+
* minutes for running broker jobs to drain), `RUSTRIGHT_QUIET_LEASE=<id>` in their environment, `rustright quiet release` after.
|
|
722
|
+
* Without `rustright` on PATH, or when the broker refuses, the lane runs without a lease and the gate says so: the tests still run,
|
|
723
|
+
* their verdict is simply taken on a possibly busy host. `o.spawn` is the gate's synchronous spawner.
|
|
724
|
+
*/
|
|
725
|
+
export function quietLeaseLane(o, { tag = "rightkit-gate:", reason = "rightkit-gate-quiet-tests", ttlMs = QUIET_LEASE_TTL_MS, waitDrainMs = QUIET_LEASE_WAIT_DRAIN_MS, pid = process.pid } = {}) {
|
|
726
|
+
const which = (cmd) => {
|
|
727
|
+
const r = o.spawn(o.platform === "win32" ? "where" : "which", [cmd]);
|
|
728
|
+
return failed(r) ? null : (text(r).split(/\r?\n/).map((l) => l.trim()).find(Boolean) ?? null);
|
|
729
|
+
};
|
|
730
|
+
return {
|
|
731
|
+
acquire: async () => {
|
|
732
|
+
if (!which("rustright")) { o.out(`${tag} note: no rustright on PATH; quiet test scans run without a broker lease`); return null; }
|
|
733
|
+
const started = Date.now();
|
|
734
|
+
const r = o.spawn("rustright", ["quiet", "acquire", "--ttl-ms", String(ttlMs), "--reason", reason, "--wait-drain-ms", String(waitDrainMs), "--holder-pid", String(pid), "--json"]);
|
|
735
|
+
if (failed(r)) { o.out(`${tag} note: broker quiet lease refused (${(why(r) || `exit ${r?.status}`).slice(0, 200)}); quiet test scans run without a lease`); return null; }
|
|
736
|
+
let id = null;
|
|
737
|
+
// The CLI prints the broker's QUIET_LEASE event: { type, granted, state, id } when held; a refusal exits non-zero above.
|
|
738
|
+
try { const j = JSON.parse(text(r)); id = j?.granted === false ? null : (j?.id ?? j?.lease?.id ?? null); } catch { id = null; }
|
|
739
|
+
if (typeof id !== "string" || !id) { o.out(`${tag} note: broker quiet lease answer has no lease id (${text(r).trim().slice(0, 200)}); quiet test scans run without a lease`); return null; }
|
|
740
|
+
o.out(`${tag} broker quiet lease ${id} held (ttl ${Math.round(ttlMs / 60000)} min; waited ${Math.round((Date.now() - started) / 1000)} s for the broker to drain)`);
|
|
741
|
+
return { id, env: { RUSTRIGHT_QUIET_LEASE: id }, at: Date.now() };
|
|
742
|
+
},
|
|
743
|
+
release: async (id) => {
|
|
744
|
+
const r = o.spawn("rustright", ["quiet", "release", String(id)]);
|
|
745
|
+
if (failed(r)) o.out(`${tag} note: broker quiet lease ${id} release failed (${(why(r) || `exit ${r?.status}`).slice(0, 200)}); it expires on its own`);
|
|
746
|
+
else o.out(`${tag} broker quiet lease ${id} released`);
|
|
747
|
+
},
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// ---- command resolution -------------------------------------------------------------------------------------
|
|
752
|
+
|
|
753
|
+
// Cargo only through the broker: `rightkit cargo`, or a `cargo` on PATH that is the managed shim (it runs cargo-guard.mjs).
|
|
754
|
+
function resolveCargoCommand(o) {
|
|
755
|
+
const which = (cmd) => {
|
|
756
|
+
const r = o.spawn(o.platform === "win32" ? "where" : "which", [cmd]);
|
|
757
|
+
return failed(r) ? [] : text(r).split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
758
|
+
};
|
|
759
|
+
if (which("rightkit").length) return ["rightkit", "cargo"];
|
|
760
|
+
const first = which("cargo")[0];
|
|
761
|
+
if (first) { try { if (o.readFile(first).includes("cargo-guard.mjs")) return ["cargo"]; } catch { /* unreadable: not the shim */ } }
|
|
762
|
+
return null;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// ---- install-hook -------------------------------------------------------------------------------------------
|
|
766
|
+
|
|
767
|
+
/** The first tracked package.json (or the root one when git cannot list) that declares @rightkit/release, or is that package itself. */
|
|
768
|
+
function releaseProvider(o, root, git) {
|
|
769
|
+
let files = ["package.json"];
|
|
770
|
+
const listed = git("ls-files", "-z", "--", ":(glob)**/package.json");
|
|
771
|
+
if (!failed(listed)) files = text(listed).split("\0").filter(Boolean).map(posix);
|
|
772
|
+
for (const file of files) {
|
|
773
|
+
const abs = path.join(root, file);
|
|
774
|
+
if (!o.exists(abs)) continue;
|
|
775
|
+
let manifest;
|
|
776
|
+
try { manifest = JSON.parse(o.readFile(abs).replace(/^\uFEFF/, "")); } catch { continue; }
|
|
777
|
+
if (manifest?.name === "@rightkit/release") return file;
|
|
778
|
+
for (const section of ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"]) if (manifest?.[section] && Object.hasOwn(manifest[section], "@rightkit/release")) return file;
|
|
779
|
+
}
|
|
780
|
+
return null;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function installHook(o, root, force) {
|
|
784
|
+
const git = (...a) => o.spawn("git", a, { cwd: root });
|
|
785
|
+
const hooksDir = path.join(root, ".githooks");
|
|
786
|
+
const hookPath = path.join(hooksDir, "pre-push");
|
|
787
|
+
const installed = "node_modules/@rightkit/release/bin/rightkit-gate.mjs";
|
|
788
|
+
const nodePath = o.exists(path.join(root, installed)) ? installed : posix(path.relative(root, fileURLToPath(import.meta.url)));
|
|
789
|
+
const wanted = hookText(nodePath);
|
|
790
|
+
const hasHook = o.exists(hookPath);
|
|
791
|
+
const current = hasHook ? o.readFile(hookPath).replace(/\r\n/g, "\n") : null;
|
|
792
|
+
if (hasHook && current !== wanted && !force) {
|
|
793
|
+
o.err(`rightkit-gate: ${path.join(".githooks", "pre-push")} already exists and differs; nothing changed. Review it, or run \`rightkit-gate install-hook --force\` to replace it.`);
|
|
794
|
+
return 1;
|
|
795
|
+
}
|
|
796
|
+
// core.hooksPath=.githooks makes git ignore .git/hooks: chain the hooks that are there now (pre-push at run time inside the gate hook).
|
|
797
|
+
const gitDirResult = git("rev-parse", "--git-dir");
|
|
798
|
+
let present = [];
|
|
799
|
+
if (!failed(gitDirResult) && text(gitDirResult).trim()) { try { present = o.readdir(path.join(path.resolve(root, text(gitDirResult).trim()), "hooks")); } catch { present = []; } }
|
|
800
|
+
const own = present.filter(chainableHook).sort();
|
|
801
|
+
const skipped = present.filter((name) => !name.endsWith(".sample") && !chainableHook(name)).sort();
|
|
802
|
+
if (skipped.length) o.err(`rightkit-gate: WARNING: not chaining .git/hooks/${skipped.join(", .git/hooks/")}: not a plain hook file name (letters, digits, - and _ only); run it from a hook of its own.`);
|
|
803
|
+
const trampolines = own.filter((name) => name !== "pre-push").map((name) => ({ name, file: path.join(hooksDir, name), wanted: trampolineText(name) }));
|
|
804
|
+
for (const t of trampolines) {
|
|
805
|
+
t.exists = o.exists(t.file);
|
|
806
|
+
t.current = t.exists ? o.readFile(t.file).replace(/\r\n/g, "\n") === t.wanted : false;
|
|
807
|
+
if (t.exists && !t.current && !force) {
|
|
808
|
+
o.err(`rightkit-gate: .githooks/${t.name} already exists and differs; nothing changed. Review it, or run \`rightkit-gate install-hook --force\` to replace it with a trampoline to .git/hooks/${t.name}.`);
|
|
809
|
+
return 1;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
const currentPath = git("config", "--get", "core.hooksPath");
|
|
813
|
+
const existingPath = failed(currentPath) ? "" : text(currentPath).trim();
|
|
814
|
+
if (existingPath && existingPath !== ".githooks" && !force) {
|
|
815
|
+
o.err(`rightkit-gate: core.hooksPath is already ${JSON.stringify(existingPath)}; nothing changed. Run \`rightkit-gate install-hook --force\` to point it at .githooks.`);
|
|
816
|
+
return 1;
|
|
817
|
+
}
|
|
818
|
+
const done = [];
|
|
819
|
+
const put = (file, body, label, note) => {
|
|
820
|
+
o.mkdir(path.dirname(file), { recursive: true }); o.writeFile(file, body);
|
|
821
|
+
try { o.chmod(file, 0o755); } catch { /* not supported here */ }
|
|
822
|
+
done.push(`${label}${note}`);
|
|
823
|
+
};
|
|
824
|
+
if (current === wanted) done.push(".githooks/pre-push already current");
|
|
825
|
+
else put(hookPath, wanted, `${hasHook ? "replaced" : "wrote"} .githooks/pre-push`, ` (${nodePath} without pnpm)`);
|
|
826
|
+
for (const t of trampolines) {
|
|
827
|
+
if (t.current) done.push(`.githooks/${t.name} already current`);
|
|
828
|
+
else put(t.file, t.wanted, `${t.exists ? "replaced" : "wrote"} .githooks/${t.name}`, ` (runs .git/hooks/${t.name})`);
|
|
829
|
+
}
|
|
830
|
+
if (existingPath === ".githooks") done.push("core.hooksPath already .githooks");
|
|
831
|
+
else {
|
|
832
|
+
const set = git("config", "core.hooksPath", ".githooks");
|
|
833
|
+
if (failed(set)) { o.err(`rightkit-gate: git config core.hooksPath failed: ${why(set)}`); return 2; }
|
|
834
|
+
done.push(`set core.hooksPath to .githooks${existingPath ? ` (was ${existingPath})` : ""}`);
|
|
835
|
+
}
|
|
836
|
+
for (const line of done) o.out(`rightkit-gate: ${line}`);
|
|
837
|
+
const chained = own.length ? own.map((name) => (name === "pre-push" ? "pre-push (run first, then the gate)" : name)) : [];
|
|
838
|
+
o.out(`rightkit-gate: chained hooks from .git/hooks: ${chained.length ? chained.join(", ") : "none"}${own.includes("pre-push") ? "" : "; the pre-push hook still runs .git/hooks/pre-push first if one appears later"}`);
|
|
839
|
+
const files = [".githooks/pre-push", ...trampolines.map((t) => `.githooks/${t.name}`)];
|
|
840
|
+
o.out(`rightkit-gate: commit the hooks with executable mode (git add --chmod=+x ${files.join(" ")}); the pre-push hook finds rightkit-gate in node_modules/.bin of the repository root, else of any workspace package, else through \`pnpm exec\`.`);
|
|
841
|
+
const provider = releaseProvider(o, root, git);
|
|
842
|
+
if (!provider) o.err("rightkit-gate: WARNING: no package.json in this repository depends on @rightkit/release, so the pre-push hook cannot find the rightkit-gate binary; add @rightkit/release as a devDependency of the root or of a workspace package and install.");
|
|
843
|
+
else o.out(`rightkit-gate: @rightkit/release is declared in ${provider}`);
|
|
844
|
+
return 0;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// ---- main ---------------------------------------------------------------------------------------------------
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* Run the CLI. Returns the exit code (0 pass, 1 blocked or failed, 2 cannot run, 128+n cancelled). `io` overrides the
|
|
851
|
+
* process, fs and output bindings so tests execute nothing real: spawn, exists, readFile, writeFile, mkdir, chmod,
|
|
852
|
+
* readStdin, out, err, cwd, env, platform, abort (an AbortController).
|
|
853
|
+
*/
|
|
854
|
+
export async function main(argv, io = {}) {
|
|
855
|
+
const o = {
|
|
856
|
+
spawn: defaultSpawn, exists: fs.existsSync, readFile: (f) => fs.readFileSync(f, "utf8"), writeFile: fs.writeFileSync, mkdir: fs.mkdirSync, chmod: fs.chmodSync, readdir: fs.readdirSync,
|
|
857
|
+
readStdin: () => { try { return process.stdin.isTTY ? "" : fs.readFileSync(0, "utf8"); } catch { return ""; } },
|
|
858
|
+
out: (m) => console.log(m), err: (m) => console.error(m), cwd: process.cwd(), env: process.env, platform: process.platform, ...io,
|
|
859
|
+
};
|
|
860
|
+
const args = [...argv];
|
|
861
|
+
const option = (name) => { const i = args.indexOf(name); return i < 0 ? undefined : args[i + 1]; };
|
|
862
|
+
if (args.includes("--version") || args.includes("-v")) {
|
|
863
|
+
o.out(JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version);
|
|
864
|
+
return 0;
|
|
865
|
+
}
|
|
866
|
+
if (args.includes("--help") || args.includes("-h")) { o.out(USAGE); return 0; }
|
|
867
|
+
const sub = args[0] && !args[0].startsWith("-") ? args[0] : null;
|
|
868
|
+
if (sub && sub !== "install-hook") { o.err(`rightkit-gate: unknown command ${JSON.stringify(sub)}\n${USAGE}`); return 2; }
|
|
869
|
+
if (args.includes("--base") && (!option("--base") || option("--base").startsWith("--"))) { o.err("rightkit-gate: --base needs a ref"); return 2; }
|
|
870
|
+
|
|
871
|
+
const top = o.spawn("git", ["rev-parse", "--show-toplevel"], { cwd: o.cwd });
|
|
872
|
+
if (failed(top) || !text(top).trim()) { o.err("rightkit-gate: not inside a git repository"); return 2; }
|
|
873
|
+
const root = path.resolve(text(top).trim());
|
|
874
|
+
const git = (...a) => o.spawn("git", a, { cwd: root });
|
|
875
|
+
if (sub === "install-hook") return installHook(o, root, args.includes("--force"));
|
|
876
|
+
|
|
877
|
+
const planOnly = args.includes("--plan");
|
|
878
|
+
let base = option("--base") ?? "origin/main";
|
|
879
|
+
const tag = "rightkit-gate:";
|
|
880
|
+
|
|
881
|
+
// git pre-push: "<local ref> <local sha> <remote ref> <remote sha>" per pushed ref. A plan reads no stdin.
|
|
882
|
+
if (!planOnly) {
|
|
883
|
+
const head = text(git("rev-parse", "HEAD")).trim();
|
|
884
|
+
const lines = o.readStdin().split(/\r?\n/).map((l) => l.trim().split(/\s+/)).filter((f) => f.length === 4);
|
|
885
|
+
for (const [, localSha, remoteRef, remoteSha] of lines) {
|
|
886
|
+
if (ZERO.test(localSha)) continue; // a delete pushes no content
|
|
887
|
+
if (localSha !== head) {
|
|
888
|
+
o.err(`${tag} BLOCKED: pushing ${localSha.slice(0, 10)} to ${remoteRef}, but HEAD is ${head.slice(0, 10)}; broker scans read the checkout, so check the pushed commit out first (git checkout --detach ${localSha.slice(0, 10)}), push HEAD:<branch>, then return`);
|
|
889
|
+
return 1;
|
|
890
|
+
}
|
|
891
|
+
if (!ZERO.test(remoteSha) && !failed(git("cat-file", "-e", `${remoteSha}^{commit}`))) base = remoteSha;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
const tracked = (name) => {
|
|
896
|
+
const r = git("ls-files", "-z", "--", `:(glob)**/${name}`);
|
|
897
|
+
if (failed(r)) throw new Error(`git ls-files failed: ${why(r)}`);
|
|
898
|
+
return text(r).split("\0").filter(Boolean).map(posix);
|
|
899
|
+
};
|
|
900
|
+
const readLive = (f) => o.readFile(f);
|
|
901
|
+
|
|
902
|
+
// Units come from the live checkout (cargo reads it); a dirty checkout then blocks below.
|
|
903
|
+
let rust; let npm; let cargoCommand = ["rightkit", "cargo"];
|
|
904
|
+
try {
|
|
905
|
+
const manifests = tracked("Cargo.toml");
|
|
906
|
+
if (manifests.length) {
|
|
907
|
+
cargoCommand = resolveCargoCommand(o);
|
|
908
|
+
if (!cargoCommand) { o.err(`${tag} BLOCKED: neither \`rightkit\` nor the managed cargo shim (cargo-guard.mjs) is first on PATH; plain cargo is never used`); return 1; }
|
|
909
|
+
if (!o.exists(path.join(root, GRANT_FILE))) { o.err(`${tag} BLOCKED: no local compile grant (${GRANT_FILE}); the broker refuses cargo, so the Rust units cannot be discovered or scanned`); return 1; }
|
|
910
|
+
}
|
|
911
|
+
// Untracked (not ignored) manifests only: git does not descend into ignored directories, so this stays cheap. A member of a
|
|
912
|
+
// workspace that is untracked or ignored is dropped by discoverRustWorkspaces itself.
|
|
913
|
+
const untracked = () => {
|
|
914
|
+
const r = git("ls-files", "-z", "--others", "--exclude-standard", "--", ":(glob)**/Cargo.toml");
|
|
915
|
+
return failed(r) ? [] : text(r).split("\0").filter(Boolean).map(posix);
|
|
916
|
+
};
|
|
917
|
+
// ci/gate.config.json `fixtures` (optional): directories whose manifests are consumer or sample crates, gated through the
|
|
918
|
+
// consumer rows of the crates they exercise (CI treats them the same: scripts/ci/gate.mjs consumerRowInvocations), never as units.
|
|
919
|
+
let fixtures = [];
|
|
920
|
+
try { const f = JSON.parse(o.readFile(path.join(root, GATE_CONFIG))).fixtures; if (Array.isArray(f)) fixtures = f.filter((d) => typeof d === "string"); } catch { /* no config: no fixtures */ }
|
|
921
|
+
rust = discoverRustWorkspaces({ root, spawn: (c, a, opts) => o.spawn(c, a, opts), cargoCommand, tracked, untracked, fixtures, readFile: readLive });
|
|
922
|
+
npm = discoverNpmUnits({ root, tracked, readFile: readLive, exists: o.exists });
|
|
923
|
+
} catch (e) { o.err(`${tag} BLOCKED: cannot discover the repository's units: ${e.message}`); return 1; }
|
|
924
|
+
for (const k of rust.skipped) o.out(`${tag} note: ${k.manifest} is skipped, not scanned: ${k.why}`);
|
|
925
|
+
|
|
926
|
+
// Broker scans (cargo, pnpm) read the live checkout, so it must equal HEAD where they read it: an uncommitted fix could
|
|
927
|
+
// otherwise hide a committed failure. Any change or untracked file under those paths blocks the gate.
|
|
928
|
+
if (!planOnly) {
|
|
929
|
+
const inputs = [...new Set([...rust.units.map((u) => u.dir), ...npm.map((u) => u.dir), ...rust.workspaces.map((w) => w.cargoDir), "ci", "scripts", "vendor", "Cargo.toml", "Cargo.lock", ".cargo", "rust-toolchain.toml", "rust-toolchain",
|
|
930
|
+
"deny.toml", "clippy.toml", "rustfmt.toml", "package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml", ".npmrc", "eslint.config.js", "eslint.config.mjs", ".eslintrc.json", "biome.json"])];
|
|
931
|
+
const dirty = git("status", "--porcelain", "--untracked-files=all", "--", ...inputs);
|
|
932
|
+
const dirtyFiles = failed(dirty) ? [`git status failed: ${why(dirty)}`]
|
|
933
|
+
: text(dirty).split(/\r?\n/).filter(Boolean).filter((l) => !l.includes("node_modules") && !l.includes(GRANT_FILE));
|
|
934
|
+
if (dirtyFiles.length) {
|
|
935
|
+
o.err(`${tag} BLOCKED: the checkout differs from HEAD where broker scans read it; commit or stash first:`);
|
|
936
|
+
for (const l of dirtyFiles.slice(0, 20)) o.err(` ${l}`);
|
|
937
|
+
return 1;
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
const ac = o.abort ?? new AbortController();
|
|
942
|
+
let exportRoot = null;
|
|
943
|
+
const cleanup = () => { if (exportRoot) fs.rmSync(exportRoot, { recursive: true, force: true }); };
|
|
944
|
+
const signals = [];
|
|
945
|
+
try {
|
|
946
|
+
let repo = root;
|
|
947
|
+
if (!planOnly) {
|
|
948
|
+
exportRoot = fs.mkdtempSync(path.join(o.env.RK_GATE_TMPDIR || os.tmpdir(), "rk-gate-"));
|
|
949
|
+
repo = path.join(exportRoot, "repo");
|
|
950
|
+
const made = o.spawn("git", ["archive", "--format=tar", "--prefix=repo/", "-o", path.join(exportRoot, "head.tar"), "HEAD"], { cwd: root });
|
|
951
|
+
const x = failed(made) ? made : o.spawn("tar", ["-xf", "head.tar"], { cwd: exportRoot });
|
|
952
|
+
if (failed(made) || failed(x)) { o.err(`${tag} cannot export HEAD: ${why(made) || why(x)}`); return 2; }
|
|
953
|
+
}
|
|
954
|
+
const diff = git("diff", "--name-only", "--diff-filter=ACMR", `${base}...HEAD`);
|
|
955
|
+
if (failed(diff)) { o.err(`${tag} cannot diff against ${base}: ${why(diff)}; pass --base <ref>`); return 2; }
|
|
956
|
+
const changed = [...new Set(text(diff).split(/\r?\n/).map((f) => f.trim()).filter(Boolean))].filter((f) => o.exists(path.join(repo, f)));
|
|
957
|
+
|
|
958
|
+
let crateFeatures = {}; let packageFeatures = {};
|
|
959
|
+
try { const gateConfig = JSON.parse(o.readFile(path.join(repo, "ci/gate.config.json"))); crateFeatures = gateConfig.rust ?? {}; packageFeatures = gateConfig.npm ?? {}; } catch { /* no config: default feature sets */ }
|
|
960
|
+
// One lock and one lock scope per Cargo workspace. A workspace whose lock-only change reaches a subset puts only that subset's
|
|
961
|
+
// units (by unit name) in scope; every other workspace keeps all of its units in scope (planScans reads the scope only for the
|
|
962
|
+
// workspaces whose own root files changed).
|
|
963
|
+
const rustLocks = {}; const scopeNames = new Set(); let restricted = false;
|
|
964
|
+
for (const ws of rust.workspaces) {
|
|
965
|
+
const lockPath = ws.cargoDir === "." ? "Cargo.lock" : `${ws.cargoDir}/Cargo.lock`;
|
|
966
|
+
try { rustLocks[ws.cargoDir] = parseLock(o.readFile(path.join(repo, lockPath))); } catch { if (ws.units.length) o.out(`${tag} note: no ${lockPath}; reverse dependents are not checked`); }
|
|
967
|
+
const nameOf = new Map(ws.units.map((u) => [u.crate, u.name]));
|
|
968
|
+
const scope = ws.units.length ? lockScope({
|
|
969
|
+
changed, cargoDir: ws.cargoDir, unitNames: new Set(nameOf.keys()),
|
|
970
|
+
showBase: (f) => { const r = git("show", `${base}:${f}`); return failed(r) ? null : text(r); },
|
|
971
|
+
readLock: () => o.readFile(path.join(repo, lockPath)),
|
|
972
|
+
readManifest: () => o.readFile(path.join(repo, ws.cargoDir === "." ? "Cargo.toml" : `${ws.cargoDir}/Cargo.toml`)),
|
|
973
|
+
}) : null;
|
|
974
|
+
if (scope === null) for (const u of ws.units) scopeNames.add(u.name);
|
|
975
|
+
else { restricted = true; for (const crate of scope) if (nameOf.has(crate)) scopeNames.add(nameOf.get(crate)); }
|
|
976
|
+
}
|
|
977
|
+
const rustScope = restricted ? scopeNames : null;
|
|
978
|
+
|
|
979
|
+
const fullPlan = planScans({
|
|
980
|
+
changed, contentRoot: repo, liveRoot: root, includeTests: true, rustScope, cargoCommand,
|
|
981
|
+
// The quiet tests (port, pipe, desktop and wall-clock bound) are proven by the CI shards, one crate per runner, unless
|
|
982
|
+
// RK_GATE_QUIET_TESTS=local asks for the serial quiet lane here (no CI, or a CI outage).
|
|
983
|
+
quietTests: o.env.RK_GATE_QUIET_TESTS === "local" ? "local" : "ci", packageFeatures,
|
|
984
|
+
rustUnits: rust.units, npmUnits: npm, cargoDir: ".", rustLocks, crateFeatures,
|
|
985
|
+
localCompile: o.exists(path.join(root, GRANT_FILE)),
|
|
986
|
+
exists: o.exists, readFile: o.readFile, platform: o.platform,
|
|
987
|
+
});
|
|
988
|
+
const plan = args.includes("--pre-push") ? prePushPlan(fullPlan) : fullPlan;
|
|
989
|
+
const jobs = Math.max(1, Number(o.env.RK_GATE_JOBS ?? 4) || 4);
|
|
990
|
+
const testJobs = Math.max(1, Number(o.env.RK_GATE_TEST_JOBS ?? jobs) || jobs);
|
|
991
|
+
|
|
992
|
+
if (planOnly) {
|
|
993
|
+
o.out(`${tag} plan: ${plan.scans.length} scan(s) for ${changed.length} changed file(s) vs ${base.slice(0, 12)} (reads the working tree; nothing runs)`);
|
|
994
|
+
const wsList = rust.workspaces.map((w) => w.cargoDir);
|
|
995
|
+
const wsNote = wsList.length === 1 ? (wsList[0] === "." ? "" : ` (workspace ${wsList[0]})`) : wsList.length > 1 ? ` (workspaces ${wsList.join(", ")})` : "";
|
|
996
|
+
o.out(`${tag} rust units: ${rust.units.map((u) => `${u.name}=${u.dir}`).join(", ") || "none"}${wsNote}; npm units: ${npm.map((u) => `${u.name}=${u.dir}`).join(", ") || "none"}`);
|
|
997
|
+
for (const s of plan.scans) o.out(` [${s.class}] ${s.id} :: ${s.command}${plan.deferred.includes(s.id) ? " [deferred: no local compile grant]" : ""}`);
|
|
998
|
+
for (const n of plan.notes) o.out(`${tag} note: ${n}`);
|
|
999
|
+
return 0;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
for (const n of plan.notes) o.out(`${tag} note: ${n}`);
|
|
1003
|
+
const quietCount = plan.scans.filter((s) => s.quiet === true).length;
|
|
1004
|
+
o.out(`${tag} ${plan.scans.length} scan(s) for ${changed.length} changed file(s) vs ${base.slice(0, 12)}; compile scans ${jobs} at a time, tests ${testJobs} at a time${quietCount ? `, ${quietCount} quiet test scan(s) last under the broker's quiet lease` : ""}`);
|
|
1005
|
+
let signalCode = 0;
|
|
1006
|
+
for (const sig of ["SIGINT", "SIGTERM"]) {
|
|
1007
|
+
const handler = () => {
|
|
1008
|
+
if (ac.signal.aborted) process.exit(128 + (sig === "SIGINT" ? 2 : 15)); // a second signal: leave now
|
|
1009
|
+
signalCode = 128 + (sig === "SIGINT" ? 2 : 15);
|
|
1010
|
+
o.err(`${tag} ${sig}: stopping; running scans are being killed`);
|
|
1011
|
+
ac.abort();
|
|
1012
|
+
};
|
|
1013
|
+
process.on(sig, handler); signals.push([sig, handler]);
|
|
1014
|
+
}
|
|
1015
|
+
// The result cache keys on the gated commit's object ids; the pre-push check above made HEAD the pushed commit.
|
|
1016
|
+
let cache = null;
|
|
1017
|
+
if (!args.includes("--no-cache") && o.env.RK_GATE_CACHE !== "0") {
|
|
1018
|
+
try {
|
|
1019
|
+
const headSha = text(git("rev-parse", "HEAD")).trim();
|
|
1020
|
+
const treeId = text(git("rev-parse", `${headSha}^{tree}`)).trim();
|
|
1021
|
+
const common = git("rev-parse", "--git-common-dir");
|
|
1022
|
+
if (headSha && treeId && !failed(common) && text(common).trim()) {
|
|
1023
|
+
const objectId = (p) => {
|
|
1024
|
+
const r = git("ls-tree", headSha, "--", p);
|
|
1025
|
+
if (failed(r)) throw new Error(`git ls-tree failed: ${why(r)}`);
|
|
1026
|
+
const line = text(r).split(/\r?\n/).find(Boolean);
|
|
1027
|
+
return line ? (line.split("\t")[0].split(/\s+/)[2] ?? null) : null;
|
|
1028
|
+
};
|
|
1029
|
+
const store = fileScanCache(path.join(path.resolve(root, text(common).trim()), "rightkit-gate-cache"));
|
|
1030
|
+
const listFiles = (dir) => {
|
|
1031
|
+
const r = git("ls-tree", "-r", "--name-only", "-z", headSha, "--", dir);
|
|
1032
|
+
if (failed(r)) throw new Error(`git ls-tree failed: ${why(r)}`);
|
|
1033
|
+
return text(r).split("\0").filter(Boolean).map(posix);
|
|
1034
|
+
};
|
|
1035
|
+
const readBlob = (p) => {
|
|
1036
|
+
const r = git("show", `${headSha}:${p}`);
|
|
1037
|
+
if (failed(r)) throw new Error(`git show failed: ${why(r)}`);
|
|
1038
|
+
return text(r);
|
|
1039
|
+
};
|
|
1040
|
+
cache = { ...store, digest: buildCacheDigest({ objectId, headTree: treeId, rustUnits: rust.units, npmUnits: npm, cargoDir: ".", rustLocks, crateFeatures, listFiles, readBlob }) };
|
|
1041
|
+
}
|
|
1042
|
+
} catch { cache = null; }
|
|
1043
|
+
}
|
|
1044
|
+
const scanResult = await runScans(plan, {
|
|
1045
|
+
classes: ["A", "B"], jobs, testJobs, signal: ac.signal, ...(cache ? { cache } : {}),
|
|
1046
|
+
quietLane: quietLeaseLane(o, { tag, reason: `rightkit-gate-quiet-tests@${text(git("rev-parse", "--short", "HEAD")).trim()}` }),
|
|
1047
|
+
onResult: (row, p) => o.out(`${tag} [${p.done}/${p.total}] ${(row.cached ? "cached" : row.status).padEnd(7)} ${row.id} (${p.seconds}s)${row.status === "fail" ? ` :: ${String(row.detail ?? "").slice(0, 300)}` : ""}`),
|
|
1048
|
+
});
|
|
1049
|
+
if (ac.signal.aborted) {
|
|
1050
|
+
const notRun = scanResult.scans.filter((r) => r.status !== "pass" && r.status !== "fail").map((r) => r.id);
|
|
1051
|
+
o.err(`${tag} CANCELLED (${signalCode || "aborted"}); ${notRun.length} scan(s) not run:`);
|
|
1052
|
+
for (const id of notRun) o.err(` - ${id}`);
|
|
1053
|
+
for (const line of scanFailures(scanResult).filter((l) => !/\(not run\)/.test(l))) o.err(` - ${line}`);
|
|
1054
|
+
return signalCode || 130;
|
|
1055
|
+
}
|
|
1056
|
+
const failures = scanFailures(scanResult);
|
|
1057
|
+
const deferrals = scanDeferrals(scanResult);
|
|
1058
|
+
for (const line of deferrals) failures.push(`DEFERRED ${line}`);
|
|
1059
|
+
for (const note of scanResult.notes) o.out(`${tag} ${note}`);
|
|
1060
|
+
if (failures.length) {
|
|
1061
|
+
o.err(`${tag} BLOCKED (${failures.length})`);
|
|
1062
|
+
for (const f of failures) o.err(` - ${f}`);
|
|
1063
|
+
o.err("Fix and push again. Never bypass with --no-verify.");
|
|
1064
|
+
return 1;
|
|
1065
|
+
}
|
|
1066
|
+
o.out(`${tag} pass (${changed.length} changed file(s) vs ${base}); ${deferrals.length} scan(s) deferred, not passed${scanResult.cacheHits.length ? `; ${scanResult.cacheHits.length} reused from the result cache` : ""}`);
|
|
1067
|
+
return 0;
|
|
1068
|
+
} finally {
|
|
1069
|
+
for (const [sig, handler] of signals) process.off(sig, handler);
|
|
1070
|
+
cleanup();
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
const invokedDirectly = (() => {
|
|
1075
|
+
try { return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return false; }
|
|
1076
|
+
})();
|
|
1077
|
+
if (invokedDirectly) {
|
|
1078
|
+
main(process.argv.slice(2)).then((code) => process.exit(code), (e) => { console.error(`rightkit-gate: ${e?.stack ?? e}`); process.exit(2); });
|
|
1079
|
+
}
|