@rightkit/release 0.6.16 → 0.6.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +7 -3
  2. package/admission-host.mjs +9 -5
  3. package/app-closure.mjs +9 -3
  4. package/bin/darwin/rightkit-swap +0 -0
  5. package/bin/rightkit-gate.mjs +469 -102
  6. package/bin/rightkit-linux-setup.mjs +15 -0
  7. package/broker-admission.mjs +184 -0
  8. package/broker-wire.mjs +15 -3
  9. package/build-launch.mjs +4 -1
  10. package/build-ownership.mjs +125 -36
  11. package/build-release.mjs +39 -7
  12. package/cargo-contract.mjs +4 -1
  13. package/cargo-guard.mjs +25 -5
  14. package/cargo-target-shim.mjs +56 -7
  15. package/clippy-route.mjs +8 -7
  16. package/crate-graph.mjs +5 -1
  17. package/credential-provider.mjs +174 -0
  18. package/deterministic-scans.mjs +247 -40
  19. package/development.mjs +8 -0
  20. package/disk-policy.mjs +2 -2
  21. package/gate-executor.mjs +5 -3
  22. package/gate-stages.mjs +34 -3
  23. package/gradle-lock.mjs +206 -0
  24. package/heavy-command.mjs +33 -2
  25. package/linux-artifacts.mjs +2 -1
  26. package/linux-setup.mjs +106 -0
  27. package/local-publish.mjs +21 -10
  28. package/local-qualify.mjs +9 -3
  29. package/managed-cargo.mjs +54 -18
  30. package/ownership-lockfiles.mjs +14 -3
  31. package/ownership.mjs +5 -1
  32. package/package.json +17 -11
  33. package/protected-credentials.mjs +13 -5
  34. package/publication-gate.mjs +9 -4
  35. package/python-lock.mjs +209 -0
  36. package/qa-admission-policy.json +32 -0
  37. package/qa-layout.mjs +229 -0
  38. package/qa-runner.mjs +156 -0
  39. package/release-artifact-path.mjs +29 -3
  40. package/release-config.mjs +1 -1
  41. package/release-qa-command.mjs +58 -8
  42. package/release-qa-policy.mjs +5 -3
  43. package/release-source-identity.mjs +1 -0
  44. package/release.mjs +10 -2
  45. package/rightkit-ownership.json +1465 -79
  46. package/rightkit-registry-resolver.mjs +7 -3
  47. package/rightkit-versions.json +173 -36
  48. package/runtime-budget-classes.json +4 -0
  49. package/runtime-budgets.mjs +5 -1
  50. package/shell-contract.mjs +1 -1
  51. package/snapshot-broker.mjs +107 -9
  52. package/snapshot-check.mjs +2 -0
  53. package/snapshot-request.mjs +20 -3
  54. package/snapshot.mjs +11 -4
  55. package/stage-commands.mjs +2 -1
  56. package/stage-ledger.mjs +15 -0
  57. package/stage-sidecar.mjs +17 -3
  58. package/suite-signing.mjs +40 -4
  59. package/swift-lock.mjs +334 -0
  60. package/test-command.mjs +13 -3
  61. package/tool-build.mjs +156 -0
  62. package/unit-gate.mjs +9 -2
  63. package/windows-identity-plan.mjs +119 -0
  64. package/windows-identity.mjs +136 -0
package/gate-stages.mjs CHANGED
@@ -54,6 +54,8 @@ export const STAGE_SOURCES = Object.freeze({
54
54
  "unit:compile": { compiles: true, packages: false, source: "snapshot" },
55
55
  "unit:package": { compiles: true, packages: true, source: "snapshot" },
56
56
  "unit:qualify": { compiles: false, packages: false, source: "snapshot" },
57
+ // `--to tool --unit <selector>` (tool-build.mjs): builds one named tool binary from the commit's snapshot and enrols it; it packages and uploads nothing.
58
+ "unit:tool": { compiles: true, packages: false, source: "snapshot" },
57
59
  "unit:release": { compiles: false, packages: false, source: "publisher", basis: "a unit release is dispatched to the trusted publisher (releaseUnit); it compiles nothing here" },
58
60
  });
59
61
 
@@ -228,10 +230,39 @@ export function compileKeyOf(env = {}) {
228
230
  return sha256(JSON.stringify(picked));
229
231
  }
230
232
 
231
- /** What a passed compile stage records about its outputs: the directories, their census and the key of the compile environment. */
232
- export function compileOutputsRecord({ dirs = [], env = {}, census = outputCensus }) {
233
+ /** The profile directory a gate compile writes (the dev profile; the install route reads its binaries there). */
234
+ export const COMPILE_PROFILE_DIR = "debug";
235
+
236
+ /** How `binaries` was chosen: only the bin targets the compile built in that directory (a consumer requires this marker). */
237
+ export const BUILT_BINARIES_SCHEMA = "built-bin-targets/1";
238
+
239
+ /**
240
+ * The sha256 of each binary this compile built (rustright#209 item 5): for every output directory, the files `<dir>/debug/<bin>[.exe]` of the
241
+ * bin targets the compile built there (`builtBins`, { [dir]: [bin target name] }: the units whose scans ran `cargo build -p <crate>` in that
242
+ * directory's workspace, so each binary was rebuilt or proven fresh by Cargo for this commit). Any other file in `debug/`, a stale binary of a
243
+ * removed or renamed target or of a package this run did not build, is never recorded. A census proves nothing once truncated (a broker
244
+ * workspace target is tens of GB, past OUTPUT_CENSUS_LIMIT); a consumer that installs a binary needs only that binary's bytes to be the compile's.
245
+ * A target whose file is missing is recorded with `sha256: null`. Sorted by directory, then name.
246
+ */
247
+ export function compiledBinaries(builtBins = {}, { platform = process.platform, stat = (f) => fs.statSync(f), readFile = (f) => fs.readFileSync(f) } = {}) {
248
+ const out = [];
249
+ for (const dir of Object.keys(builtBins ?? {}).filter((d) => typeof d === "string" && d).sort()) {
250
+ for (const bin of [...new Set(builtBins[dir] ?? [])].sort()) {
251
+ const name = `${bin}${platform === "win32" ? ".exe" : ""}`;
252
+ const file = path.join(dir, COMPILE_PROFILE_DIR, name);
253
+ let info = null;
254
+ try { info = stat(file); } catch { info = null; }
255
+ out.push(info?.isFile?.() ? { dir, name, size: info.size, sha256: sha256(readFile(file)) } : { dir, name, size: null, sha256: null });
256
+ }
257
+ }
258
+ return out;
259
+ }
260
+
261
+ /** What a passed compile stage records about its outputs: the directories, their census, the built binaries' digests and the key of the compile environment. */
262
+ export function compileOutputsRecord({ dirs = [], env = {}, builtBins = null, census = outputCensus, binaries = compiledBinaries }) {
233
263
  const c = census(dirs);
234
- return { dirs: c.dirs, entries: c.entries, digest: c.digest, truncated: c.truncated, key: compileKeyOf(env) };
264
+ const known = builtBins && typeof builtBins === "object" ? Object.fromEntries(Object.entries(builtBins).filter(([dir]) => c.dirs.includes(dir))) : null;
265
+ return { dirs: c.dirs, entries: c.entries, digest: c.digest, truncated: c.truncated, ...(known ? { binaries: binaries(known), binariesOf: BUILT_BINARIES_SCHEMA } : {}), key: compileKeyOf(env) };
235
266
  }
236
267
 
237
268
  /**
@@ -0,0 +1,206 @@
1
+ // File-only Gradle dependency-locking check (class A scan `gradle-lock`). Gradle resolves dependencies at build time, so a build with no
2
+ // committed lock can drift between hosts; with a lock, a stale one fails here instead of inside the compile. This module reads committed
3
+ // files and executes nothing (no Gradle, no wrapper). It checks DIRECT dependencies only: each project directory that declares dependencies
4
+ // must have its own gradle.lockfile (or legacy gradle/dependency-locks/*.lockfile), and every literal direct dependency (build.gradle[.kts]
5
+ // strings and the libs.versions.toml entries the build references) must have a locked version at or above the declared one. Dynamic or
6
+ // interpolated versions are checked for presence only; a declaration that cannot be read literally is a finding, never a silent pass.
7
+ const NOT_SET_UP = "Gradle dependency locking is not set up: run ./gradlew dependencies --write-locks and commit gradle.lockfile";
8
+ const join = (dir, f) => (dir === "" || dir === "." ? f : `${dir}/${f}`);
9
+ const SKIP_DIRS = new Set([".git", ".gradle", "build", "node_modules", ".idea", "out", "target", ".build"]);
10
+ const BUILD_FILE = /^build\.gradle(\.kts)?$/;
11
+ export const GRADLE_BUILD_FILE = /(^|\/)(build|settings)\.gradle(\.kts)?$/;
12
+
13
+ // ---- versions -------------------------------------------------------------------------------------------------------------
14
+ const tokensOf = (v) => String(v).split(/[.\-_+]/).filter((t) => t !== "");
15
+ const RELEASE_WORDS = new Set(["final", "ga", "release", "sp"]);
16
+ /** Gradle-like version order (approximate): numeric segments by value, qualifiers below the plain release. */
17
+ export function compareGradleVersions(a, b) {
18
+ const x = tokensOf(a); const y = tokensOf(b);
19
+ for (let i = 0; i < Math.max(x.length, y.length); i++) {
20
+ const p = x[i]; const q = y[i];
21
+ if (p === undefined) return /^\d+$/.test(q) ? (Number(q) === 0 ? 0 : -1) : RELEASE_WORDS.has(q.toLowerCase()) ? 0 : 1;
22
+ if (q === undefined) return /^\d+$/.test(p) ? (Number(p) === 0 ? 0 : 1) : RELEASE_WORDS.has(p.toLowerCase()) ? 0 : -1;
23
+ const pn = /^\d+$/.test(p); const qn = /^\d+$/.test(q);
24
+ if (pn && qn) { if (Number(p) !== Number(q)) return Number(p) < Number(q) ? -1 : 1; continue; }
25
+ if (pn !== qn) return pn ? 1 : -1;
26
+ if (p.toLowerCase() !== q.toLowerCase()) return p.toLowerCase() < q.toLowerCase() ? -1 : 1;
27
+ }
28
+ return 0;
29
+ }
30
+ const isDynamic = (v) => v === null || v === undefined || /[+[\](),]|^latest\.|\$/.test(v);
31
+
32
+ // ---- lockfiles ------------------------------------------------------------------------------------------------------------
33
+ /** Map "group:artifact" -> Set of locked versions, from gradle.lockfile text (`g:a:v=config,config`; comments and `empty=` ignored). */
34
+ export function parseLockfile(text, into = new Map()) {
35
+ for (const raw of String(text).split(/\r?\n/)) {
36
+ const line = raw.trim();
37
+ if (!line || line.startsWith("#") || line.startsWith("empty=")) continue;
38
+ const m = /^([^:=\s]+):([^:=\s]+):([^=\s]+)=/.exec(line);
39
+ if (!m) continue;
40
+ const key = `${m[1]}:${m[2]}`;
41
+ if (!into.has(key)) into.set(key, new Set());
42
+ into.get(key).add(m[3]);
43
+ }
44
+ return into;
45
+ }
46
+
47
+ // ---- libs.versions.toml (the literal shapes Gradle documents; no TOML library needed) -------------------------------------
48
+ function stripTomlComment(line) {
49
+ let inStr = false;
50
+ for (let i = 0; i < line.length; i++) {
51
+ const c = line[i];
52
+ if (c === '"' && line[i - 1] !== "\\") inStr = !inStr;
53
+ else if (c === "#" && !inStr) return line.slice(0, i);
54
+ }
55
+ return line;
56
+ }
57
+ const tomlStr = (s) => { const m = /^"((?:[^"\\]|\\.)*)"$/.exec(String(s).trim()); return m ? m[1] : null; };
58
+ function inlineFields(body) {
59
+ // `key = "v"`, `key.ref = "v"`, `key = { ref = "v" }` inside one inline table (one level of nesting).
60
+ const out = {}; const re = /([A-Za-z_][\w.-]*)\s*=\s*("(?:[^"\\]|\\.)*"|\{[^}]*\})/g; let m;
61
+ while ((m = re.exec(body))) {
62
+ if (m[2].startsWith("{")) { for (const [k, v] of Object.entries(inlineFields(m[2].slice(1, -1)))) out[`${m[1]}.${k}`] = v; } else out[m[1]] = tomlStr(m[2]);
63
+ }
64
+ return out;
65
+ }
66
+ /** { versions, libraries: Map(alias -> { module, version, line }), bundles: Map(name -> [alias]), problems: [{ line, text }] } */
67
+ export function parseCatalog(text) {
68
+ const versions = {}; const libraries = new Map(); const bundles = new Map(); const problems = []; const refs = [];
69
+ let section = null;
70
+ String(text).split(/\r?\n/).forEach((raw, i) => {
71
+ const line = stripTomlComment(raw).trim();
72
+ if (!line) return;
73
+ const sec = /^\[([\w.-]+)\]$/.exec(line);
74
+ if (sec) { section = sec[1]; return; }
75
+ const kv = /^([A-Za-z_][\w.-]*)\s*=\s*(.*)$/.exec(line);
76
+ if (!kv) return;
77
+ const [, key, value] = kv; const n = i + 1;
78
+ if (section === "versions") {
79
+ const s = tomlStr(value);
80
+ if (s !== null) versions[key] = s;
81
+ else if (value.startsWith("{")) { const f = inlineFields(value.slice(1, value.lastIndexOf("}"))); versions[key] = f.strictly ?? f.require ?? f.prefer ?? null; if (versions[key] === null) problems.push({ line: n, text: `version ${key} has no strictly/require/prefer literal` }); }
82
+ else problems.push({ line: n, text: `version ${key} is not a literal the checker reads` });
83
+ } else if (section === "libraries") {
84
+ const s = tomlStr(value);
85
+ if (s !== null) {
86
+ const p = s.split(":");
87
+ if (p.length >= 2 && p.length <= 3) libraries.set(key, { module: `${p[0]}:${p[1]}`, version: p[2] ?? null, line: n });
88
+ else problems.push({ line: n, text: `library ${key} is not group:artifact[:version]` });
89
+ } else if (value.startsWith("{")) {
90
+ const f = inlineFields(value.slice(1, value.lastIndexOf("}")));
91
+ const module = f.module ?? (f.group && f.name ? `${f.group}:${f.name}` : null);
92
+ if (!module) { problems.push({ line: n, text: `library ${key} has no module or group/name` }); return; }
93
+ const ref = f["version.ref"] ?? f["version.ref"] ?? null;
94
+ const direct = f.version ?? f["version.strictly"] ?? f["version.require"] ?? f["version.prefer"] ?? null;
95
+ const lib = { module, version: direct, line: n };
96
+ if (ref !== null) refs.push([lib, ref]);
97
+ libraries.set(key, lib);
98
+ } else problems.push({ line: n, text: `library ${key} is not a literal the checker reads` });
99
+ } else if (section === "bundles") {
100
+ const members = [...value.matchAll(/"([^"]+)"/g)].map((m) => m[1]);
101
+ bundles.set(key, members);
102
+ }
103
+ });
104
+ for (const [lib, ref] of refs) {
105
+ if (Object.hasOwn(versions, ref)) lib.version = versions[ref];
106
+ else problems.push({ line: lib.line, text: `library ${lib.module} references version ${ref}, which [versions] does not define` });
107
+ }
108
+ return { versions, libraries, bundles, problems };
109
+ }
110
+ const aliasKey = (a) => a.replace(/[-_.]/g, ".").toLowerCase();
111
+
112
+ // ---- build files ----------------------------------------------------------------------------------------------------------
113
+ const CONFIG = "(?:api|implementation|compileOnly|runtimeOnly|compile|runtime|annotationProcessor|kapt|ksp|coreLibraryDesugaring|lintChecks|lintPublish|[a-z][A-Za-z]*(?:Implementation|Api|CompileOnly|RuntimeOnly|AnnotationProcessor|Kapt|Ksp|Compile|Runtime))";
114
+ const DEP_LINE = new RegExp(`^\\s*(${CONFIG})\\b\\s*\\(?\\s*(?:platform\\(\\s*|enforcedPlatform\\(\\s*)?(.*)$`);
115
+ function stripGradleComments(text) {
116
+ return String(text).replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " ")).split("\n").map((l) => l.replace(/(^|[^:"'\w])\/\/.*$/, "$1")).join("\n");
117
+ }
118
+ /** [{ line, config, kind: "gav"|"alias"|"bundle"|"unreadable", group, name, version, alias, text }] from a build.gradle[.kts]'s dependency lines. */
119
+ export function parseBuildDependencies(source) {
120
+ const out = [];
121
+ stripGradleComments(source).split("\n").forEach((l, i) => {
122
+ const m = DEP_LINE.exec(l);
123
+ if (!m) return;
124
+ const line = i + 1; const config = m[1]; let rest = m[2].trim();
125
+ if (/^(project|files|fileTree|gradleApi|localGroovy|gradleKotlinDsl)\b/.test(rest) || rest.startsWith("{") || rest === "") return;
126
+ let r;
127
+ if ((r = /^["']([^"':\s]+):([^"':\s]+)(?::([^"':\s]*))?["']/.exec(rest))) { out.push({ line, config, kind: "gav", group: r[1], name: r[2], version: r[3] ?? null }); return; }
128
+ if ((r = /^group\s*[:=]\s*["']([^"']+)["']\s*,\s*name\s*[:=]\s*["']([^"']+)["'](?:\s*,\s*version\s*[:=]\s*["']([^"']+)["'])?/.exec(rest))) { out.push({ line, config, kind: "gav", group: r[1], name: r[2], version: r[3] ?? null }); return; }
129
+ if ((r = /^libs\.bundles\.([\w.]+?)(?:\.get\(\))?\s*\)*\s*$/.exec(rest))) { out.push({ line, config, kind: "bundle", alias: r[1] }); return; }
130
+ if ((r = /^libs\.((?!plugins\.|versions\.)[\w.]+?)(?:\.get\(\))?\s*\)*\s*$/.exec(rest))) { out.push({ line, config, kind: "alias", alias: r[1] }); return; }
131
+ if ((r = /^kotlin\(\s*["']([\w.-]+)["']/.exec(rest))) { out.push({ line, config, kind: "gav", group: "org.jetbrains.kotlin", name: `kotlin-${r[1]}`, version: null }); return; }
132
+ out.push({ line, config, kind: "unreadable", text: rest.slice(0, 70) });
133
+ });
134
+ return out;
135
+ }
136
+
137
+ /** null when `locked` (Set) holds the dependency at or above `version`; else why not. Dynamic versions need only presence. */
138
+ function lockedProblem(locked, version) {
139
+ if (!locked || locked.size === 0) return "has no locked version";
140
+ if (isDynamic(version)) return null;
141
+ return [...locked].some((v) => compareGradleVersions(v, version) >= 0) ? null : `is locked at ${[...locked].join(", ")}, below the declared ${version}`;
142
+ }
143
+
144
+ /**
145
+ * Findings for the Gradle build rooted at `dir` ("." for the repository root). `read(file)` / `exists(file)` take repo-relative paths;
146
+ * `list(dir) -> [{ name, isDir }]` lists a directory.
147
+ */
148
+ export function gradleLockProblems({ dir, read, exists, list }) {
149
+ const out = [];
150
+ const catalogFile = join(dir, "gradle/libs.versions.toml");
151
+ let catalog = null;
152
+ if (exists(catalogFile)) { catalog = parseCatalog(read(catalogFile)); out.push(...catalog.problems.map((p) => `${catalogFile}:${p.line}: ${p.text}`)); }
153
+ const builds = [];
154
+ const walk = (d, depth) => {
155
+ let entries = [];
156
+ try { entries = list(d); } catch { return; }
157
+ for (const e of entries) {
158
+ if (e.isDir) { if (depth < 6 && !SKIP_DIRS.has(e.name) && !e.name.startsWith(".")) walk(join(d, e.name), depth + 1); } else if (BUILD_FILE.test(e.name)) builds.push(join(d, e.name));
159
+ }
160
+ };
161
+ walk(dir, 0);
162
+ for (const file of builds.sort()) {
163
+ const deps = parseBuildDependencies(read(file));
164
+ if (!deps.length) continue;
165
+ const projectDir = file.includes("/") ? file.slice(0, file.lastIndexOf("/")) : ".";
166
+ const lockFile = join(projectDir, "gradle.lockfile");
167
+ const locks = new Map(); let any = false;
168
+ if (exists(lockFile)) { parseLockfile(read(lockFile), locks); any = true; }
169
+ const legacy = join(projectDir, "gradle/dependency-locks");
170
+ try { for (const e of list(legacy)) if (!e.isDir && e.name.endsWith(".lockfile")) { parseLockfile(read(`${legacy}/${e.name}`), locks); any = true; } } catch { /* no legacy lock directory */ }
171
+ if (!any) { out.push(`${file}: ${deps.length} declared ${deps.length === 1 ? "dependency" : "dependencies"} and no ${lockFile}: ${NOT_SET_UP}`); continue; }
172
+ const expand = [];
173
+ for (const d of deps) {
174
+ const at = `${file}:${d.line}`;
175
+ if (d.kind === "unreadable") { out.push(`${at}: ${d.config} dependency cannot be read literally (${d.text})`); continue; }
176
+ if (d.kind === "gav") {
177
+ if (/\$|\{/.test(d.group + d.name)) { out.push(`${at}: ${d.config} dependency group or name is interpolated, which cannot be read literally`); continue; }
178
+ expand.push({ at, module: `${d.group}:${d.name}`, version: d.version });
179
+ continue;
180
+ }
181
+ if (!catalog) { out.push(`${at}: ${d.config} uses libs.${d.alias} but ${catalogFile} does not exist`); continue; }
182
+ if (d.kind === "bundle") {
183
+ const key = [...catalog.bundles.keys()].find((k) => aliasKey(k) === aliasKey(d.alias));
184
+ if (!key) { out.push(`${at}: libs.bundles.${d.alias} is not in ${catalogFile} [bundles]`); continue; }
185
+ for (const a of catalog.bundles.get(key)) { const lib = catalog.libraries.get(a); if (lib) expand.push({ at, module: lib.module, version: lib.version, via: `libs.bundles.${d.alias}` }); else out.push(`${at}: bundle ${key} names ${a}, which [libraries] does not define`); }
186
+ continue;
187
+ }
188
+ const key = [...catalog.libraries.keys()].find((k) => aliasKey(k) === aliasKey(d.alias));
189
+ if (!key) { out.push(`${at}: libs.${d.alias} is not in ${catalogFile} [libraries]`); continue; }
190
+ const lib = catalog.libraries.get(key);
191
+ expand.push({ at, module: lib.module, version: lib.version, via: `libs.${d.alias}` });
192
+ }
193
+ for (const e of expand) {
194
+ const why = lockedProblem(locks.get(e.module), e.version);
195
+ if (why) out.push(`${e.at}: ${e.module}${e.version ? `:${e.version}` : ""}${e.via ? ` (${e.via})` : ""} ${why} in ${lockFile}`);
196
+ }
197
+ }
198
+ return out;
199
+ }
200
+
201
+ /** Repo-relative Gradle build root for a changed file: the nearest ancestor with settings.gradle[.kts], else the nearest with build.gradle[.kts]. */
202
+ export function gradleOwnerOf(file, { exists, ancestorDirs }) {
203
+ const dirs = ancestorDirs(file.includes("/") ? file.slice(0, file.lastIndexOf("/")) : ".").reverse();
204
+ const has = (d, names) => names.some((n) => exists(join(d, n)));
205
+ return dirs.find((d) => has(d, ["settings.gradle", "settings.gradle.kts"])) ?? dirs.find((d) => has(d, ["build.gradle", "build.gradle.kts"])) ?? null;
206
+ }
package/heavy-command.mjs CHANGED
@@ -221,12 +221,43 @@ function spawnOwnerWatcher(details) {
221
221
  watcher.unref();
222
222
  }
223
223
 
224
- export function summarizePreBrokerWaits({ heavySlot, suiteSlot, heavyOwnerInherited = false } = {}) {
224
+ /**
225
+ * Whether `right-release build` takes the host-wide heavy-work slot for the whole run (rightkit#1367 step 2). On a broker host every compile, Swift and
226
+ * xcodebuild step, pnpm and node step goes through build-launch and becomes a broker job (Cargo, SwiftPM, native iOS alike), so the broker's scheduler and
227
+ * resource budget admit the work: no slot for the run and no memory or CPU wait. What the release runs itself, outside build-launch, is local work the broker
228
+ * never sees (`localSteps`: its own `pnpm install`); the caller takes the slot around just those steps. A direct host takes the slot for the whole run. A
229
+ * parent that already owns one (RIGHTSUITE_HEAVY_WORK_OWNER=1) is inherited, never taken twice.
230
+ * @returns {{ take: boolean, localSteps: boolean, inherited: boolean, skipReason: string | null }}
231
+ */
232
+ export function heavySlotDecision({ env = process.env, brokerHost = false } = {}) {
233
+ if (env.RIGHTSUITE_HEAVY_WORK_OWNER === "1") return { take: false, localSteps: false, inherited: true, skipReason: null };
234
+ if (brokerHost) return { take: false, localSteps: true, inherited: false, skipReason: "broker host: build-launch makes the build steps broker jobs, which the broker's scheduler admits; the slot is held only around the release's own local steps" };
235
+ return { take: true, localSteps: false, inherited: false, skipReason: null };
236
+ }
237
+
238
+ /** The slot decision and the slot it asks for, in one step: what a release run takes before it starts. `acquire` is a seam for tests. */
239
+ export function acquireRunHeavySlot({ env = process.env, brokerHost = false, acquire = acquireHeavyWorkSlot, ...options } = {}) {
240
+ const decision = heavySlotDecision({ env, brokerHost });
241
+ return { decision, slot: decision.take ? acquire(options) : null };
242
+ }
243
+
244
+ /**
245
+ * Runs the local step `run` holding the heavy-work slot when the decision says the run holds none but has local steps (a broker host: its own `pnpm install` is a process
246
+ * the broker never sees); otherwise `run` runs as it is (the run holds the slot, or inherits one). `onSlot(slot)` is told when the slot is taken and `onSlot(null)` when it ends.
247
+ */
248
+ export async function withLocalHeavySlot(decision, run, { acquire = acquireHeavyWorkSlot, onSlot = () => {}, ...options } = {}) {
249
+ if (!decision?.localSteps) return run(null);
250
+ const slot = acquire(options);
251
+ onSlot(slot);
252
+ try { return await run(slot); } finally { slot.release(); onSlot(null); }
253
+ }
254
+
255
+ export function summarizePreBrokerWaits({ heavySlot, suiteSlot, heavyOwnerInherited = false, heavySkipped = null } = {}) {
225
256
  const heavy = heavySlot?.waits ?? null;
226
257
  const suite = suiteSlot?.waits ?? null;
227
258
  const layers = [
228
259
  heavy ? { layer: "heavy-work-slot", ms: (heavy.slotWaitMs ?? 0) + (heavy.resourceWaitMs ?? 0), slotWaitMs: heavy.slotWaitMs ?? 0, resourceWaitMs: heavy.resourceWaitMs ?? 0, blockers: heavy.slotBlockers ?? [], resourceBlockers: heavy.resourceBlockers ?? [] }
229
- : { layer: "heavy-work-slot", ms: 0, skipped: heavyOwnerInherited ? "slot owned by parent heavy-command" : "not acquired" },
260
+ : { layer: "heavy-work-slot", ms: 0, skipped: heavyOwnerInherited ? "slot owned by parent heavy-command" : heavySkipped ?? "not acquired" },
230
261
  suite ? { layer: "suite-build-slot", ms: suite.waitMs ?? 0, blockers: suite.blockers ?? [] } : { layer: "suite-build-slot", ms: 0, skipped: "inactive (non-shared cache mode)" },
231
262
  ];
232
263
  const totalMs = layers.reduce((sum, layer) => sum + layer.ms, 0);
@@ -91,7 +91,8 @@ export function packLinuxArtifacts({ root, archive, app, version, commit, split,
91
91
  const names = archive.include.map((item) => path.basename(item));
92
92
  const dup = names.find((n, i) => names.indexOf(n) !== i);
93
93
  if (dup) throw new Error(`linux artifacts: two include entries share the base name ${dup}; the archive is flat`);
94
- const tar = run("tar", ["-czf", target, ...archive.include.flatMap((item) => ["-C", path.join(root, path.dirname(item)), path.basename(item)])], { stdio: "inherit" });
94
+ // The archive is named relative to the working directory, and `-C` directories use forward slashes: a GNU tar (Git for Windows) reads `C:\\...` before the first colon as `host:path` and fails.
95
+ const tar = run("tar", ["-czf", file, ...archive.include.flatMap((item) => ["-C", path.resolve(root, path.dirname(item)).replaceAll("\\", "/"), path.basename(item)])], { cwd: outDir, stdio: "inherit" });
95
96
  if (tar.status !== 0) throw new Error(`linux artifacts: tar failed (${tar.status})`);
96
97
  const bytes = fs.readFileSync(target);
97
98
  const entry = { file, sha256: createHash("sha256").update(bytes).digest("hex"), size: bytes.length, glibc };
@@ -0,0 +1,106 @@
1
+ // Idempotent Linux host provisioning for apps gated by `rightkit-gate` (rightkit#1365): reads the app's pins, installs Node
2
+ // (official tarball into ~/.local), pnpm (corepack, else npm into ~/.local), Rust (rustup), then fills the pnpm store for every
3
+ // committed lockroot with `pnpm fetch` against a throwaway virtual store, so an existing node_modules is never cleared.
4
+ import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
5
+ import { homedir, tmpdir } from "node:os";
6
+ import { join, relative, resolve } from "node:path";
7
+ import { parse as parseToml } from "smol-toml";
8
+
9
+ const EXACT = /^\d+\.\d+\.\d+$/;
10
+
11
+ export function readPins(root, read = (p) => (existsSync(p) ? readFileSync(p, "utf8") : null)) {
12
+ const pkgText = read(join(root, "package.json"));
13
+ const pkg = pkgText ? JSON.parse(pkgText) : {};
14
+ const nodeFile = read(join(root, ".node-version"))?.trim().replace(/^v/, "");
15
+ const node = nodeFile || String(pkg.engines?.node ?? "").trim() || null;
16
+ const pm = /^pnpm@(\d+\.\d+\.\d+)/.exec(pkg.packageManager ?? "");
17
+ const tomlText = read(join(root, "rust-toolchain.toml"));
18
+ const rust = tomlText ? (parseToml(tomlText).toolchain?.channel ?? null) : null;
19
+ return { node, pnpm: pm ? pm[1] : null, rust };
20
+ }
21
+
22
+ // "26.8.x" / "26.8" / "26" / "^26.8.0" / "26.8.1" -> newest matching release in the nodejs.org index (array of {version}).
23
+ export function resolveNodeVersion(spec, index) {
24
+ if (!spec) return null;
25
+ const clean = spec.replace(/^[v^~=]+/, "").replace(/\.x\b/g, "").replace(/\.\*/g, "");
26
+ if (EXACT.test(clean)) return clean;
27
+ const m = /^(\d+)(?:\.(\d+))?/.exec(clean);
28
+ if (!m) throw new Error(`cannot resolve Node pin ${JSON.stringify(spec)}`);
29
+ const found = index.map((e) => e.version.replace(/^v/, "")).filter((v) => {
30
+ const [a, b] = v.split(".");
31
+ return a === m[1] && (m[2] === undefined || b === m[2]);
32
+ }).sort((x, y) => x.split(".").map(Number).reduce((d, n, i) => d || n - Number(y.split(".")[i]), 0) * -1);
33
+ if (!found.length) throw new Error(`no Node release matches ${JSON.stringify(spec)}`);
34
+ return found[0];
35
+ }
36
+
37
+ export function nodeArch(arch = process.arch) {
38
+ const a = { x64: "x64", arm64: "arm64", arm: "armv7l" }[arch];
39
+ if (!a) throw new Error(`unsupported CPU architecture ${arch}`);
40
+ return a;
41
+ }
42
+
43
+ export function findLockroots(root, readDir = (d) => readdirSync(d, { withFileTypes: true })) {
44
+ const out = [];
45
+ const walk = (dir) => {
46
+ const entries = readDir(dir);
47
+ if (entries.some((e) => e.isFile() && e.name === "pnpm-lock.yaml")) out.push(relative(root, dir) || ".");
48
+ for (const e of entries) {
49
+ if (e.isDirectory() && !["node_modules", ".git", "target"].includes(e.name) && !e.name.startsWith(".")) walk(join(dir, e.name));
50
+ }
51
+ };
52
+ walk(root);
53
+ return out.sort();
54
+ }
55
+
56
+ // run(cmd, args, opts) -> {status, stdout}; fetchText(url) -> string. Both injectable so the plan is testable offline.
57
+ export function setup({ root, home = homedir(), arch = process.arch, run, fetchText, log = () => {}, env = process.env, dryRun = false } = {}) {
58
+ root = resolve(root);
59
+ const pins = readPins(root);
60
+ const bin = join(home, ".local", "bin");
61
+ const steps = [];
62
+ const sh = (cmd, args, opts = {}) => {
63
+ steps.push([cmd, ...args].join(" "));
64
+ if (dryRun) return { status: 0, stdout: "" };
65
+ const r = run(cmd, args, { cwd: root, ...opts, env: { ...env, ...opts.env, PATH: `${bin}:${join(home, ".cargo", "bin")}:${opts.env?.PATH ?? env.PATH ?? ""}` } });
66
+ if (r.status !== 0) throw new Error(`${cmd} ${args.join(" ")} failed (${r.status})`);
67
+ return r;
68
+ };
69
+ const probe = (cmd, args) => { try { return (run(cmd, args, { cwd: root, env: { ...env, PATH: `${bin}:${join(home, ".cargo", "bin")}:${env.PATH ?? ""}` } }).stdout ?? "").trim(); } catch { return ""; } };
70
+
71
+ if (pins.node) {
72
+ const exact = EXACT.test(pins.node.replace(/^v/, ""));
73
+ const version = dryRun && !exact ? null : resolveNodeVersion(pins.node, exact ? [] : JSON.parse(fetchText("https://nodejs.org/dist/index.json")));
74
+ if (dryRun && !version) steps.push(`(install Node ${pins.node})`);
75
+ if (version && probe("node", ["--version"]) !== `v${version}`) {
76
+ const name = `node-v${version}-linux-${nodeArch(arch)}`;
77
+ const dest = join(home, ".local");
78
+ log(`Node ${version}`);
79
+ sh("sh", ["-c", `set -e; mkdir -p "${dest}/lib" "${bin}"; cd "$(mktemp -d)"; curl -fsSLO https://nodejs.org/dist/v${version}/${name}.tar.xz; curl -fsSL https://nodejs.org/dist/v${version}/SHASUMS256.txt | grep " ${name}.tar.xz$" | sha256sum -c -; rm -rf "${dest}/lib/${name}"; tar -xJf ${name}.tar.xz -C "${dest}/lib"; for b in node npm npx corepack; do [ -e "${dest}/lib/${name}/bin/$b" ] && ln -sfn "${dest}/lib/${name}/bin/$b" "${bin}/$b"; done`]);
80
+ }
81
+ }
82
+ if (pins.pnpm && probe("pnpm", ["--version"]) !== pins.pnpm) {
83
+ log(`pnpm ${pins.pnpm}`);
84
+ if (dryRun || probe("corepack", ["--version"])) {
85
+ sh("corepack", ["enable", "--install-directory", bin]);
86
+ sh("corepack", ["prepare", `pnpm@${pins.pnpm}`, "--activate"]);
87
+ } else sh("npm", ["install", "-g", `pnpm@${pins.pnpm}`, "--prefix", join(home, ".local")]);
88
+ }
89
+ if (pins.rust) {
90
+ if (!probe("rustup", ["--version"])) {
91
+ log("rustup");
92
+ sh("sh", ["-c", "curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain none --no-modify-path"]);
93
+ }
94
+ log(`Rust ${pins.rust}`);
95
+ sh("rustup", ["toolchain", "install", "--profile", "minimal"]); // reads rust-toolchain.toml from the app root
96
+ }
97
+ const lockroots = findLockroots(root);
98
+ for (const lr of lockroots) {
99
+ const dir = resolve(root, lr);
100
+ log(`pnpm store: ${lr}`);
101
+ // The throwaway virtual store keeps `pnpm fetch` from touching the root's node_modules.
102
+ const vstore = dryRun ? join(tmpdir(), "rk-vstore") : mkdtempSync(join(tmpdir(), "rk-vstore-"));
103
+ try { sh("pnpm", ["fetch", "--virtual-store-dir", vstore], { cwd: dir }); } finally { if (!dryRun) rmSync(vstore, { recursive: true, force: true }); }
104
+ }
105
+ return { pins, lockroots, steps };
106
+ }
package/local-publish.mjs CHANGED
@@ -35,9 +35,11 @@ import { requireDoorRun, gateStateDir } from "./gate-stages.mjs";
35
35
  export const LOCAL_OPERATOR = "agent (local-build grant)";
36
36
  export const LOCAL_HOST_POLICY_REASON = "Adrian 2026-10-14 local-build grant (.rightkit-local-build.json); Windows covered by the next CI run";
37
37
  export const LOCAL_WIN32_HOST_POLICY_REASON = "Adrian 2026-10-14 local-build grant (.rightkit-local-build.json); published from the Windows host, the macOS run is not part of this receipt";
38
- // The hosts `--local` accepts and the host policy each records in the evidence.
39
- export const LOCAL_HOST_POLICIES = Object.freeze({ darwin: "darwin-only", win32: "win32-only" });
40
- const localHostPolicyReason = (platform) => (platform === "win32" ? LOCAL_WIN32_HOST_POLICY_REASON : LOCAL_HOST_POLICY_REASON);
38
+ export const LOCAL_LINUX_HOST_POLICY_REASON = "GATE-CONTRACT Linux direct origin (GB-29, rightkit#1341): compiled and qualified locally with no broker; the macOS and Windows qualification is the publish run's own native verify jobs";
39
+ // The hosts `--local` accepts and the host policy each records in the evidence. A Linux direct origin records "linux-direct": it is
40
+ // not a single-host policy (isDarwinOnlyPolicy / isWin32OnlyPolicy stay false), so both native hosts are still required on CI.
41
+ export const LOCAL_HOST_POLICIES = Object.freeze({ darwin: "darwin-only", win32: "win32-only", linux: "linux-direct" });
42
+ const localHostPolicyReason = (platform) => (platform === "win32" ? LOCAL_WIN32_HOST_POLICY_REASON : platform === "linux" ? LOCAL_LINUX_HOST_POLICY_REASON : LOCAL_HOST_POLICY_REASON);
41
43
  const STEP_TIMEOUT_MS = 30 * 60 * 1000;
42
44
 
43
45
  export class LocalPublishError extends Error {
@@ -407,12 +409,15 @@ function testEnv(deps, isAssetGated) {
407
409
  return { ...inherited, RIGHTKIT_REQUIRE_ASSETS: isAssetGated ? "0" : "1", RIGHTKIT_ISOLATED_CHECKOUT: "1", CARGO_TERM_COLOR: "never" };
408
410
  }
409
411
 
410
- function assertLocalHost(deps, workspaceRoot, kind) {
412
+ function assertLocalHost(deps, workspaceRoot, kind, { qualifyOnly = false, unit = null } = {}) {
411
413
  const platform = deps.platform ?? process.platform;
412
414
  if (!(deps.localBuildGranted ?? localBuildGranted)(workspaceRoot)) refuse(`--local needs the local-build grant: a committed ${LOCAL_BUILD_FILE} (allow "build-test-publish") naming this repository at ${workspaceRoot}`);
413
- if (!Object.hasOwn(LOCAL_HOST_POLICIES, platform)) refuse(`--local publishes from the Mac only (host policy: darwin-only) or from Windows (host policy: win32-only); this host is ${platform}`);
414
- // The npm path reads the registry token from the macOS Keychain and ships a Developer-ID-signed helper: Mac only.
415
- if (kind === "npm" && platform !== "darwin") refuse(`publish npm --local needs the macOS Keychain npm token and runs on the Mac only; Windows publishes crates (publish cargo --local); this host is ${platform}`);
415
+ if (!Object.hasOwn(LOCAL_HOST_POLICIES, platform)) refuse(`--local runs on the Mac (host policy: darwin-only), Windows (host policy: win32-only) or a Linux direct origin (linux-direct); this host is ${platform}`);
416
+ // The npm upload reads the registry token from the macOS Keychain: Mac only.
417
+ // A Linux direct origin only qualifies (no token; the upload is publish.yml, GB-29), so it may qualify every npm package, @rightkit/release
418
+ // included: its Developer-ID-signed helper is built and signed hosted (publish.yml build-swap/sign-swap), never on the origin.
419
+ const linuxNpmQualify = platform === "linux" && qualifyOnly;
420
+ if (kind === "npm" && platform !== "darwin" && !linuxNpmQualify) refuse(`publish npm --local needs the macOS Keychain npm token and runs on the Mac only; Windows publishes crates (publish cargo --local); this host is ${platform}`);
416
421
  return platform;
417
422
  }
418
423
 
@@ -582,7 +587,8 @@ async function* publishUnitStages(parsed, deps, batch) {
582
587
 
583
588
 
584
589
  // 1. Preconditions, fail closed.
585
- const platform = assertLocalHost(deps, workspaceRoot, kind);
590
+ const platform = assertLocalHost(deps, workspaceRoot, kind, { qualifyOnly: parsed.qualifyOnly, unit: parsed.unit });
591
+ if (platform === "linux" && !parsed.qualifyOnly) refuse("a Linux direct origin qualifies only (--qualify-only); the upload is publish.yml with admission=direct (GB-29, rightkit#1341)");
586
592
  if (parsed.qualifyOnly && fs.existsSync(parsed.bundleDir)) refuse(`qualification bundle already exists: ${parsed.bundleDir}`);
587
593
  const dir = kind === "npm" ? `packages/${unit.slice("@rightkit/".length)}` : `crates/${unit}`;
588
594
  const unitPath = path.join(workspaceRoot, dir);
@@ -691,7 +697,8 @@ async function* publishUnitStages(parsed, deps, batch) {
691
697
  const cleanup = () => fs.rmSync(scratch, { recursive: true, force: true });
692
698
  try {
693
699
  let artifactFile; const packCommands = [];
694
- if (kind === "npm" && unit === SWAP_HELPER_PACKAGE) {
700
+ if (kind === "npm" && unit === SWAP_HELPER_PACKAGE && platform !== "linux") {
701
+ // A Linux direct origin skips this: it packs without the helper and the hosted sign-swap job binds the sealed one.
695
702
  // #258: the package ships a Developer-ID-signed bin/darwin/rightkit-swap; build, sign and verify it before packing (fails closed). The
696
703
  // helper and its signing receipt are written into the unit's own directory of the snapshot (packages/release/bin/darwin, both ignored by
697
704
  // packages/release/.gitignore): declared stage outputs of this stage, recorded by the gate's stage-output ledger (stage-ledger.mjs) like
@@ -782,7 +789,10 @@ async function* publishUnitStages(parsed, deps, batch) {
782
789
  fs.mkdirSync(path.dirname(evidencePath), { recursive: true });
783
790
  fs.writeFileSync(evidencePath, `${JSON.stringify(evidence, null, 2)}\n`, { flag: parsed.qualifyOnly ? "wx" : "w" });
784
791
  log(`[right-release] wrote ${evidencePath} (artifact sha256 ${artifactSha256})`);
785
- const gateOptions = { kind, name: unit, version, packagePath: unitPath, evidencePath, artifactBytes: fs.readFileSync(artifactFile) };
792
+ // The gate reads source identity through the same git the stages use (a unit snapshot has no .git; sourceGit answers for its commit).
793
+ // A Linux direct origin only ever qualifies (GB-29): its bundle proves the Linux run, and the upload's own native jobs prove Mac and Windows.
794
+ const gateOptions = { kind, name: unit, version, packagePath: unitPath, evidencePath, artifactBytes: fs.readFileSync(artifactFile),
795
+ ...(deps.git ? { git: deps.git } : {}), ...(platform === "linux" && parsed.qualifyOnly ? { directLinux: true } : {}) };
786
796
  (deps.enforceGate ?? enforcePublicationGate)(gateOptions, { write: (line) => log(line.replace(/\n$/, "")) });
787
797
 
788
798
  if (parsed.qualifyOnly) {
@@ -824,6 +834,7 @@ async function runLocalPublishBatch(parsed, deps) {
824
834
  const readJson = deps.readJson ?? defaultReadJson;
825
835
  const run = deps.run ?? defaultRunAsync;
826
836
  const platform = assertLocalHost(deps, workspaceRoot, "cargo");
837
+ if (platform === "linux") refuse("a Linux direct origin qualifies one unit at a time; batch publish runs on the Mac or Windows (GB-29, rightkit#1341)");
827
838
 
828
839
  // 1. The crate list and its dependency layers.
829
840
  const requested = parsed.batch.file ? parseCrateLayers((deps.readText ?? ((file) => fs.readFileSync(file, "utf8")))(parsed.batch.file)) : [parsed.batch.crates];
package/local-qualify.mjs CHANGED
@@ -31,7 +31,8 @@ import zlib from "node:zlib";
31
31
  import { fileURLToPath, pathToFileURL } from "node:url";
32
32
  import { spawnSync } from "./background-process.mjs";
33
33
  import { managedRuntimeClient, spawnCollect } from "./managed-cargo.mjs";
34
- import { planScans, runScans } from "./deterministic-scans.mjs";
34
+ import { defaultSpawnAsync, planScans, runScans } from "./deterministic-scans.mjs";
35
+ import { createBrokerLive } from "./broker-admission.mjs";
35
36
  import { pathDependencyDirs, readCargoPackage } from "./crate-graph.mjs";
36
37
  import { deriveCrateLayers } from "./local-publish.mjs";
37
38
  import { RUNTIME_BUDGET_CRATES } from "./runtime-budget-crates.mjs";
@@ -409,10 +410,15 @@ async function localScanRecord({ root, commit, crates, jobs, scratch, deps, log
409
410
  // Repository-level scans (workflows, YAML) plus every scan of the units, each in its own crate workspace (lock, deny, audit); other crates and npm packages stay out of scope.
410
411
  const files = tracked.filter((file) => (!file.startsWith("crates/") && !file.startsWith("packages/")) || unitDirs.some((dir) => file.startsWith(dir)));
411
412
  const crateFeatures = (deps.readJson ?? defaultReadJson)(path.join(repo, "ci", "gate.config.json"))?.rust ?? {};
412
- const plan = (deps.planScans ?? planScans)({ changed: files, contentRoot: repo, liveRoot: root, includeTests: false, localCompile: true, cargoCommand: detectCargoCommand(deps), crateFeatures, readBytes: (file) => fs.readFileSync(file) });
413
+ const hostCommand = detectCargoCommand(deps);
414
+ const plan = (deps.planScans ?? planScans)({ changed: files, contentRoot: repo, liveRoot: root, includeTests: false, localCompile: true, cargoCommand: hostCommand, crateFeatures, readBytes: (file) => fs.readFileSync(file) });
413
415
  const observedTools = modules.observeToolVersions();
414
416
  log(`[local-qualify] scans: ${plan.scans.length} planned over ${files.length} files (class A, then class B through the broker)`);
415
- const result = await (deps.runScans ?? runScans)(plan, { classes: ["A", "B"], jobs: Math.max(1, Number(jobs ?? 4) || 4), onResult: (row, progress) => log(`[local-qualify] scan [${progress.done}/${progress.total}] ${row.status.padEnd(7)} ${row.id}`) });
417
+ // detectCargoCommand above accepts only `rustright` or the managed shim on PATH, so this is always a broker host: the broker orders the carried class B scans (rightkit#1367 step 2).
418
+ // The client the live reader asks through: the managed runtime client, else the `rustright` the scans themselves run through.
419
+ const client = managedRuntimeClient(deps.env ?? process.env, deps.platform ?? process.platform) ?? (hostCommand[0] === "rustright" ? "rustright" : null);
420
+ const brokerLive = client ? createBrokerLive({ client, env: deps.env ?? process.env, spawn: defaultSpawnAsync, platform: deps.platform ?? process.platform }) : null;
421
+ const result = await (deps.runScans ?? runScans)(plan, { classes: ["A", "B"], jobs: Math.max(1, Number(jobs ?? 4) || 4), brokerHost: true, ...(brokerLive ? { brokerLive } : {}), onResult: (row, progress) => log(`[local-qualify] scan [${progress.done}/${progress.total}] ${row.status.padEnd(7)} ${row.id}`) });
416
422
  // Every unit is bound to its own crate workspace lock (#1285): crates/<x>/Cargo.lock, no root lock.
417
423
  const cargoLocks = Object.fromEntries([...crates].sort().map((name) => [name, { path: `crates/${name}/Cargo.lock`, sha256: sha256(fs.readFileSync(path.join(root, "crates", name, "Cargo.lock"))) }]));
418
424
  return modules.buildLocalScanRecord({ result, planned: plan.scans, sourceCommit: commit, headCommit: commit, cargoLocks, observedTools, units: crates, files: files.length });