@rightkit/release 0.6.10 → 0.6.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -5
- package/admission-files.mjs +3 -0
- package/admission.mjs +2 -2
- package/advisory-exceptions.mjs +41 -5
- package/android-play-upload.mjs +16 -15
- package/app-check.mjs +13 -9
- package/bin/rightkit-gate.mjs +772 -355
- package/broker-pins.mjs +152 -0
- package/build-release.mjs +80 -27
- package/cargo-ledger.mjs +62 -0
- package/cargo-target-shim.mjs +98 -26
- package/cli/right-release.mjs +28 -4
- package/clippy-equivalence.mjs +202 -0
- package/clippy-route.mjs +543 -0
- package/committed-source.mjs +39 -5
- package/crate-workspaces.mjs +87 -0
- package/create-mac-updater.mjs +14 -2
- package/deterministic-scans.mjs +196 -21
- package/development.mjs +13 -4
- package/direct-receipt.mjs +135 -0
- package/door-policy.mjs +10 -0
- package/door.mjs +6 -6
- package/freshness-census.mjs +257 -0
- package/gate-stages.mjs +76 -10
- package/hook-retirement.mjs +65 -0
- package/init-command.mjs +1 -1
- package/inventory-check.mjs +11 -8
- package/ios-upload.mjs +20 -15
- package/local-publish.mjs +77 -41
- package/local-qualify.mjs +5 -3
- package/local-runtime.mjs +5 -4
- package/mac-package-recipe.mjs +4 -1
- package/mirror-root-artifact.mjs +18 -5
- package/ownership-class-rules.mjs +1 -1
- package/ownership-evidence.mjs +37 -13
- package/ownership-hooks.mjs +20 -59
- package/ownership-lockfiles.mjs +14 -1
- package/ownership-policy-bundle.mjs +3 -2
- package/ownership-staged.mjs +2 -1
- package/ownership.mjs +24 -0
- package/package-recipe-targets.mjs +35 -6
- package/package.json +1 -1
- package/pipeline-admission.mjs +4 -4
- package/preflight.mjs +27 -4
- package/publish-cargo.mjs +4 -3
- package/recipe-build.mjs +38 -14
- package/release-artifact-path.mjs +341 -0
- package/release-invocation.mjs +8 -1
- package/release-qa-command.mjs +25 -9
- package/release-source-brand.mjs +17 -0
- package/release-source-identity.mjs +171 -0
- package/release.mjs +44 -17
- package/rightkit-versions.json +63 -42
- package/runtime-budgets.mjs +6 -2
- package/scan-digest.mjs +71 -5
- package/sign-updater.mjs +6 -3
- package/snapshot-broker.mjs +172 -0
- package/snapshot-check.mjs +12 -6
- package/snapshot-request.mjs +207 -5
- package/snapshot.mjs +123 -10
- package/source-split.mjs +73 -0
- package/stage-commands.mjs +2 -2
- package/stage-ledger.mjs +204 -0
- package/stage-sidecar-command.mjs +5 -1
- package/stage-sidecar.mjs +100 -20
- package/swap-helper-bundle.mjs +5 -2
- package/unit-gate.mjs +45 -3
- package/web-build.mjs +6 -4
- package/win-package-recipe.mjs +20 -7
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// One Cargo workspace and lock per RightKit crate (Adrian, 2026-10-29: "I want you to have 1 cargo dot lock per component";
|
|
2
|
+
// rightkit#1285). Every `crates/<x>` is its own workspace; crates that use each other link by path dependency across
|
|
3
|
+
// workspaces. This module reads manifests only (no cargo) and answers which crates exist, which sibling crates each one
|
|
4
|
+
// path-depends on, and which crates a change reaches: the changed crate plus every crate that path-depends on it, transitively.
|
|
5
|
+
// It imports nothing outside node: CI's plan job runs it before any package install, so it reads the few manifest keys it needs
|
|
6
|
+
// (the package name and dependency `path` values) line by line instead of through a TOML library.
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
|
|
10
|
+
const posix = (p) => p.split(path.sep).join("/");
|
|
11
|
+
const defaults = { readFile: (f) => fs.readFileSync(f, "utf8"), readdir: (d) => fs.readdirSync(d), exists: fs.existsSync };
|
|
12
|
+
|
|
13
|
+
/** [{ name, dir: "crates/<x>", manifest, lock }] for every crates/<x>/Cargo.toml, sorted by dir. */
|
|
14
|
+
export function crateWorkspaces(root, io = {}) {
|
|
15
|
+
const { readFile, readdir, exists } = { ...defaults, ...io };
|
|
16
|
+
const cratesDir = path.join(root, "crates");
|
|
17
|
+
if (!exists(cratesDir)) return [];
|
|
18
|
+
const out = [];
|
|
19
|
+
for (const entry of [...readdir(cratesDir)].map(String).sort()) {
|
|
20
|
+
if (entry.startsWith(".")) continue;
|
|
21
|
+
const manifest = `crates/${entry}/Cargo.toml`;
|
|
22
|
+
if (!exists(path.join(root, manifest))) continue;
|
|
23
|
+
const name = manifestFacts(readFile(path.join(root, manifest))).name;
|
|
24
|
+
if (typeof name !== "string") continue;
|
|
25
|
+
out.push({ name, dir: `crates/${entry}`, manifest, lock: `crates/${entry}/Cargo.lock` });
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const DEPENDENCY_TABLE = /^(?:target\.(?:'[^']*'|"[^"]*"|[^.\]]+)\.)?(?:dev-|build-)?dependencies$/;
|
|
31
|
+
const unquote = (v) => v.replace(/^(["'])(.*)\1$/, "$2");
|
|
32
|
+
|
|
33
|
+
/** { name, paths }: the [package] name and every dependency `path` value (inline tables and `[dependencies.<x>]` sections). */
|
|
34
|
+
export function manifestFacts(text) {
|
|
35
|
+
let section = ""; let name; const paths = [];
|
|
36
|
+
for (const raw of String(text).split(/\r?\n/)) {
|
|
37
|
+
const line = raw.replace(/\s+#.*$/, "").trim();
|
|
38
|
+
if (!line || line.startsWith("#")) continue;
|
|
39
|
+
const header = line.match(/^\[\[?([^\[\]]+)\]\]?$/);
|
|
40
|
+
if (header) { section = header[1].trim(); continue; }
|
|
41
|
+
if (section === "package") { const m = line.match(/^name\s*=\s*("[^"]*"|'[^']*')/); if (m) name = unquote(m[1]); continue; }
|
|
42
|
+
if (DEPENDENCY_TABLE.test(section)) {
|
|
43
|
+
const m = line.match(/^[A-Za-z0-9_.-]+\s*=\s*\{(.*)\}$/);
|
|
44
|
+
const p = m && m[1].match(/(?:^|,)\s*path\s*=\s*("[^"]*"|'[^']*')/);
|
|
45
|
+
if (p) paths.push(unquote(p[1]));
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const dotted = section.match(/^(.*)\.([^.]+)$/);
|
|
49
|
+
if (dotted && DEPENDENCY_TABLE.test(dotted[1])) { const m = line.match(/^path\s*=\s*("[^"]*"|'[^']*')/); if (m) paths.push(unquote(m[1])); }
|
|
50
|
+
}
|
|
51
|
+
return { name, paths };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Map<name, Set<name>>: each crate's direct path dependencies on sibling crates (normal, dev, build, any target). */
|
|
55
|
+
export function pathDependencies(root, io = {}, workspaces = crateWorkspaces(root, io)) {
|
|
56
|
+
const { readFile } = { ...defaults, ...io };
|
|
57
|
+
const byDir = new Map(workspaces.map((w) => [w.dir, w.name]));
|
|
58
|
+
const deps = new Map(workspaces.map((w) => [w.name, new Set()]));
|
|
59
|
+
for (const w of workspaces) {
|
|
60
|
+
for (const dep of manifestFacts(readFile(path.join(root, w.manifest))).paths) {
|
|
61
|
+
const target = byDir.get(posix(path.posix.normalize(path.posix.join(w.dir, posix(dep)))));
|
|
62
|
+
if (target && target !== w.name) deps.get(w.name).add(target);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return deps;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Set<name>: every crate that path-depends on any of `names`, transitively; `names` themselves are excluded. */
|
|
69
|
+
export function reverseDependents(names, deps) {
|
|
70
|
+
const reverse = new Map();
|
|
71
|
+
for (const [from, tos] of deps) for (const to of tos) (reverse.get(to) ?? reverse.set(to, new Set()).get(to)).add(from);
|
|
72
|
+
const seen = new Set(); const queue = [...names];
|
|
73
|
+
while (queue.length) {
|
|
74
|
+
for (const dependent of reverse.get(queue.shift()) ?? []) {
|
|
75
|
+
if (!seen.has(dependent)) { seen.add(dependent); queue.push(dependent); }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
for (const n of names) seen.delete(n);
|
|
79
|
+
return seen;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The crate whose workspace holds `file` (repository-relative, posix), or null. */
|
|
83
|
+
export function crateOfFile(file, workspaces) {
|
|
84
|
+
let best = null;
|
|
85
|
+
for (const w of workspaces) if (file.startsWith(`${w.dir}/`) && (!best || w.dir.length > best.dir.length)) best = w;
|
|
86
|
+
return best?.name ?? null;
|
|
87
|
+
}
|
package/create-mac-updater.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { spawnSync } from "./background-process.mjs";
|
|
|
3
3
|
import { existsSync, rmSync } from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { signMacUpdaterFiles } from "./macos-signing-session.mjs";
|
|
6
|
+
import { ArtifactPathError, loadSplitArtifactContext, resolveArtifactPath, resolveArtifactWritePath } from "./release-artifact-path.mjs";
|
|
6
7
|
|
|
7
8
|
const args = process.argv.slice(2);
|
|
8
9
|
let app;
|
|
@@ -18,8 +19,19 @@ for (let i = 0; i < args.length; i++) {
|
|
|
18
19
|
}
|
|
19
20
|
if (!app || !output) fail("--app and --output are required");
|
|
20
21
|
|
|
21
|
-
|
|
22
|
-
|
|
22
|
+
// A door split (row 7.04c, R-T2): a target-prefixed `--app` is the bound Cargo target's, an absolute one must lie inside it, and the tarball is
|
|
23
|
+
// written under the snapshot app root (a generated, ignored ledger entry). Outside a split both are resolved against the cwd as always.
|
|
24
|
+
let appPath;
|
|
25
|
+
let outputPath;
|
|
26
|
+
try {
|
|
27
|
+
const artifactPaths = await loadSplitArtifactContext({ cwd });
|
|
28
|
+
const split = artifactPaths.mode === "split";
|
|
29
|
+
appPath = split ? resolveArtifactPath(app, artifactPaths) : path.resolve(cwd, app);
|
|
30
|
+
outputPath = split ? resolveArtifactWritePath(output, artifactPaths) : path.resolve(cwd, output);
|
|
31
|
+
} catch (error) {
|
|
32
|
+
if (error instanceof ArtifactPathError) fail(error.message);
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
23
35
|
if (!dryRun && !existsSync(appPath)) fail(`signed app not found: ${appPath}`);
|
|
24
36
|
|
|
25
37
|
const env = { ...process.env };
|
package/deterministic-scans.mjs
CHANGED
|
@@ -25,12 +25,14 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
25
25
|
import { spawn as spawnChild, spawnSync } from "./background-process.mjs";
|
|
26
26
|
import { spawnUnderOwnership } from "./build-ownership.mjs";
|
|
27
27
|
import { checkAdvisoryExceptions } from "./advisory-exceptions.mjs";
|
|
28
|
-
import { canonicalJson, digestScanSet, requiredScanIds, rustCratePlan, rustScanId } from "./scan-digest.mjs";
|
|
28
|
+
import { CLIPPY_UNIVERSAL_FEATURE_FLAGS, canonicalJson, clippyBatchPlan, clippySelectionOf, digestScanSet, requiredScanIds, rustCratePlan, rustScanId } from "./scan-digest.mjs";
|
|
29
29
|
import { reach } from "./lock-graph.mjs";
|
|
30
30
|
import { cargoManifestState } from "./cargo-manifest-text.mjs";
|
|
31
31
|
import { LAUNCH_GUARD_IMPORT } from "./class-a-launch-guard.mjs";
|
|
32
32
|
import { advisoryCacheRoot, ensureAdvisoryDb, prepareDenyOverlay, readAdvisoryPin } from "./advisory-db.mjs";
|
|
33
33
|
import { observedEnvOf, readRequestRef, verifySnapshotReceipt, writeSnapshotRequest } from "./snapshot-request.mjs";
|
|
34
|
+
import { SCAN_TOKEN_ENV, readLedger, scanTokenOf, unclaimedLedgers } from "./cargo-ledger.mjs";
|
|
35
|
+
import { verifyBrokerLedger } from "./snapshot-broker.mjs";
|
|
34
36
|
|
|
35
37
|
export const SCAN_RESULT_SCHEMA = "rightkit.deterministic-scans/1";
|
|
36
38
|
/**
|
|
@@ -48,6 +50,14 @@ export const CHECKER_IDENTITY = (() => {
|
|
|
48
50
|
})();
|
|
49
51
|
export const CHECKER_VERSION = CHECKER_IDENTITY;
|
|
50
52
|
export const SCAN_CLASS = Object.freeze({ A: "A", B: "B" });
|
|
53
|
+
/**
|
|
54
|
+
* Recorded in the result of every wrapped snapshot compile (item 7 row 7.10, lead re-decision F7): cargo hands a build script its own
|
|
55
|
+
* path in CARGO (measured, cargo 1.98.0), so a cargo a build script runs through $CARGO never reaches the wrapper's ledger.
|
|
56
|
+
*/
|
|
57
|
+
export const UNOBSERVED_NESTED_CARGO_NOTE = "cargo calls that a build script makes through $CARGO are unobserved: cargo hands build scripts its own path, not the gate's wrapper, so they are not in the cargo ledger; they inherit the parent's bound CARGO_TARGET_DIR, and the per-scan sentinel and the after-compile snapshot verify are the backstops";
|
|
58
|
+
|
|
59
|
+
/** The ledger token of the gate's own tool version probes (they reach the cargo wrapper like any scan does). */
|
|
60
|
+
const PROBE_LEDGER_TOKEN = scanTokenOf("tool-probe");
|
|
51
61
|
|
|
52
62
|
/** Scan id prefixes (the text before the first ":") that belong to each class. */
|
|
53
63
|
export const CLASS_A_SCAN_PREFIXES = Object.freeze([
|
|
@@ -197,6 +207,19 @@ export function checkArgs(unit, { features = [], messageFormat, locked = false }
|
|
|
197
207
|
}
|
|
198
208
|
|
|
199
209
|
/** `cargo doc -p <unit> --no-deps --all-features --locked`; run it with RUSTDOCFLAGS=DOC_RUSTDOCFLAGS (CI). */
|
|
210
|
+
// Every compile reports every failure (Adrian, 2026-10-29: "even to ship, why isn't it better to get all errors and fix them all
|
|
211
|
+
// wholesale?"; rightkit#1293): a compile keeps going past a failed crate (`--keep-going`) and a test run past a failed test binary
|
|
212
|
+
// (`--no-fail-fast`). The run still fails if anything failed; the crates that compiled stay warm for the next run.
|
|
213
|
+
// Cargo 1.98: build, check, clippy (through its cargo check) and doc take `--keep-going`; test and bench reject it and take
|
|
214
|
+
// `--no-fail-fast` instead (the broker's BR29 adds the same split).
|
|
215
|
+
const KEEP_GOING_SUBCOMMANDS = new Set(["build", "check", "clippy", "doc"]);
|
|
216
|
+
const NO_FAIL_FAST_SUBCOMMANDS = new Set(["test", "bench"]);
|
|
217
|
+
export function everyFailure(args) {
|
|
218
|
+
const [sub, ...rest] = args;
|
|
219
|
+
const until = rest.indexOf("--"); const own = until === -1 ? rest : rest.slice(0, until);
|
|
220
|
+
const flag = KEEP_GOING_SUBCOMMANDS.has(sub) ? "--keep-going" : NO_FAIL_FAST_SUBCOMMANDS.has(sub) ? "--no-fail-fast" : null;
|
|
221
|
+
return flag && !own.includes(flag) ? [sub, flag, ...rest] : args;
|
|
222
|
+
}
|
|
200
223
|
export function docArgs(unit) { return ["doc", "-p", unit, "--no-deps", "--all-features", "--locked"]; }
|
|
201
224
|
export const DOC_RUSTDOCFLAGS = "-D warnings";
|
|
202
225
|
/** The same check as rustdoc arguments (`cargo rustdoc ... -- -D warnings`), for the broker path. */
|
|
@@ -208,6 +231,35 @@ export function fmtCheckArgs(unit = null) { return unit ? ["fmt", "-p", unit, "-
|
|
|
208
231
|
/** `cargo metadata` that validates manifests and the lock without resolving anything new. */
|
|
209
232
|
export function metadataArgs({ noDeps = true } = {}) { return ["metadata", ...(noDeps ? ["--no-deps"] : []), "--format-version", "1", "--locked"]; }
|
|
210
233
|
|
|
234
|
+
// ---- Clippy batching (GATE-CONTRACT §1 "Clippy route & batching"; item 8 rows 8.6, 8.8, 8.15) -----------------------------------------
|
|
235
|
+
|
|
236
|
+
// The batch planner itself (clippyBatchPlan, clippySelectionOf, CLIPPY_UNIVERSAL_FEATURE_FLAGS) lives in scan-digest.mjs, beside the scan
|
|
237
|
+
// ids, and is re-exported here.
|
|
238
|
+
export { CLIPPY_UNIVERSAL_FEATURE_FLAGS, clippyBatchPlan, clippySelectionOf };
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Does this crate's directory declare sidecars its build needs staged first? A Tauri crate with `bundle.externalBin` refuses to build
|
|
242
|
+
* until its sidecars are in place (bin/rightkit-gate.mjs, the compile stage), so it is scheduled after the independent crates. The
|
|
243
|
+
* declaration is read from the crate's own Tauri configuration (JSON, JSON5 or TOML), in its directory or its `src-tauri`.
|
|
244
|
+
*/
|
|
245
|
+
export function sidecarBoundCrate({ dir, exists = fs.existsSync, readFile = (f) => fs.readFileSync(f, "utf8") }) {
|
|
246
|
+
for (const base of [dir, path.join(dir, "src-tauri")]) {
|
|
247
|
+
for (const name of ["tauri.conf.json", "tauri.conf.json5", "Tauri.toml"]) {
|
|
248
|
+
const file = path.join(base, name);
|
|
249
|
+
let body;
|
|
250
|
+
try { if (!exists(file)) continue; body = readFile(file); } catch { continue; }
|
|
251
|
+
try {
|
|
252
|
+
const conf = JSON.parse(body);
|
|
253
|
+
const list = conf?.bundle?.externalBin ?? conf?.tauri?.bundle?.externalBin;
|
|
254
|
+
if (Array.isArray(list) && list.length) return { bound: true, file, externalBin: list };
|
|
255
|
+
continue;
|
|
256
|
+
} catch { /* not strict JSON: read as text */ }
|
|
257
|
+
if (/(^|[\s{,"'])externalBin["']?\s*[:=]\s*\[\s*["']/.test(body)) return { bound: true, file, externalBin: null };
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return { bound: false };
|
|
261
|
+
}
|
|
262
|
+
|
|
211
263
|
// ---- plan ---------------------------------------------------------------------------------------------
|
|
212
264
|
|
|
213
265
|
const posix = (p) => p.replaceAll("\\", "/");
|
|
@@ -305,6 +357,11 @@ export const ancestorConfigChanged = (ws, changed, pattern = ANCESTOR_RUST_CONFI
|
|
|
305
357
|
const rel = dir === "." ? f : f.startsWith(`${dir}/`) ? f.slice(dir.length + 1) : null;
|
|
306
358
|
return rel !== null && pattern.test(rel);
|
|
307
359
|
}));
|
|
360
|
+
// Genuinely shared inputs of a repository whose every crate is its own Cargo workspace (rightkit#1285): no crate directory holds them,
|
|
361
|
+
// so a change to one reaches every unit. Anything else outside a crate directory reaches no crate (a vendored source maps to the crates
|
|
362
|
+
// that patch or path-depend on it through `externalInputs`; a repository-level toolchain, .cargo, clippy or rustfmt file is an
|
|
363
|
+
// ANCESTOR_RUST_CONFIG_FILE of every workspace). The gate configuration, the pipeline's toolchain pins and the gate itself are named here.
|
|
364
|
+
export const SHARED_RUST_SCOPE_FILE = /^(ci\/gate\.config\.json|ci\/rk-pipeline\.json|packages\/release\/(bin\/rightkit-gate|deterministic-scans|scan-digest|advisory-exceptions|crate-workspaces|lock-graph|cargo-target-shim)\.mjs)$/;
|
|
308
365
|
const WORKSPACE_JS_FILE = /^(package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml|\.npmrc)$/;
|
|
309
366
|
// The linter a package has is the one it configures. ESLint reads a flat `eslint.config.*` or a legacy `.eslintrc*` from the package directory or
|
|
310
367
|
// any ancestor of it; Biome reads `biome.json` / `biome.jsonc` the same way. A Biome-only app (ViewRight, ScrapeRight: `"lint": "biome lint ."`)
|
|
@@ -739,7 +796,7 @@ export function planScans({
|
|
|
739
796
|
// Repository-specific units for any layout: [{ name, dir, publish? }], dir repo-relative ("." for a root unit). When given they
|
|
740
797
|
// replace the crates/<name> and packages/<name> layout (rightkit-gate). `cargoDir` is the Cargo workspace root the cargo scans run in.
|
|
741
798
|
// A rust unit may name its own workspace: { name, dir, cargoDir, crate, publish? } runs its scans with cwd cargoDir and `-p <crate>`;
|
|
742
|
-
// `name` is then the unit's identity in scan ids and feature config (rightkit-gate names it `<cargoDir>:<crate>` below the root), and
|
|
799
|
+
// `name` is then the unit's identity in scan ids and feature config (rightkit-gate names it `<cargoDir>:<crate>` below the root, and the plain crate for a `crates/<crate>` workspace of its own), and
|
|
743
800
|
// `crateFeatures` is read by `name` first, then by `crate`. Units of different workspaces never share a lock, a deny/audit run or a
|
|
744
801
|
// `cargo metadata`; a unit that sets no cargoDir belongs to the global `cargoDir` above.
|
|
745
802
|
rustUnits = null, npmUnits = null, cargoDir = ".",
|
|
@@ -757,6 +814,19 @@ export function planScans({
|
|
|
757
814
|
rustLocks = null,
|
|
758
815
|
// The pinned RustSec advisory database ({ url, commit }, advisory-db.json): cargo-deny and cargo-audit read exactly that commit and never fetch.
|
|
759
816
|
advisoryPin = null,
|
|
817
|
+
// Cross-workspace reach of a crate-per-workspace repository (rightkit#1285): `crateDependents(unitNames: Set) -> Set` answers the
|
|
818
|
+
// units that path-depend on any of `unitNames`, transitively, across Cargo workspaces (the gate builds it from packages/release/
|
|
819
|
+
// crate-workspaces.mjs; manifests only). Every such unit that is not itself in scope gets a `check-dependent` scan. null plans none beyond
|
|
820
|
+
// the per-workspace lock graph (rustLocks).
|
|
821
|
+
crateDependents = null,
|
|
822
|
+
// `{ "<repo-relative directory>": [unit names] }`: sources outside every unit that a unit compiles (vendor/citationberg is patched into
|
|
823
|
+
// rightkit-typeset). A changed file under the directory puts those units in scope. null maps none.
|
|
824
|
+
externalInputs = null,
|
|
825
|
+
// Clippy batching (item 8; clippyBatchPlan). false keeps the production path: one Clippy scan per crate and lint feature set. It is
|
|
826
|
+
// switched on only after the batch-equivalence run (clippy-equivalence.mjs, row 8.7) has passed on the hosts the map names; then the
|
|
827
|
+
// planner emits one scan per compatible batch and no per-crate Clippy scan for a crate that belongs to one (row 8.15).
|
|
828
|
+
// `shardSolo` is ci/shards.json `solo`; `clippySeparate` maps a unit name to the reason equivalence found batching changed it.
|
|
829
|
+
clippyBatching = false, shardSolo = [], clippySeparate = new Map(),
|
|
760
830
|
} = {}) {
|
|
761
831
|
if (!contentRoot) throw new TypeError("planScans: contentRoot is required");
|
|
762
832
|
if (!Array.isArray(cargoCommand) || cargoCommand.length === 0 || !["rustright", "cargo"].includes(cargoCommand[0]) || cargoCommand.slice(1).some((a) => a !== "cargo")) {
|
|
@@ -810,7 +880,7 @@ export function planScans({
|
|
|
810
880
|
const plainDir = (d) => !ABSOLUTE_PATH.test(d) && !d.split("/").includes("..") && SAFE_UNIT_DIR.test(d);
|
|
811
881
|
const cargoWs = cleanDir(cargoDir);
|
|
812
882
|
if (!plainDir(cargoWs)) throw new TypeError("planScans: cargoDir must be a plain repository-relative directory");
|
|
813
|
-
const cargoIn = (ws, ...args) => ({ tool: cargoCommand[0], args: [...cargoCommand.slice(1), ...args], ...(ws !== "." ? { cwd: ws } : {}), ...(cargoLiveRoot ? { root: "cargoLive" } : {}), ...(targetDirs?.[ws] ? { env: { CARGO_TARGET_DIR: targetDirs[ws] } } : {}) });
|
|
883
|
+
const cargoIn = (ws, ...args) => ({ tool: cargoCommand[0], args: [...cargoCommand.slice(1), ...everyFailure(args)], ...(ws !== "." ? { cwd: ws } : {}), ...(cargoLiveRoot ? { root: "cargoLive" } : {}), ...(targetDirs?.[ws] ? { env: { CARGO_TARGET_DIR: targetDirs[ws] } } : {}) });
|
|
814
884
|
const unitMap = (units, kind) => {
|
|
815
885
|
if (units === null) return null;
|
|
816
886
|
if (!Array.isArray(units)) throw new TypeError(`planScans: ${kind}Units must be an array of { name, dir }`);
|
|
@@ -865,7 +935,13 @@ export function planScans({
|
|
|
865
935
|
};
|
|
866
936
|
// Units of an explicit-workspace layout (some unit names its cargoDir) get workspace-suffixed ids for the per-workspace scans.
|
|
867
937
|
const perWorkspaceIds = Array.isArray(rustUnits) && rustUnits.some((u) => u?.cargoDir !== undefined);
|
|
868
|
-
|
|
938
|
+
// The label a per-workspace scan id carries: the workspace directory, except for a workspace that is exactly one crate-named unit
|
|
939
|
+
// (`crates/rightkit-fs` holding rightkit-fs, rightkit#1285 D2), which is labelled by the crate (`cargo-deny:rightkit-fs`).
|
|
940
|
+
const wsLabel = (ws) => {
|
|
941
|
+
if (rustMap) for (const [name, u] of rustMap) if (u.cargoDir === ws && name === u.crate && ws === `crates/${u.crate}`) return name;
|
|
942
|
+
return ws;
|
|
943
|
+
};
|
|
944
|
+
const wsIdSuffix = (ws) => (perWorkspaceIds && ws !== "." ? `:${wsLabel(ws)}` : "");
|
|
869
945
|
const wsOf = (n) => (rustMap ? rustMap.get(n).cargoDir : cargoWs);
|
|
870
946
|
const crateOf = (n) => (rustMap?.get(n)?.crate ?? n);
|
|
871
947
|
const featuresOf = (n) => crateFeatures?.[n] ?? crateFeatures?.[crateOf(n)] ?? {};
|
|
@@ -899,13 +975,32 @@ export function planScans({
|
|
|
899
975
|
const verbatimUpstream = (f) => f.startsWith("vendor/") && typeof upstream[f] === "string" && bytesDigest(f) === upstream[f];
|
|
900
976
|
const recordedPatch = (f) => f.startsWith("vendor/") && DIGEST.test(patched[f]?.base ?? "") && typeof patched[f]?.note === "string"
|
|
901
977
|
&& patched[f].note.startsWith("vendor/") && DIGEST.test(patched[f]?.sha256 ?? "") && bytesDigest(f) === patched[f].sha256;
|
|
978
|
+
// Declared fixture files (Adrian, 2026-10-30: "there should be a .rkignore list that somehow allows for those to be passed";
|
|
979
|
+
// rightkit#1291): `.rightkit/fixture-files.json` { schema: 1, files: { "<path>": { sha256, note } } } lists files that are
|
|
980
|
+
// deliberately malformed (an audit's known-answer inputs). A listed file whose bytes still match its sha256 skips the per-file
|
|
981
|
+
// format and parse scans only (rustfmt, node --check, json-parse, toml-parse); every other scan still reads it. Any edit changes
|
|
982
|
+
// the digest, so the file is checked again until re-recorded. A malformed manifest, a bad digest or an empty note refuses.
|
|
983
|
+
let fixtureFiles = {};
|
|
984
|
+
if (exists(path.join(contentRoot, ".rightkit/fixture-files.json"))) {
|
|
985
|
+
try {
|
|
986
|
+
const manifest = JSON.parse(readFile(path.join(contentRoot, ".rightkit/fixture-files.json")));
|
|
987
|
+
if (manifest?.schema !== 1 || !manifest.files || typeof manifest.files !== "object" || Array.isArray(manifest.files)) throw new Error("schema 1 with a files object is required");
|
|
988
|
+
for (const [f, row] of Object.entries(manifest.files)) {
|
|
989
|
+
if (!/^[0-9a-f]{64}$/.test(row?.sha256 ?? "")) throw new Error(`${f}: sha256 must be 64 lowercase hex characters`);
|
|
990
|
+
if (typeof row?.note !== "string" || !row.note.trim()) throw new Error(`${f}: a note saying why the file is malformed is required`);
|
|
991
|
+
if (f.startsWith("/") || f.split("/").includes("..")) throw new Error(`${f}: paths are repository-relative`);
|
|
992
|
+
}
|
|
993
|
+
fixtureFiles = manifest.files;
|
|
994
|
+
} catch (error) { refuse(".rightkit/fixture-files.json", error.message); }
|
|
995
|
+
}
|
|
996
|
+
const declaredFixture = (f) => Object.hasOwn(fixtureFiles, f) && bytesDigest(f) === fixtureFiles[f].sha256;
|
|
902
997
|
// Generated Rust is not hand-formatted: bindgen output, or rustfmt's own `@generated` marker in the first five lines.
|
|
903
998
|
const generatedRust = (f) => {
|
|
904
999
|
let head = "";
|
|
905
1000
|
try { head = readFile(path.join(contentRoot, f)).split("\n", 5).join("\n"); } catch { return false; }
|
|
906
1001
|
return /@generated\b|automatically generated by rust-bindgen\b/.test(head);
|
|
907
1002
|
};
|
|
908
|
-
const rustFiles = files.filter((f) => f.endsWith(".rs") && !verbatimUpstream(f) && !recordedPatch(f) && !generatedRust(f));
|
|
1003
|
+
const rustFiles = files.filter((f) => f.endsWith(".rs") && !verbatimUpstream(f) && !recordedPatch(f) && !generatedRust(f) && !declaredFixture(f));
|
|
909
1004
|
const byEdition = new Map();
|
|
910
1005
|
const editionOf = rustEditionResolver({ contentRoot, exists, readFile });
|
|
911
1006
|
const refusedEditions = new Set();
|
|
@@ -927,12 +1022,12 @@ export function planScans({
|
|
|
927
1022
|
add({ id: `rustfmt:${edition}${i ? `:${i / RUSTFMT_BATCH + 1}` : ""}`, class: A, tool: "rustfmt", args: ["--check", "--edition", edition, ...batch], root: "content" });
|
|
928
1023
|
}
|
|
929
1024
|
}
|
|
930
|
-
for (const f of files.filter((f) => /\.(mjs|cjs|js)$/.test(f) && !f.includes("node_modules")).sort()) {
|
|
1025
|
+
for (const f of files.filter((f) => /\.(mjs|cjs|js)$/.test(f) && !f.includes("node_modules") && !declaredFixture(f)).sort()) {
|
|
931
1026
|
add({ id: `node-check:${f}`, class: A, tool: "node", args: ["--check", f], root: "content" });
|
|
932
1027
|
}
|
|
933
|
-
const jsonFiles = files.filter((f) => f.endsWith(".json") && !f.includes("node_modules")).sort();
|
|
1028
|
+
const jsonFiles = files.filter((f) => f.endsWith(".json") && !f.includes("node_modules") && !declaredFixture(f)).sort();
|
|
934
1029
|
if (jsonFiles.length) add({ id: "json-parse", class: A, internal: "json-parse", files: jsonFiles });
|
|
935
|
-
const tomlFiles = files.filter((f) => f.endsWith(".toml") && !f.includes("node_modules")).sort();
|
|
1030
|
+
const tomlFiles = files.filter((f) => f.endsWith(".toml") && !f.includes("node_modules") && !declaredFixture(f)).sort();
|
|
936
1031
|
if (tomlFiles.length) add({ id: "toml-parse", class: A, internal: "toml-parse", files: tomlFiles });
|
|
937
1032
|
// The strict workflow YAML parser covers the subset GitHub workflows and actions use; other YAML is not parsed with it.
|
|
938
1033
|
const yamlFiles = files.filter((f) => /^\.github\/.+\.ya?ml$/.test(f)).sort();
|
|
@@ -950,7 +1045,12 @@ export function planScans({
|
|
|
950
1045
|
const wideJsRoots = new Set(pnpmRoots.filter((r) => files.some((f) => { const rel = wsRel(r, f); return rel !== null && WORKSPACE_JS_FILE.test(rel); })));
|
|
951
1046
|
const wideCrates = wideWs.size ? (rustMap ? [...rustMap.keys()].filter((n) => wideWs.has(wsOf(n))) : listDir(path.join(contentRoot, "crates"))).filter((n) => !rustScope || rustScope.has(n)) : [];
|
|
952
1047
|
if (wideWs.size && rustScope) notes.push(`workspace Rust change reaches ${wideCrates.length} crate(s) through the lock graph`);
|
|
953
|
-
|
|
1048
|
+
// A source outside every unit that a unit compiles (externalInputs), and a shared input, reach their units; nothing else outside a
|
|
1049
|
+
// unit's directory does (a crate-per-workspace repository has no root manifest or lock to widen the scope).
|
|
1050
|
+
const externalUnits = rustMap && externalInputs ? Object.entries(externalInputs).flatMap(([dir, names]) => (files.some((f) => f === dir || f.startsWith(`${dir.replace(/\/+$/, "")}/`)) ? names : [])).filter((n) => rustMap.has(n)) : [];
|
|
1051
|
+
const sharedUnits = rustMap && files.some((f) => SHARED_RUST_SCOPE_FILE.test(f)) ? [...rustMap.keys()] : [];
|
|
1052
|
+
if (sharedUnits.length) notes.push(`a shared build input changed (${files.filter((f) => SHARED_RUST_SCOPE_FILE.test(f)).join(", ")}); every Rust unit is in scope`);
|
|
1053
|
+
const crateNames = safeUnits([...new Set([...(rustMap ? files.map((f) => unitOf(rustMap, f)).filter(Boolean) : under("crates/").map((f) => f.split("/")[1])), ...wideCrates, ...externalUnits, ...sharedUnits])].filter((n) => exists(path.join(contentRoot, rustDir(n), "Cargo.toml"))).sort(), "crate");
|
|
954
1054
|
// The workspace a changed file belongs to is the deepest one that holds it (a root workspace holds only what no nested one does).
|
|
955
1055
|
const wsOfFile = (f) => { let best = null; for (const ws of workspaces) if ((ws === "." || f.startsWith(`${ws}/`)) && (best === null || ws.length > best.length)) best = ws; return best; };
|
|
956
1056
|
const configWs = new Set();
|
|
@@ -1002,7 +1102,7 @@ export function planScans({
|
|
|
1002
1102
|
// A non-empty list only: `[^\]\s]` is one character that is neither whitespace nor the closing bracket, so `ignore = []` followed by
|
|
1003
1103
|
// a later `]` (the next table header) is not an ignore (Pulse, 2026-10-27: `\S` matched the `]` itself).
|
|
1004
1104
|
const ignores = ["deny.toml", ".cargo/audit.toml"].some((f) => { try { return /\bignore\s*=\s*\[[^\]]*[^\]\s][^\]]*\]/s.test(readFile(path.join(contentRoot, dir, f))); } catch { return false; } });
|
|
1005
|
-
if (ignores && !(dir === "" && exists(path.join(contentRoot, "ci/advisory-exceptions.json")))) add({ id: `advisory-exceptions${dir ? `:${
|
|
1105
|
+
if (ignores && !(dir === "" && exists(path.join(contentRoot, "ci/advisory-exceptions.json")))) add({ id: `advisory-exceptions${dir ? `:${wsLabel(ws)}` : ""}`, class: A, internal: "advisory-exceptions", dir, exceptionsFile: ".rightkit/advisory-exceptions.json" });
|
|
1006
1106
|
}
|
|
1007
1107
|
// Like generated-docs, planned only when a changed file is one of the contract's inputs or projections. The generator runs from
|
|
1008
1108
|
// the trusted scanner checkout (the import below); the exported contract and projections are data it checks.
|
|
@@ -1085,6 +1185,7 @@ export function planScans({
|
|
|
1085
1185
|
...(trusted ? {} : { deferred: "requires a trusted-root run: the generator is repository code and no separate trusted checkout provides it; the content root's copy is never executed" }),
|
|
1086
1186
|
});
|
|
1087
1187
|
}
|
|
1188
|
+
const clippyEntries = [];
|
|
1088
1189
|
for (const name of crateNames) {
|
|
1089
1190
|
// Feature sets exactly as CI's gate runs them (scripts/ci/gate.mjs, ci/gate.config.json rust.<crate>):
|
|
1090
1191
|
// clippy per `featureSets` (default features when absent); check per `checkFeatureSets` (default
|
|
@@ -1097,16 +1198,25 @@ export function planScans({
|
|
|
1097
1198
|
// check runs only when no clippy set covers default features.
|
|
1098
1199
|
if (defaultCheck) add({ id: rustScanId.check(name), class: B, ...cargoIn(ws, ...checkArgs(crate, { locked: true })) });
|
|
1099
1200
|
checkSets.forEach((set, i) => add({ id: rustScanId.checkFeatures(name, i), class: B, ...cargoIn(ws, ...checkArgs(crate, { features: set, locked: true })) }));
|
|
1100
|
-
lintSets.forEach((set
|
|
1201
|
+
if (clippyBatching) lintSets.forEach((set) => clippyEntries.push({ name, crate, ws, set, count: lintSets.length, env: targetDirs?.[ws] ?? null }));
|
|
1202
|
+
else lintSets.forEach((set, i) => add({ id: rustScanId.clippy(name, lintSets.length, i), class: B, ...cargoIn(ws, ...clippyArgs(crate, { features: set, locked: true })) }));
|
|
1101
1203
|
// rustdoc flags go after `--` for this crate's library only: the broker owns RUSTDOCFLAGS and refuses
|
|
1102
1204
|
// a request that sets it. A crate with no library has no API docs to check.
|
|
1103
1205
|
if (exists(path.join(contentRoot, rustDir(name), "src", "lib.rs"))) {
|
|
1104
1206
|
add({ id: rustScanId.doc(name), class: B, ...cargoIn(ws, "rustdoc", "-p", crate, "--lib", ...lintSets[0], "--locked", "--", ...DOC_RUSTDOC_ARGS) });
|
|
1105
1207
|
}
|
|
1106
1208
|
}
|
|
1209
|
+
if (clippyBatching && clippyEntries.length) {
|
|
1210
|
+
// One Clippy scan per batch (row 8.6): the independent batches first, the sidecar-bound ones after them (row 8.8; the compile lane
|
|
1211
|
+
// drains in plan order). The reason a configuration stays separate is recorded in the plan notes.
|
|
1212
|
+
const sidecarBound = new Set(clippyEntries.filter((e) => sidecarBoundCrate({ dir: path.join(contentRoot, rustDir(e.name)), exists, readFile }).bound).map((e) => e.name));
|
|
1213
|
+
const plan = clippyBatchPlan(clippyEntries, { solo: shardSolo, separate: clippySeparate, sidecarBound, toolchain: cargoCommand.join(" ") });
|
|
1214
|
+
for (const b of plan.batches) add({ id: b.id, class: B, batch: { units: b.units, order: b.order, reason: b.reason }, ...cargoIn(b.ws, ...clippyArgs(b.crates, { features: b.set, locked: true })) });
|
|
1215
|
+
notes.push(...plan.notes);
|
|
1216
|
+
}
|
|
1107
1217
|
// Reverse dependents: a workspace crate that is not in scope but whose lock closure reaches one that is must still compile.
|
|
1108
1218
|
// A unit only depends on units of its own Cargo workspace (its own lock), so each workspace is judged by its own lock.
|
|
1109
|
-
const inScope = new Set(crateNames);
|
|
1219
|
+
const inScope = new Set(crateNames); const planned = new Set();
|
|
1110
1220
|
for (const ws of [...new Set(crateNames.map(wsOf))].sort()) {
|
|
1111
1221
|
const lock = lockOf(ws);
|
|
1112
1222
|
if (!lock) continue;
|
|
@@ -1114,7 +1224,13 @@ export function planScans({
|
|
|
1114
1224
|
const scoped = crateNames.filter((n) => wsOf(n) === ws).map(crateOf);
|
|
1115
1225
|
const workspace = (rustMap ? [...rustMap.keys()] : listDir(path.join(contentRoot, "crates"))).filter((n) => wsOf(n) === ws);
|
|
1116
1226
|
const dependents = safeUnits(workspace.filter((n) => !inScope.has(n) && exists(path.join(contentRoot, rustDir(n), "Cargo.toml")) && scoped.some((t) => reach(lock, crateOf(n), memo).has(t))).sort(), "crate");
|
|
1117
|
-
for (const name of dependents) add({ id: `check-dependent:${name}`, class: B, ...cargoIn(ws, ...checkArgs(crateOf(name), { locked: true })) });
|
|
1227
|
+
for (const name of dependents) { planned.add(name); add({ id: `check-dependent:${name}`, class: B, ...cargoIn(ws, ...checkArgs(crateOf(name), { locked: true })) }); }
|
|
1228
|
+
}
|
|
1229
|
+
// Across Cargo workspaces (one workspace and lock per crate): a unit that path-depends on a unit in scope, transitively, is judged by
|
|
1230
|
+
// the manifests' path dependencies, since no lock holds both.
|
|
1231
|
+
if (typeof crateDependents === "function" && rustMap && crateNames.length) {
|
|
1232
|
+
const across = [...crateDependents(new Set(crateNames))].filter((n) => !inScope.has(n) && !planned.has(n) && rustMap.has(n) && exists(path.join(contentRoot, rustDir(n), "Cargo.toml"))).sort();
|
|
1233
|
+
for (const name of safeUnits(across, "crate")) add({ id: `check-dependent:${name}`, class: B, ...cargoIn(wsOf(name), ...checkArgs(crateOf(name), { locked: true })) });
|
|
1118
1234
|
}
|
|
1119
1235
|
// A linter config in the unit's directory or any ancestor of it (a repository-level config covers every unit, as the tools look it up).
|
|
1120
1236
|
const hasConfig = (dir, names) => ancestorDirsOf(dir).some((d) => names.some((f) => exists(path.join(liveRoot, inDir(d, f)))));
|
|
@@ -1804,7 +1920,11 @@ export async function runScans(plan, options = {}) {
|
|
|
1804
1920
|
const ctx = { deps, contentRoot, read: (f) => deps.readFile(path.join(contentRoot, f)) };
|
|
1805
1921
|
const toolVersions = {}; const probed = new Map(); const advisoryDbs = new Map();
|
|
1806
1922
|
const results = [];
|
|
1807
|
-
//
|
|
1923
|
+
// The gate-owned cargo ledger of a wrapped snapshot compile (options.cargoLedger = { dir }): every call the wrapper decided, filed
|
|
1924
|
+
// under its scan's token. `ledgerSeen` is the run's one-use record of carriers and request ids across every scan.
|
|
1925
|
+
const cargoLedger = options.cargoLedger?.dir ? options.cargoLedger : null;
|
|
1926
|
+
const claimedTokens = new Set(); const ledgerSeen = { nonces: new Set(), ids: new Set() };
|
|
1927
|
+
// options.cache ={ read(key), write(key, row), digest(scan) => string | null } (null: not cacheable, which is how the live class A checks opt out).
|
|
1808
1928
|
const cache = options.cache && typeof options.cache.read === "function" && typeof options.cache.write === "function" && typeof options.cache.digest === "function" ? options.cache : null;
|
|
1809
1929
|
const cacheHits = [];
|
|
1810
1930
|
let classAFailed = false;
|
|
@@ -1844,7 +1964,11 @@ export async function runScans(plan, options = {}) {
|
|
|
1844
1964
|
// lease, open.
|
|
1845
1965
|
const probeMs = Number.isFinite(options.probeTimeoutMs) && options.probeTimeoutMs > 0 ? options.probeTimeoutMs : PROBE_TIMEOUT_MS;
|
|
1846
1966
|
const probeSignal = signal ? AbortSignal.any([signal, AbortSignal.timeout(probeMs)]) : AbortSignal.timeout(probeMs);
|
|
1847
|
-
|
|
1967
|
+
// The gate's own version probes reach the wrapper too (cargo is first on PATH): they are filed under the probe token, which the
|
|
1968
|
+
// gate claims and checks like a scan's.
|
|
1969
|
+
const probeLedgerEnv = cargoLedger ? { [SCAN_TOKEN_ENV]: PROBE_LEDGER_TOKEN } : {};
|
|
1970
|
+
if (cargoLedger) claimedTokens.add(PROBE_LEDGER_TOKEN);
|
|
1971
|
+
const r = await deps.spawn(command, PROBES[tool] ?? ["--version"], { cwd, env: { ...deps.env, ...PNPM_NO_HOOKS_ENV, COREPACK_ENABLE_AUTO_PIN: "0", ...probeLedgerEnv }, signal: probeSignal });
|
|
1848
1972
|
if (!r || r.error || r.status !== 0) outcome = { ok: false, why: r?.error?.message ?? `exit ${r?.status}` };
|
|
1849
1973
|
else {
|
|
1850
1974
|
const text = String(r.stdout || r.stderr || "");
|
|
@@ -1879,7 +2003,7 @@ export async function runScans(plan, options = {}) {
|
|
|
1879
2003
|
try { onResult(row, { done, total, seconds: Math.round((Date.now() - started) / 1000) }); } catch { /* reporting never fails a scan */ }
|
|
1880
2004
|
return row;
|
|
1881
2005
|
}
|
|
1882
|
-
// Class A may run concurrently.
|
|
2006
|
+
// Class A may run concurrently. Class B runs one scan per component at a time, `classBJobs` components at once (below).
|
|
1883
2007
|
const aScans = ordered.filter((s) => s.class === SCAN_CLASS.A);
|
|
1884
2008
|
const bScans = ordered.filter((s) => s.class === SCAN_CLASS.B);
|
|
1885
2009
|
// Class A scans are independent content checks (and short broker calls: metadata, package list, deny, audit): they run
|
|
@@ -1928,7 +2052,25 @@ export async function runScans(plan, options = {}) {
|
|
|
1928
2052
|
const slots = new Array(bScans.length);
|
|
1929
2053
|
const lanes = { compile: [], doc: [], tests: [], quiet: [] };
|
|
1930
2054
|
bScans.forEach((s, i) => lanes[laneOf(s)].push(i));
|
|
1931
|
-
|
|
2055
|
+
// RightKit is many components, never one build (Adrian, 2026-10-29; rightkit#1285): class B scans of different components run
|
|
2056
|
+
// concurrently, `classBJobs` at a time (the broker queues beyond its build capacity). A component is the directory a scan runs in
|
|
2057
|
+
// (`cwd`, its own Cargo workspace and output directory); scans of one component stay in plan order, because they share its
|
|
2058
|
+
// output directory and cargo locks it.
|
|
2059
|
+
const bJobs = Math.max(1, Number(options.classBJobs ?? 1) || 1);
|
|
2060
|
+
const drain = async (queue, extraEnv = null) => {
|
|
2061
|
+
const busy = new Set(); const running = new Set();
|
|
2062
|
+
const keyOf = (i) => bScans[i].cwd ?? ".";
|
|
2063
|
+
while (queue.length || running.size) {
|
|
2064
|
+
const at = running.size < bJobs ? queue.findIndex((i) => !busy.has(keyOf(i))) : -1;
|
|
2065
|
+
if (at === -1) { await Promise.race(running); continue; }
|
|
2066
|
+
const [i] = queue.splice(at, 1); const key = keyOf(i); busy.add(key);
|
|
2067
|
+
// A scan that throws becomes a failed row: with several components in flight, a rejection must not abandon the others.
|
|
2068
|
+
const job = runOne(bScans[i], extraEnv)
|
|
2069
|
+
.catch((error) => ({ id: bScans[i].id, class: bScans[i].class, command: bScans[i].command, tool: bScans[i].tool ?? null, toolVersion: null, status: "fail", code: "SCAN_ERROR", detail: String(error?.message ?? error) }))
|
|
2070
|
+
.then((row) => { slots[i] = row; }).finally(() => { busy.delete(key); running.delete(job); });
|
|
2071
|
+
running.add(job);
|
|
2072
|
+
}
|
|
2073
|
+
};
|
|
1932
2074
|
await drain(lanes.compile);
|
|
1933
2075
|
await drain(lanes.doc);
|
|
1934
2076
|
await drain(lanes.tests);
|
|
@@ -2119,14 +2261,19 @@ export async function runScans(plan, options = {}) {
|
|
|
2119
2261
|
// CARGO_TARGET_DIR gets an empty directory of its own as one. Every cargo reached through the wrapper is bound elsewhere; only
|
|
2120
2262
|
// a cargo that bypassed it (an absolute path, `rustup run`, a reset PATH) can write here, and the scan then fails below.
|
|
2121
2263
|
let sentinel = null;
|
|
2122
|
-
|
|
2264
|
+
// (A scan that is itself the carried broker request is bound by the broker: no sentinel, so the client's observed environment is the scan's own.)
|
|
2265
|
+
if (options.cargoSentinel && !viaSnapshotBroker && !Object.hasOwn(s.env ?? {}, "CARGO_TARGET_DIR")) {
|
|
2123
2266
|
sentinel = path.join(options.cargoSentinel, sha256(s.id).slice(0, 16));
|
|
2124
2267
|
fs.rmSync(sentinel, { recursive: true, force: true });
|
|
2125
2268
|
fs.mkdirSync(sentinel, { recursive: true });
|
|
2126
2269
|
}
|
|
2127
2270
|
const sentinelEnv = sentinel ? { CARGO_TARGET_DIR: sentinel, CARGO_BUILD_TARGET_DIR: sentinel } : {};
|
|
2271
|
+
// Every cargo call the wrapper decides during this scan is filed under this token in the gate's ledger.
|
|
2272
|
+
const ledgerToken = cargoLedger ? scanTokenOf(s.id) : null;
|
|
2273
|
+
if (ledgerToken) claimedTokens.add(ledgerToken);
|
|
2274
|
+
const ledgerEnv = ledgerToken ? { [SCAN_TOKEN_ENV]: ledgerToken } : {};
|
|
2128
2275
|
try {
|
|
2129
|
-
try { r = await deps.spawn(p.command, finalArgs, { cwd: path.join(rootDir(s.root), s.cwd ?? ""), env: { ...deps.env, ...sentinelEnv, ...s.env, ...(extraEnv ?? {}), ...guardEnv, ...(carrier ? carrier.made.env : {}) }, signal: scanSignal, timeout: limitMs }); } finally { clearTimeout(clock); advisory?.cleanup?.(); }
|
|
2276
|
+
try { r = await deps.spawn(p.command, finalArgs, { cwd: path.join(rootDir(s.root), s.cwd ?? ""), env: { ...deps.env, ...sentinelEnv, ...s.env, ...(extraEnv ?? {}), ...guardEnv, ...ledgerEnv, ...(carrier ? carrier.made.env : {}) }, signal: scanSignal, timeout: limitMs }); } finally { clearTimeout(clock); advisory?.cleanup?.(); }
|
|
2130
2277
|
if (signal?.aborted && (!r || r.error || r.status !== 0)) return { ...row, status: "not-run", code: "CANCELLED", detail: "cancelled while running" };
|
|
2131
2278
|
if ((timer.signal.aborted || r?.error?.code === "ETIMEDOUT") && (!r || r.error || r.status !== 0)) return { ...row, status: "fail", code: "TIMEOUT", detail: `${s.id} ran longer than ${Math.round(limitMs / 60000)} min and was killed with its process tree; a scan that does not finish fails` };
|
|
2132
2279
|
if (!r || r.error) { status = "fail"; code = "SPAWN_ERROR"; detail = r?.error?.message ?? "no result"; }
|
|
@@ -2139,6 +2286,19 @@ export async function runScans(plan, options = {}) {
|
|
|
2139
2286
|
try { written = fs.readdirSync(sentinel); } catch (e) { written = [`(unreadable: ${e.message})`]; }
|
|
2140
2287
|
if (written.length) { status = "fail"; code = "RK_CARGO_UNBOUND"; detail = `${s.id}: a cargo that bypassed the gate's wrapper wrote ${written.slice(0, 5).join(", ")} into the unbound sentinel ${sentinel}; with several Cargo workspaces no output directory can be inferred for it, so the scan proves nothing about where its outputs went`; }
|
|
2141
2288
|
}
|
|
2289
|
+
// The wrapper's ledger (rows 7.10 and 7.24): a refused, unverified or unfinished cargo call fails this scan even when the code
|
|
2290
|
+
// under test ignored cargo's exit status; on a broker host each call's signed receipt is verified again here, by the gate.
|
|
2291
|
+
if (ledgerToken) {
|
|
2292
|
+
let problems;
|
|
2293
|
+
try {
|
|
2294
|
+
const ledger = readLedger(cargoLedger.dir, ledgerToken);
|
|
2295
|
+
problems = [...ledger.problems, ...(deps.snapshotBroker ? await verifyBrokerLedger(ledger.entries, deps.snapshotBroker, { seen: ledgerSeen }) : [])];
|
|
2296
|
+
} catch (e) { problems = [`the cargo ledger cannot be read (${e.message})`]; }
|
|
2297
|
+
if (problems.length) {
|
|
2298
|
+
const text = `${s.id}: the gate's cargo ledger records ${problems.length} cargo call(s) that did not complete as a verified success: ${problems.slice(0, 5).join("; ")}`;
|
|
2299
|
+
if (status === "pass") { status = "fail"; code = "RK_CARGO_LEDGER"; detail = text; } else detail = `${detail ? `${detail}; ` : ""}${text}`;
|
|
2300
|
+
}
|
|
2301
|
+
}
|
|
2142
2302
|
if (s.advisoryDb === "deny" && code !== "RK_CARGO_UNBOUND") {
|
|
2143
2303
|
const denyError = `${r.stdout ?? ""}\n${r.stderr ?? ""}`.split(/\r?\n/).find((line) => /\[ERROR\]/.test(line));
|
|
2144
2304
|
if (r.truncated) { status = "fail"; code = "DENY_ERROR"; detail = "cargo-deny output exceeded the kept tail, so the absence of an [ERROR] line cannot be shown"; }
|
|
@@ -2150,7 +2310,7 @@ export async function runScans(plan, options = {}) {
|
|
|
2150
2310
|
try {
|
|
2151
2311
|
const ref = readRequestRef(path.join(carrier.refDir, "request.json"));
|
|
2152
2312
|
const receipt = await deps.snapshotBroker.receiptOf(ref.requestId);
|
|
2153
|
-
const { evidence } = verifySnapshotReceipt(receipt, { pins: deps.snapshotBroker.pins, jobId: ref.requestId, carrier: carrier.made.carrier, argv: carrier.cargoArgv, workspace: ref.workspace, observedEnv: observedEnvOf({ ...deps.env, ...s.env, ...(extraEnv ?? {}) }, deps.platform ?? process.platform) });
|
|
2313
|
+
const { evidence } = verifySnapshotReceipt(receipt, { pins: deps.snapshotBroker.pins, jobId: ref.requestId, carrier: carrier.made.carrier, argv: carrier.cargoArgv, workspace: ref.workspace, observedEnv: observedEnvOf({ ...deps.env, ...s.env, ...(extraEnv ?? {}) }, deps.platform ?? process.platform), checkout: deps.snapshotBroker.checkout, outputRoots: deps.snapshotBroker.outputRoots, platform: deps.snapshotBroker.platform ?? deps.platform ?? process.platform });
|
|
2154
2314
|
row.snapshotEvidence = { requestId: ref.requestId, outputDir: evidence.outputDir, treeAtStart: evidence.snapshot.treeAtStart, treeAtExit: evidence.snapshot.treeAtExit };
|
|
2155
2315
|
} catch (e) {
|
|
2156
2316
|
status = "fail"; code = "SNAPSHOT_EVIDENCE"; detail = `${s.id}: the broker's snapshot evidence did not verify (${e.code ?? "error"}: ${e.message})`;
|
|
@@ -2181,12 +2341,27 @@ export async function runScans(plan, options = {}) {
|
|
|
2181
2341
|
return { ...row, status, ...(code ? { code } : {}), ...(detail ? { detail } : {}) };
|
|
2182
2342
|
}
|
|
2183
2343
|
|
|
2344
|
+
// Cargo calls the gate cannot attribute to a scan (the code under test cleared or replaced the scan token): never skipped.
|
|
2345
|
+
if (cargoLedger) {
|
|
2346
|
+
// The gate's own version probes: anything but a completed call (a refused probe) fails the run too.
|
|
2347
|
+
try {
|
|
2348
|
+
const probeProblems = readLedger(cargoLedger.dir, PROBE_LEDGER_TOKEN).problems;
|
|
2349
|
+
if (probeProblems.length) results.push({ id: "cargo-ledger:tool-probe", class: SCAN_CLASS.B, command: "cargo ledger", tool: null, toolVersion: null, status: "fail", code: "RK_CARGO_LEDGER", detail: `the gate's tool version probes reached the wrapper and did not complete: ${probeProblems.slice(0, 5).join("; ")}` });
|
|
2350
|
+
} catch { /* an unreadable probe ledger is named by the unclaimed check only when it holds records */ }
|
|
2351
|
+
let unclaimed;
|
|
2352
|
+
try { unclaimed = unclaimedLedgers(cargoLedger.dir, claimedTokens); } catch (e) { unclaimed = [`unreadable (${e.message})`]; }
|
|
2353
|
+
if (unclaimed.length) {
|
|
2354
|
+
const calls = [];
|
|
2355
|
+
for (const token of unclaimed) { try { for (const e of readLedger(cargoLedger.dir, token).entries) calls.push(`cargo ${(e.argv ?? []).join(" ")} (${e.status}, from ${e.cwd})`.trim()); } catch { /* named by its token below */ } }
|
|
2356
|
+
results.push({ id: "cargo-ledger", class: SCAN_CLASS.B, command: "cargo ledger", tool: null, toolVersion: null, status: "fail", code: "RK_CARGO_LEDGER", detail: `cargo calls were filed under ${unclaimed.length} scan token(s) no scan owns (${unclaimed.slice(0, 3).join(", ")}), so they cannot be tied to a scan (${SCAN_TOKEN_ENV} was cleared, or the call came from outside every scan): ${calls.slice(0, 5).join("; ") || "unreadable"}` });
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
2184
2359
|
const cancelled = signal?.aborted === true;
|
|
2185
2360
|
return {
|
|
2186
2361
|
schema: SCAN_RESULT_SCHEMA, ok: !cancelled && results.every((r) => r.status === "pass"), complete: !cancelled && results.every((r) => r.status === "pass"), classes, checker, ...(cancelled ? { cancelled: true } : {}),
|
|
2187
2362
|
inputDigest: plan.inputDigest ?? null,
|
|
2188
2363
|
deferred: results.filter((r) => r.status === "deferred").map((r) => r.id),
|
|
2189
|
-
scans: results, toolVersions, cacheHits: [...cacheHits].sort(), notes: plan.notes ?? [],
|
|
2364
|
+
scans: results, toolVersions, cacheHits: [...cacheHits].sort(), notes: [...(plan.notes ?? []), ...(cargoLedger ? [UNOBSERVED_NESTED_CARGO_NOTE] : [])],
|
|
2190
2365
|
scanSetDigest: digestScanSet(results),
|
|
2191
2366
|
classDigests: { A: digestScanSet(results.filter((r) => r.class === "A")), B: digestScanSet(results.filter((r) => r.class === "B")) },
|
|
2192
2367
|
};
|
package/development.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import path from "node:path";
|
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import { defaultReleaseConfigFile, importReleaseConfig } from "./release-config.mjs";
|
|
9
9
|
import { assertPrimaryReleaseCheckout } from "./release-invocation.mjs";
|
|
10
|
+
import { readSourceSplit } from "./source-split.mjs";
|
|
10
11
|
import { assertReleaseExecutionAllowed } from "./execution-policy.mjs";
|
|
11
12
|
import { acquireManagedRunLease, directCargoEnv, directPlatform, managedRuntimeClient, managedRuntimeCommand, runCargoSync } from "./managed-cargo.mjs";
|
|
12
13
|
import { BUNDLE_MARKERS, bundleMarkerBuffer, readTauriBundleMarkers } from "./tauri-bundle-marker.mjs";
|
|
@@ -107,7 +108,10 @@ export function runDevelopment({ root, platform, config, buildOnly = false, dryR
|
|
|
107
108
|
// iOS: --dry-run validates the config and prints the plan on any host; everything else needs macOS with Xcode.
|
|
108
109
|
if (!(platform === "ios" && dryRun)) assertNativeDevelopmentHost(platform);
|
|
109
110
|
const target = validateDevelopmentConfig(config, platform, { root });
|
|
110
|
-
|
|
111
|
+
// Inside the door's source split `root` is the snapshot of a commit (no git directory): the repository is the checkout the door
|
|
112
|
+
// ran in, whose primary-checkout rule and execution policy still apply, and the commit is the split's.
|
|
113
|
+
const split = readSourceSplit({ env, cwd: root });
|
|
114
|
+
const repoRoot = split ? split.checkout : git(root, ["rev-parse", "--show-toplevel"]);
|
|
111
115
|
assertPrimaryReleaseCheckout(repoRoot, env);
|
|
112
116
|
if (dryRun && uiTests) {
|
|
113
117
|
if (!target.uiTests) throw new Error("development.targets.ios.uiTests is not declared: list the journeys in uiTests.onlyTesting");
|
|
@@ -147,10 +151,11 @@ export function runDevelopment({ root, platform, config, buildOnly = false, dryR
|
|
|
147
151
|
// Linux: the declared files become one tarball per architecture plus the digest manifest a guest link pins (rightkit#1230).
|
|
148
152
|
// Only from a clean checkout: an artifact others install must name the exact commit it was built from.
|
|
149
153
|
if (platform === "linux" && target.archive && !dryRun) {
|
|
150
|
-
|
|
154
|
+
// A split's snapshot is exactly its commit (the door verifies it at the start and end of the stage); the checkout may hold anything.
|
|
155
|
+
if (!split && git(repoRoot, ["status", "--porcelain", "--untracked-files=normal"])) throw new Error("linux artifacts are packed only from a clean checkout: commit first, so the manifest names the exact source");
|
|
151
156
|
let version;
|
|
152
157
|
try { version = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")).version; } catch { version = undefined; }
|
|
153
|
-
packLinuxArtifacts({ root, archive: validateLinuxArchive(target.archive), app: target.archive.name, version, commit: git(repoRoot, ["rev-parse", "HEAD"]), run });
|
|
158
|
+
packLinuxArtifacts({ root, archive: validateLinuxArchive(target.archive), app: target.archive.name, version, commit: split ? split.commit : git(repoRoot, ["rev-parse", "HEAD"]), run });
|
|
154
159
|
}
|
|
155
160
|
if (buildOnly || !target.install) return { built: true, installed: false, ...(staged.length ? { staged } : {}) };
|
|
156
161
|
if (iosPrepared) return { built: true, installed: true, ios: installIosDevelopment(iosPrepared.install, { root, env: childEnv, run }) };
|
|
@@ -361,7 +366,11 @@ function execute(command, { root, env, dryRun, run, label, door = null }) {
|
|
|
361
366
|
if (!inside(root, cwd)) throw new Error(`development ${label} cwd must stay inside app root`);
|
|
362
367
|
const args = (command.args ?? []).map(String);
|
|
363
368
|
if (dryRun) return { cmd: command.cmd, args, cwd };
|
|
364
|
-
|
|
369
|
+
// A pnpm step through the door is the broker's rightkit-pnpm job (or plain pnpm on a direct host), launched by name: the door never uses
|
|
370
|
+
// the node entry resolveCommand would find, so resolving pnpm's JavaScript CLI here would only fail a host that does not need it.
|
|
371
|
+
// The door's own `pnpm exec right-release` forms still resolve to this package's right-release.
|
|
372
|
+
const ownReleaseStep = (args[0] === "exec" && args[1] === "right-release") || args[0] === "right-release";
|
|
373
|
+
const resolved = door && command.cmd === "pnpm" && !ownReleaseStep ? { command: "pnpm", args } : resolveCommand(command.cmd, args, { root, env });
|
|
365
374
|
// Every dev build step goes through the one door (build-launch.mjs): the door's own right-release stays in process order, a pnpm step
|
|
366
375
|
// is the rightkit-pnpm broker job (by its own name, not the node entry resolveCommand finds), anything else as resolved.
|
|
367
376
|
// A configured command can run code regardless of its label, so installation commands use the door too.
|