@rightkit/release 0.6.13 → 0.6.15

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 (111) hide show
  1. package/README.md +49 -1
  2. package/adapter-env.mjs +165 -0
  3. package/android-ndk.mjs +5 -1
  4. package/android-release.mjs +73 -8
  5. package/app-check.mjs +22 -8
  6. package/app-closure.mjs +214 -0
  7. package/assemble-app-command.mjs +24 -7
  8. package/atomic-write.mjs +57 -0
  9. package/bin/rightkit-gate.mjs +1193 -337
  10. package/broker-client.mjs +106 -0
  11. package/broker-exception.mjs +211 -0
  12. package/broker-handshake.mjs +160 -0
  13. package/broker-pins.mjs +27 -8
  14. package/broker-wire.mjs +262 -0
  15. package/build-launch.mjs +364 -12
  16. package/build-ownership.mjs +117 -49
  17. package/build-release.mjs +3 -2
  18. package/cache-command.mjs +7 -2
  19. package/candidate-build-receipt.mjs +96 -0
  20. package/candidate-receipt-command.mjs +29 -0
  21. package/cargo-contract.mjs +4 -3
  22. package/cargo-ledger.mjs +1 -1
  23. package/cargo-license-notices.mjs +2 -2
  24. package/cargo-shapes.mjs +332 -0
  25. package/cargo-target-shim.mjs +44 -5
  26. package/cargo-target.mjs +5 -0
  27. package/carried-adapter-run.mjs +31 -0
  28. package/carried-cargo-run.mjs +9 -0
  29. package/carried-cargo.mjs +237 -0
  30. package/carrier-census.mjs +124 -0
  31. package/class-a-launch-guard.mjs +266 -33
  32. package/cli/right-release.mjs +2 -0
  33. package/clippy-route.mjs +23 -11
  34. package/compile-inputs.mjs +9 -12
  35. package/control-plane.mjs +18 -0
  36. package/deps-cargo-lock.mjs +208 -0
  37. package/deps.mjs +5 -0
  38. package/deterministic-scans.mjs +273 -44
  39. package/development-signing.mjs +14 -1
  40. package/development.mjs +112 -26
  41. package/direct-origin.mjs +107 -0
  42. package/disk-command.mjs +1 -0
  43. package/disk-policy.mjs +18 -3
  44. package/freshness-census.mjs +206 -58
  45. package/gate-executor.mjs +30 -12
  46. package/gate-stages.mjs +93 -16
  47. package/github-release.mjs +2 -1
  48. package/github-upload-gate.mjs +2 -1
  49. package/install-tauri-authenticode.mjs +346 -0
  50. package/ios-dev.mjs +77 -23
  51. package/ios-package-recipe.mjs +100 -41
  52. package/linux-artifacts.mjs +17 -2
  53. package/linux-qualifier.mjs +83 -0
  54. package/local-publish.mjs +36 -14
  55. package/local-runtime.mjs +2 -2
  56. package/lock-identity.mjs +138 -0
  57. package/lsclean.sh +0 -0
  58. package/managed-cargo.mjs +161 -44
  59. package/managed-roots.mjs +130 -12
  60. package/media-sidecar-catalog.mjs +1 -1
  61. package/native-adapters.mjs +411 -0
  62. package/native-helpers.mjs +26 -18
  63. package/native-tool-planning.mjs +94 -0
  64. package/ownership-mark.mjs +182 -0
  65. package/pack-digest.mjs +122 -0
  66. package/package.json +12 -12
  67. package/pipeline-receipts.mjs +302 -302
  68. package/platform-dir.mjs +14 -0
  69. package/pnpm-adapter-grammar.mjs +226 -0
  70. package/progress-control.mjs +7 -2
  71. package/provenance-real-receipt.pins.json +7 -0
  72. package/provenance-verify.mjs +262 -1
  73. package/publication-outcomes.mjs +21 -0
  74. package/publish-update.mjs +2 -1
  75. package/qa-journeys-prebuilt-run.mjs +18 -0
  76. package/qa-journeys-prebuilt.mjs +148 -0
  77. package/qa-snapshot-run.mjs +53 -0
  78. package/qa-snapshot.mjs +164 -0
  79. package/qualification-verify.mjs +144 -0
  80. package/qualify-reuse.mjs +38 -0
  81. package/recipe-build.mjs +16 -3
  82. package/release-qa-audit.mjs +2 -2
  83. package/release-qa-command.mjs +2 -1
  84. package/release-qa-receipt.mjs +3 -2
  85. package/release-state.mjs +3 -2
  86. package/release.mjs +14 -3
  87. package/rightkit-drift.mjs +27 -5
  88. package/rightkit-versions.json +15 -11
  89. package/rustright-names.mjs +32 -0
  90. package/scan-tool-enrolment.mjs +167 -0
  91. package/sign-windows.mjs +212 -112
  92. package/signing-files.mjs +35 -0
  93. package/signing-identity.mjs +7 -0
  94. package/snapshot-broker.mjs +528 -57
  95. package/snapshot-check.mjs +95 -11
  96. package/snapshot-metadata.mjs +123 -0
  97. package/snapshot-request.mjs +389 -96
  98. package/stage-binary.mjs +22 -4
  99. package/stage-commands.mjs +56 -13
  100. package/stage-sidecar.mjs +93 -21
  101. package/suite-signing-policy.mjs +657 -0
  102. package/suite-signing.mjs +223 -0
  103. package/swift-scratch.mjs +25 -0
  104. package/tauri-locked.mjs +25 -0
  105. package/test-command.mjs +164 -29
  106. package/unit-gate.mjs +201 -31
  107. package/upload-large.mjs +0 -0
  108. package/web-build.mjs +2 -2
  109. package/win-package-recipe.mjs +26 -2
  110. package/bin/darwin/rightkit-swap +0 -0
  111. package/bin/darwin/rightkit-swap.sign.json +0 -6
@@ -12,6 +12,15 @@
12
12
  // Counts come from the target directory the way the broker takes them (rustright-policy cargo_freshness.rs): a unit is one directory
13
13
  // of a `.fingerprint`; a unit whose newest file is at or after the census start was rebuilt (`notFresh`, a new unit included), the
14
14
  // others were reused (`fresh`). Judging unexplained rebuilds belongs to item 11; this module only measures and records.
15
+ //
16
+ // GB-68 (BROKER-CONTRACT section 16; the shared vector test-support/vectors/freshness-evidence-vectors.v1.json, held by freshness-vectors.test.mjs):
17
+ // - the log reader reads only the ROOT's lines: those before Cargo's first execution status word (`Compiling`, `Checking`, ... `Finished`); nested
18
+ // Cargo chatter after it is neither counted nor stripped; a `failed to read ...fingerprint...` reason followed by a `Caused by:` not-found cause
19
+ // (Unix `os error 2`, Windows `os error 3`) is `FingerprintMissing`, any other cause stays `unexplained`; every reason carries its `cause`;
20
+ // - the census reads the SELECTED scope only: `<target>/<profile>/.fingerprint` and, for a `--target` triple, `<target>/<triple>/<profile>/.fingerprint`
21
+ // (never every profile or triple, never `doc` or `package`), taken from the compiling process's own arguments and environment;
22
+ // - a fingerprint directory or unit that exists and cannot be read is `fingerprint-census-read-failed`: unavailable, never an empty census and never a
23
+ // first build. Only an absent directory is empty.
15
24
  import fs from "node:fs";
16
25
  import path from "node:path";
17
26
 
@@ -28,6 +37,86 @@ export const CENSUS_ENTRY_CAP = 250_000;
28
37
  // A held partial line longer than this is not a log line.
29
38
  const PARTIAL_CAP = 64 * 1024;
30
39
 
40
+ /** Cargo's execution-phase status words: the first one ends the window in which fingerprint lines are the root's (pre-resolution words do not). */
41
+ export const EXECUTION_STATUS = Object.freeze(["Compiling", "Checking", "Documenting", "Fresh", "Dirty", "Running", "Finished", "Scraping"]);
42
+ const EXECUTION_WORDS = new Set(EXECUTION_STATUS);
43
+
44
+ const SOURCE_OR_DEPENDENCY = new Set(["FsStatusOutdated", "PathToSourceChanged", "NumberOfDependenciesChanged", "UnitDependencyNameChanged", "UnitDependencyInfoChanged", "LocalLengthsChanged", "PrecalculatedComponentsChanged", "DepInfoOutputChanged", "RerunIfChangedOutputFileChanged", "RerunIfChangedOutputPathsChanged"]);
45
+ const ENV_RUSTFLAGS_PROFILE_FEATURES_TARGET = new Set(["EnvVarChanged", "EnvVarsChanged", "RustflagsChanged", "ProfileConfigurationChanged", "FeaturesChanged", "DeclaredFeaturesChanged", "TargetConfigurationChanged", "CompileKindChanged", "LocalFingerprintTypeChanged", "FingerprintMethodChanged", "ConfigSettingsChanged"]);
46
+
47
+ /**
48
+ * The cause a reason's `kind` explains (RustRight cargo_evidence.rs `cause_of`; the vector's `causes`): the first build, a missing fingerprint (named apart, and still unexplained
49
+ * by itself), a toolchain change, a source or dependency change, an environment, rustflags, profile, features or target change. Any other kind, one Cargo adds later included, is `unexplained`.
50
+ */
51
+ export function causeOf(kind) {
52
+ if (kind === "FreshBuild") return "first-build";
53
+ if (kind === "FingerprintMissing") return "fingerprint-missing";
54
+ if (kind === "RustcChanged") return "toolchain-changed";
55
+ if (SOURCE_OR_DEPENDENCY.has(kind)) return "source-or-dependency-changed";
56
+ if (ENV_RUSTFLAGS_PROFILE_FEATURES_TARGET.has(kind)) return "env-rustflags-profile-features-or-target-changed";
57
+ return "unexplained";
58
+ }
59
+
60
+ /** Whether a `Caused by:` line says the fingerprint file was absent: `No such file or directory (os error 2)` on Unix, `... (os error 2)` or `... path specified. (os error 3)` on Windows. */
61
+ const missingFingerprint = (cause) => cause.endsWith("(os error 2)") || cause.endsWith("(os error 3)");
62
+
63
+ /** The profile directory a command line selects (RustRight `cargo_profile_directory`): `--release`/`-r`, `--profile X` (`dev` and `test` are `debug`, `bench` is `release`, a custom name is itself); nothing after `--`. */
64
+ export function cargoProfileDirectory(argv) {
65
+ let profile = null; let release = false;
66
+ const words = Array.isArray(argv) ? argv.map(String) : [];
67
+ for (let i = 0; i < words.length && words[i] !== "--"; i += 1) {
68
+ const item = words[i];
69
+ if (item === "--release" || item === "-r") release = true;
70
+ else if (item === "--profile") profile = words[i + 1] ?? null;
71
+ else if (item.startsWith("--profile=")) profile = item.slice("--profile=".length);
72
+ }
73
+ if (profile === "dev" || profile === "test") return "debug";
74
+ if (profile === "bench") return "release";
75
+ if (profile !== null) return profile;
76
+ return release ? "release" : "debug";
77
+ }
78
+
79
+ /** Whether a command line asks for JSON messages (`--message-format json*` in any spelling, before `--`). */
80
+ export function wantsCargoJson(argv) {
81
+ const words = Array.isArray(argv) ? argv.map(String) : [];
82
+ for (let i = 0; i < words.length; i += 1) {
83
+ const item = words[i];
84
+ if (item === "--") break;
85
+ const value = item === "--message-format" ? words[i + 1] : item.startsWith("--message-format=") ? item.slice("--message-format=".length) : undefined;
86
+ if (value !== undefined && value.split(",").some((segment) => segment.startsWith("json"))) return true;
87
+ }
88
+ return false;
89
+ }
90
+
91
+ /** The triples a command line names with `--target` (before `--`), then `CARGO_BUILD_TARGET` when the line names none. */
92
+ export function cargoTargetTriples(argv, env = {}) {
93
+ const words = Array.isArray(argv) ? argv.map(String) : [];
94
+ const found = [];
95
+ for (let i = 0; i < words.length && words[i] !== "--"; i += 1) {
96
+ const item = words[i];
97
+ const value = item === "--target" ? words[i + 1] : item.startsWith("--target=") ? item.slice("--target=".length) : undefined;
98
+ if (typeof value === "string" && value !== "" && !found.includes(value)) found.push(value);
99
+ }
100
+ if (found.length === 0) {
101
+ const named = Object.entries(env ?? {}).find(([key]) => key.toUpperCase() === "CARGO_BUILD_TARGET");
102
+ if (named && typeof named[1] === "string" && named[1].trim() !== "") for (const t of named[1].trim().split(/\s+/)) if (!found.includes(t)) found.push(t);
103
+ }
104
+ return found;
105
+ }
106
+
107
+ /**
108
+ * The `.fingerprint` directories one compile selects in a target directory: `<target>/<triple>/<profile>/.fingerprint` for each selected triple, then `<target>/<profile>/.fingerprint`
109
+ * (RustRight `fingerprint_census_start`). Never every profile or triple, never `doc` or `package`.
110
+ */
111
+ export function selectedFingerprintDirectories(targetDir, { argv = [], triples = [] } = {}) {
112
+ const profile = cargoProfileDirectory(argv);
113
+ const found = [];
114
+ for (const triple of triples) { const dir = path.join(targetDir, triple, profile, ".fingerprint"); if (!found.includes(dir)) found.push(dir); }
115
+ const plain = path.join(targetDir, profile, ".fingerprint");
116
+ if (!found.includes(plain)) found.push(plain);
117
+ return found;
118
+ }
119
+
31
120
  const stripAnsi = (text) => text.replace(/\u001b\[[^A-Za-z]*[A-Za-z]/g, "");
32
121
  const afterPrefix = (message, prefixes) => {
33
122
  for (const prefix of prefixes) if (message.startsWith(prefix)) return message.slice(prefix.length);
@@ -63,6 +152,10 @@ export class FingerprintLog {
63
152
  this.dirtyUnits = 0;
64
153
  this.reasons = [];
65
154
  this.truncated = 0;
155
+ // Cargo printed its first execution-phase status line: later fingerprint lines are not the root's (a nested Cargo's).
156
+ this.executing = false;
157
+ // The reason just recorded was Cargo's `failed to read` of a `.fingerprint` file: its `Caused by:` chain follows on the next lines and says whether the file was simply absent.
158
+ this.awaitingCause = null;
66
159
  }
67
160
 
68
161
  /** One chunk of standard error in; what the caller sees out. Without stripping that is the chunk itself. */
@@ -91,8 +184,25 @@ export class FingerprintLog {
91
184
  return rest;
92
185
  }
93
186
 
187
+ /** Whether `raw` is one of the ROOT's fingerprint lines (counted, and stripped when stripping). */
94
188
  #line(raw) {
95
189
  const line = stripAnsi(raw.replace(/\s+$/, ""));
190
+ // GB-68: after the first execution status word nothing is the root's: nested Cargo chatter is neither counted nor stripped.
191
+ if (this.executing) { this.awaitingCause = null; return false; }
192
+ const first = line.trim().split(/\s+/)[0];
193
+ if (EXECUTION_WORDS.has(first)) { this.executing = true; return false; }
194
+ // The `Caused by:` chain of a `failed to read` reason: the heading, then the cause, indented. Blank lines inside the chain belong to it.
195
+ if (this.awaitingCause !== null) {
196
+ const text = line.trim();
197
+ if (text === "") return true;
198
+ if (!this.awaitingCause.headingSeen && text === "Caused by:") { this.awaitingCause.headingSeen = true; return true; }
199
+ const { index, headingSeen } = this.awaitingCause;
200
+ this.awaitingCause = null;
201
+ if (headingSeen && /^\s/.test(line)) {
202
+ if (missingFingerprint(text) && this.reasons[index]) { this.reasons[index].kind = "FingerprintMissing"; this.reasons[index].cause = causeOf("FingerprintMissing"); }
203
+ return true;
204
+ }
205
+ }
96
206
  const at = line.indexOf(FINGERPRINT_TARGET);
97
207
  if (at < 0) return false;
98
208
  const message = line.slice(at + FINGERPRINT_TARGET.length).replace(/^:+/, "").trim();
@@ -111,8 +221,11 @@ export class FingerprintLog {
111
221
  if (this.reasons.length >= REASONS_CAP) { this.truncated += 1; return; }
112
222
  const { unit, target } = this.current ?? { unit: "unknown", target: null };
113
223
  const chars = Array.from(reason);
114
- const text = chars.length > REASON_TEXT_CAP ? `${chars.slice(0, REASON_TEXT_CAP).join("")}…` : reason;
115
- this.reasons.push({ unit, target, kind: reasonKind(reason), reason: text });
224
+ const text = chars.length > REASON_TEXT_CAP ? `${chars.slice(0, REASON_TEXT_CAP).join("")}\u2026` : reason;
225
+ const kind = reasonKind(reason);
226
+ this.reasons.push({ unit, target, kind, cause: causeOf(kind), reason: text });
227
+ const readsFingerprint = reason.startsWith("failed to read `") && (reason.includes("/.fingerprint/") || reason.includes("\\.fingerprint\\"));
228
+ this.awaitingCause = readsFingerprint ? { index: this.reasons.length - 1, headingSeen: false } : null;
116
229
  }
117
230
 
118
231
  /** What another log's reading adds to this one (the census keeps one aggregate over every compiling process). */
@@ -146,76 +259,108 @@ export function compilesWorkspace(command, args) {
146
259
  return false;
147
260
  }
148
261
 
149
- // Directories never searched for `.fingerprint`s: rustdoc output and packaging verification are not a profile's units.
150
- const NOT_PROFILES = new Set(["doc", "package", "tmp", "incremental"]);
262
+ /** An error that says the path is not there (an empty profile), as opposed to being there and unreadable. */
263
+ const isAbsent = (error) => error?.code === "ENOENT";
151
264
 
152
- /** The `.fingerprint` directories of one target directory: `<target>/<profile>/.fingerprint` and `<target>/<triple>/<profile>/.fingerprint`. */
153
- export function fingerprintDirectories(targetDir, fsApi = fs) {
154
- const found = [];
155
- const subdirs = (dir) => {
156
- try { return fsApi.readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory() && !NOT_PROFILES.has(e.name)).map((e) => e.name); } catch { return []; }
157
- };
158
- const has = (dir) => { try { return fsApi.statSync(dir).isDirectory(); } catch { return false; } };
159
- for (const first of subdirs(targetDir)) {
160
- const direct = path.join(targetDir, first, ".fingerprint");
161
- if (has(direct)) { found.push(direct); continue; }
162
- for (const second of subdirs(path.join(targetDir, first))) {
163
- const nested = path.join(targetDir, first, second, ".fingerprint");
164
- if (has(nested)) found.push(nested);
165
- }
166
- }
167
- return found;
265
+ /** The fingerprint directory's entries: `{ names }`, `{ names: [] }` when it is absent, or `{ error }` when it exists and cannot be read (a file, permission, I/O: never read as empty). */
266
+ function readFingerprintDirectory(dir, fsApi) {
267
+ try { return { names: fsApi.readdirSync(dir) }; } catch (error) { return isAbsent(error) ? { names: [] } : { error: `${dir}: ${error?.code ?? error?.message ?? "unreadable"}` }; }
168
268
  }
169
269
 
170
270
  /**
171
- * Walks every fingerprint directory of `targetDirs`. `withTimes: false` only counts units; `withTimes: true` also takes each unit's
172
- * newest file time. `capped` is set when more than `cap` entries (units and files) were visited.
271
+ * The `build.target` triples `.cargo/config.toml` / `.cargo/config` name for a compile run from `cwd` (the process's own directory upward, then `$CARGO_HOME`): the compile's output then
272
+ * sits under `<target>/<triple>/<profile>`, so the selected scope must name it as `--target` would. A simple reader of the `[build]` table's `target` string or string list.
173
273
  */
174
- export function walkFingerprints(targetDirs, { withTimes, cap = CENSUS_ENTRY_CAP, fsApi = fs } = {}) {
175
- const units = new Map();
176
- let visited = 0;
177
- for (const targetDir of targetDirs) {
178
- for (const fpDir of fingerprintDirectories(targetDir, fsApi)) {
179
- let names;
180
- try { names = fsApi.readdirSync(fpDir); } catch { continue; }
181
- for (const name of names) {
182
- visited += 1;
183
- if (visited > cap) return { units, capped: true };
184
- const unitDir = path.join(fpDir, name);
185
- if (!withTimes) { units.set(unitDir, 0); continue; }
186
- let newest = 0;
187
- try {
188
- const own = fsApi.statSync(unitDir);
189
- newest = own.mtimeMs;
190
- if (own.isDirectory()) {
191
- for (const file of fsApi.readdirSync(unitDir)) {
192
- visited += 1;
193
- if (visited > cap) return { units, capped: true };
194
- try { newest = Math.max(newest, fsApi.statSync(path.join(unitDir, file)).mtimeMs); } catch { /* vanished: ignore */ }
195
- }
196
- }
197
- } catch { /* vanished: counted with the time it had */ }
198
- units.set(unitDir, newest);
199
- }
274
+ export function configuredBuildTargets(cwd, { env = {}, fsApi = fs } = {}) {
275
+ const files = [];
276
+ if (typeof cwd === "string" && cwd) for (let dir = path.resolve(cwd); ; dir = path.dirname(dir)) { files.push(path.join(dir, ".cargo", "config.toml"), path.join(dir, ".cargo", "config")); if (path.dirname(dir) === dir) break; }
277
+ const home = Object.entries(env ?? {}).find(([key]) => key.toUpperCase() === "CARGO_HOME")?.[1];
278
+ if (typeof home === "string" && home) files.push(path.join(home, "config.toml"), path.join(home, "config"));
279
+ for (const file of files) {
280
+ let text;
281
+ try { text = fsApi.readFileSync(file, "utf8"); } catch { continue; }
282
+ let inBuild = false;
283
+ for (const raw of String(text).split(/\r?\n/)) {
284
+ const line = raw.replace(/#.*$/, "").trim();
285
+ const table = /^\[\s*([^\]]+?)\s*\]$/.exec(line);
286
+ if (table) { inBuild = table[1] === "build"; continue; }
287
+ const dotted = /^build\.target\s*=\s*(.+)$/.exec(line);
288
+ const plain = inBuild ? /^target\s*=\s*(.+)$/.exec(line) : null;
289
+ const value = (dotted ?? plain)?.[1];
290
+ if (value !== undefined) return [...value.matchAll(/["']([^"']+)["']/g)].map((m) => m[1]);
200
291
  }
201
292
  }
202
- return { units, capped: false };
293
+ return [];
203
294
  }
204
295
 
205
296
  /**
206
- * One census for one stage of a direct compile: `start()` before the scans, `wrapSpawn(spawn)` around the scan runner's spawner
207
- * (it turns the fingerprint log on for the compiling cargo processes only), `finish()` after them.
297
+ * One census for one stage of a direct compile: `start()` before the scans, `wrapSpawn(spawn)` around the scan runner's spawner (it selects each compiling cargo process's scope and
298
+ * turns the fingerprint log on for it only), `finish()` after them. `start({ argv, triples, env })` selects a scope itself (a caller that knows the command).
299
+ *
300
+ * The scope is what the compiling processes SELECT (cargoProfileDirectory, cargoTargetTriples), not every profile directory of the target: each selected directory's baseline is read
301
+ * before the first process that selects it, and `since` is the census start. A directory that exists and cannot be read, at the baseline or in the walk, makes the census
302
+ * unavailable (`fingerprint-census-read-failed`); with no compiling process there is no scope and the census is unavailable (`fingerprint-census-no-compiling-process`).
208
303
  */
209
304
  export function createFreshnessCensus({ targetDirs, now = Date.now, fsApi = fs, cap = CENSUS_ENTRY_CAP } = {}) {
210
305
  const dirs = [...new Set(Object.values(targetDirs ?? {}).filter((d) => typeof d === "string" && d))];
211
306
  const log = new FingerprintLog({ strip: false });
212
- let since = null; let atStart = null; let processes = 0; let outputCut = false;
307
+ let since = null; let processes = 0; let outputCut = false;
308
+ const baselines = new Map(); // fingerprint directory -> { count } | { error }
309
+ let unitsAtStart = 0; let capped = false; let readError = null; let selected = false;
310
+
311
+ const select = ({ argv = [], triples = null, env = {}, cwd = null } = {}) => {
312
+ const named = triples ?? [...cargoTargetTriples(argv, env), ...(cargoTargetTriples(argv, env).length === 0 ? configuredBuildTargets(cwd, { env, fsApi }) : [])];
313
+ for (const targetDir of dirs) {
314
+ for (const dir of selectedFingerprintDirectories(targetDir, { argv, triples: [...new Set(named)] })) {
315
+ selected = true;
316
+ if (baselines.has(dir)) continue;
317
+ const read = readFingerprintDirectory(dir, fsApi);
318
+ if (read.error !== undefined) { baselines.set(dir, { error: read.error }); readError ??= read.error; continue; }
319
+ baselines.set(dir, { count: read.names.length });
320
+ unitsAtStart += read.names.length;
321
+ if (unitsAtStart > cap) capped = true;
322
+ }
323
+ }
324
+ };
325
+
326
+ /** Walks the selected directories: the units' newest file times, or why the walk cannot be trusted. */
327
+ const walk = () => {
328
+ const units = new Map();
329
+ let visited = 0;
330
+ for (const dir of baselines.keys()) {
331
+ const read = readFingerprintDirectory(dir, fsApi);
332
+ if (read.error !== undefined) return { reason: "fingerprint-census-read-failed" };
333
+ for (const name of read.names) {
334
+ visited += 1;
335
+ if (visited > cap) return { reason: "fingerprint-census-entry-cap-exceeded" };
336
+ const unitDir = path.join(dir, name);
337
+ let newest = 0;
338
+ let files = [];
339
+ try { files = fsApi.readdirSync(unitDir); } catch (error) {
340
+ // A unit that vanished meanwhile, or is a plain file, holds no files; any other failure means its freshness was never read.
341
+ if (!isAbsent(error) && error?.code !== "ENOTDIR") return { reason: "fingerprint-census-read-failed" };
342
+ }
343
+ for (const file of files) {
344
+ visited += 1;
345
+ if (visited > cap) return { reason: "fingerprint-census-entry-cap-exceeded" };
346
+ try { newest = Math.max(newest, fsApi.statSync(path.join(unitDir, file)).mtimeMs); } catch { /* vanished: it holds no time */ }
347
+ }
348
+ units.set(unitDir, newest);
349
+ }
350
+ }
351
+ return { units };
352
+ };
353
+
213
354
  return {
214
- start() { since = now(); atStart = walkFingerprints(dirs, { withTimes: false, cap, fsApi }); },
355
+ start(selection) { since = now(); if (selection) select(selection); },
356
+ select,
357
+ /** What the selection reads at the start: the directories, the units counted there, whether the entry cap was passed and the first read error. */
358
+ scope() { return { directories: [...baselines.keys()], unitsAtStart, capped, readError }; },
215
359
  wrapSpawn(spawn) {
216
360
  return async (command, args, options = {}) => {
217
361
  if (!compilesWorkspace(command, args)) return spawn(command, args, options);
218
362
  const env = options.env ?? process.env;
363
+ if (since !== null) select({ argv: Array.isArray(args) ? args : [], env, cwd: options.cwd ?? process.cwd() });
219
364
  // A caller's own CARGO_LOG stands and stays visible; the gate's own is kept out of the output.
220
365
  const ours = !Object.keys(env).some((k) => k.toUpperCase() === "CARGO_LOG");
221
366
  const result = await spawn(command, args, ours ? { ...options, env: { ...env, CARGO_LOG: FINGERPRINT_LOG_FILTER } } : options);
@@ -231,13 +376,16 @@ export function createFreshnessCensus({ targetDirs, now = Date.now, fsApi = fs,
231
376
  finish() {
232
377
  const base = { source: FRESHNESS_SOURCE, cargoProcesses: processes, dirtyUnits: log.dirtyUnits, truncated: log.truncated, ...(outputCut ? { outputCut: true } : {}) };
233
378
  const unavailable = (reason) => ({ unitsAtStart: 0, fresh: 0, notFresh: 0, invalidationReasons: log.reasons, available: false, reason, ...base });
234
- if (since === null || atStart === null) return unavailable("fingerprint-census-not-started");
235
- const end = walkFingerprints(dirs, { withTimes: true, cap, fsApi });
236
- if (atStart.capped || end.capped) return unavailable("fingerprint-census-entry-cap-exceeded");
379
+ if (since === null) return unavailable("fingerprint-census-not-started");
380
+ if (!selected) return unavailable("fingerprint-census-no-compiling-process");
381
+ if (capped) return unavailable("fingerprint-census-entry-cap-exceeded");
382
+ if (readError !== null) return unavailable("fingerprint-census-read-failed");
383
+ const end = walk();
384
+ if (end.reason) return unavailable(end.reason);
237
385
  if (end.units.size === 0) return unavailable("no-fingerprint-units-found");
238
386
  let notFresh = 0;
239
387
  for (const newest of end.units.values()) if (newest >= since) notFresh += 1;
240
- return { unitsAtStart: atStart.units.size, fresh: end.units.size - notFresh, notFresh, invalidationReasons: log.reasons, available: true, ...base };
388
+ return { unitsAtStart, fresh: end.units.size - notFresh, notFresh, invalidationReasons: log.reasons, available: true, ...base };
241
389
  },
242
390
  };
243
391
  }
@@ -250,7 +398,7 @@ export function validateFreshness(freshness) {
250
398
  if (!freshness || typeof freshness !== "object" || Array.isArray(freshness)) return ["freshness is absent"];
251
399
  for (const key of ["unitsAtStart", "fresh", "notFresh"]) if (!isCount(freshness[key])) problems.push(`freshness.${key} is not a count`);
252
400
  if (!Array.isArray(freshness.invalidationReasons)) problems.push("freshness.invalidationReasons is not a list");
253
- else freshness.invalidationReasons.forEach((r, i) => { if (!r || typeof r.unit !== "string" || typeof r.kind !== "string" || typeof r.reason !== "string") problems.push(`freshness.invalidationReasons[${i}] has no unit, kind and reason`); });
401
+ else freshness.invalidationReasons.forEach((r, i) => { if (!r || typeof r.unit !== "string" || typeof r.kind !== "string" || typeof r.reason !== "string") problems.push(`freshness.invalidationReasons[${i}] has no unit, kind and reason`); else if (r.cause !== undefined && typeof r.cause !== "string") problems.push(`freshness.invalidationReasons[${i}].cause is not a string`); });
254
402
  if (freshness.available !== undefined && typeof freshness.available !== "boolean") problems.push("freshness.available is not a boolean");
255
403
  if (freshness.available === false && typeof freshness.reason !== "string") problems.push("an unavailable census names no reason");
256
404
  return problems;
package/gate-executor.mjs CHANGED
@@ -2,27 +2,45 @@
2
2
  // broker - `rustright` is on PATH, or the first `cargo` is the broker's managed shim: every cargo call goes through the broker.
3
3
  // refuse - a broker-managed machine (Mac/Dell markers, broker socket, managed agent-bin) whose broker cannot be reached from here:
4
4
  // the gate never compiles around it.
5
- // direct - every Linux host (WSL2, VM, cloud, CI, Linux hosted on the Dell) always, and any unmanaged Mac or Windows host: cargo
6
- // runs directly, through the same gates and stages.
5
+ // direct - every Linux host (WSL2, VM, cloud, CI, Linux hosted on the Dell) that has not declared a broker route, and any unmanaged Mac or
6
+ // Windows host: cargo runs directly, through the same gates and stages.
7
+ // Linux optional broker (Adrian, 2026-10-31: Linux normally needs no broker, but a broker is allowed there): a Linux host that EXPLICITLY declares
8
+ // RIGHTKIT_LINUX_BROKER_CLIENT + RIGHTKIT_LINUX_BROKER_SOCKET (managed-cargo.mjs resolveLinuxBrokerRoute) gets kind "broker" with route
9
+ // "linux-broker" when the client, endpoint and optional pinned digest validate, and kind "refuse" when they do not. A declared Linux broker never
10
+ // falls back to direct cargo; a `rustright` on PATH, a shim directory or a socket named in another variable never declares it.
7
11
  import { brokerManagedHost, managedCargoShimOnPath } from "./cargo-contract.mjs";
8
- import { directCargoPath, directPlatform } from "./managed-cargo.mjs";
12
+ import { LINUX_BROKER_ROUTE, directCargoPath, directPlatform, resolveLinuxBrokerRoute } from "./managed-cargo.mjs";
9
13
 
10
- export { directCargoPath };
14
+ export { directCargoPath, LINUX_BROKER_ROUTE };
11
15
 
12
16
  export const EXECUTOR_KINDS = Object.freeze(["broker", "refuse", "direct"]);
13
17
 
14
18
  /**
15
- * @param {{ env?: object, platform?: string, exists?: (p: string) => boolean, which: (cmd: string) => string[], readFile: (p: string) => string }} o
16
- * @returns {{ kind: "broker" | "refuse" | "direct", cargo: string[] | null, problem?: string, env?: { PATH: string }, note?: string }}
19
+ * @param {{ forceDirect?: boolean, env?: object, platform?: string, exists?: (p: string) => boolean, which: (cmd: string) => string[], readFile: (p: string) => string, fsOps?: object }} o
20
+ * @returns {{ kind: "broker" | "refuse" | "direct", cargo: string[] | null, problem?: string, code?: string, route?: "linux-broker", client?: string, socket?: string, clientSha256?: string, env?: { PATH?: string, RUSTRIGHT_BUILD_BROKER_SOCKET?: string }, note?: string }}
17
21
  */
18
- export function resolveExecutor({ env = process.env, platform = process.platform, exists, which, readFile }) {
22
+ export function resolveExecutor({ env = process.env, platform = process.platform, exists, which, readFile, fsOps, forceDirect = false }) {
19
23
  // Every Linux (and any other non-Mac, non-Windows) execution host runs direct, whatever is on PATH or in the environment: WSL2, a VM,
20
- // a cloud or CI runner, or Linux hosted on the Dell (GATE-CONTRACT §1 "One entry"; joint plan step 4). Only Adrian's Mac and Windows
21
- // hosts use the broker.
24
+ // a cloud or CI runner, or Linux hosted on the Dell (GATE-CONTRACT §1 "One entry"; joint plan step 4), unless the host explicitly declared
25
+ // the optional Linux broker route (below). Adrian's Mac and Windows hosts use the broker as before.
22
26
  // `cargo` there must be a real toolchain: RustRight shim directories are taken off the PATH the gate hands its children (`env`).
23
- if (directPlatform(platform)) {
24
- const direct = directCargoPath({ env, exists });
25
- return { kind: "direct", cargo: ["cargo"], ...(direct.dropped.length ? { env: { PATH: direct.path }, note: `RustRight shims taken off PATH for direct cargo: ${direct.dropped.join(", ")}` } : {}), ...(direct.cargo ? {} : { note: `no direct cargo on PATH${direct.dropped.length ? ` (RustRight shims skipped: ${direct.dropped.join(", ")})` : ""}; cargo scans fail as a missing tool` }) };
27
+ // `forceDirect` is the one documented exception (broker-exception.mjs, validated by the gate before it is passed): a Mac or Dell run
28
+ // outside the broker, by Adrian's recorded approval. It takes the direct route with the same shim scrub, never the Linux broker route.
29
+ if (forceDirect || directPlatform(platform)) {
30
+ const linux = forceDirect ? { declared: false } : resolveLinuxBrokerRoute({ env, platform, fsOps });
31
+ if (linux.declared) {
32
+ // One typed choice for the gate, managed-cargo, the carried adapters and every child (they read it again from the same environment): the
33
+ // validated client and endpoint, never a PATH lookup. `env` is what the gate hands its children: the endpoint the client must talk to.
34
+ if (!linux.ok) return { kind: "refuse", cargo: null, route: LINUX_BROKER_ROUTE, code: linux.code, problem: linux.problem };
35
+ return {
36
+ kind: "broker", route: LINUX_BROKER_ROUTE, cargo: [linux.client, "cargo"], client: linux.client, socket: linux.socket, clientSha256: linux.clientSha256,
37
+ env: { RUSTRIGHT_BUILD_BROKER_SOCKET: linux.socket },
38
+ note: `declared Linux broker route: client ${linux.client} (sha256 ${linux.clientSha256.slice(0, 12)}), endpoint ${linux.socket}`,
39
+ };
40
+ }
41
+ const pathKey = platform === "win32" ? Object.keys(env).find((k) => k.toUpperCase() === "PATH") ?? "PATH" : "PATH";
42
+ const direct = directCargoPath({ env: { ...env, PATH: env[pathKey] }, exists, platform });
43
+ return { kind: "direct", cargo: ["cargo"], ...(direct.dropped.length ? { env: { [pathKey]: direct.path }, note: `RustRight shims taken off PATH for direct cargo: ${direct.dropped.join(", ")}` } : {}), ...(direct.cargo ? {} : { note: `no direct cargo on PATH${direct.dropped.length ? ` (RustRight shims skipped: ${direct.dropped.join(", ")})` : ""}; cargo scans fail as a missing tool` }) };
26
44
  }
27
45
  if (which("rustright").length) return { kind: "broker", cargo: ["rustright", "cargo"] };
28
46
  const first = which("cargo")[0];
package/gate-stages.mjs CHANGED
@@ -11,6 +11,8 @@
11
11
  import { createHash, randomBytes } from "node:crypto";
12
12
  import fs from "node:fs";
13
13
  import path from "node:path";
14
+ import { writeFileAtomic } from "./atomic-write.mjs";
15
+ import { platformDirOf } from "./platform-dir.mjs";
14
16
 
15
17
  export const STAGES = Object.freeze(["check", "compile", "package", "qualify", "release"]);
16
18
  export const RECEIPT_SCHEMA = "rightkit.gate-receipt/1";
@@ -25,8 +27,11 @@ const sha256 = (data) => createHash("sha256").update(data).digest("hex");
25
27
  * `--to test` included, builds a commit; uncommitted changes never enter a build). One row per stage name the gate dispatches:
26
28
  * the STAGES, `dev` and `test`, `stage` (the hand-off to the publisher) and each STAGES entry again as a RightKit unit stage
27
29
  * (`--to <stage> --unit <selector>`). `compiles` and `packages` say whether the stage starts a compiler or seals bytes.
28
- * source "snapshot": the gate-owned snapshot of the selected commit (`wired: false` names a stage whose snapshot route is
29
- * refused today, `blocker` saying why; it still never reads the checkout).
30
+ * source "snapshot": the gate-owned snapshot of the selected commit (the checkout is never a build source on any host: rightkit#1301 deleted
31
+ * the checkout route, so no row carries an interim checkout rule). `implemented: true` says the executable route exists in code and runs;
32
+ * `witnessed: false` with `pending` says which host acceptance (native fixture witness on the Mac and the Dell) has not yet been proved. The two
33
+ * are separate facts (GB-86): an implemented route may run for its witness, and a route whose host acceptance is unproved is never presented as
34
+ * ready for apps (`appFacingReady`). A stage the route planner cannot run fails at planning with a named capability, not through this table.
30
35
  * source "sealed" or "publisher", with `basis`: the stage compiles nothing from source (it acts on bytes an earlier stage
31
36
  * sealed, or runs only in the trusted publisher on hosted CI with its normal checkout, docs/GATE-CONTRACT.md).
32
37
  * source "checkout", with `gap`: a known breach, listed so it cannot grow; stageSourceProblems ignores it, stageSourceGaps names it.
@@ -35,24 +40,35 @@ const sha256 = (data) => createHash("sha256").update(data).digest("hex");
35
40
  */
36
41
  export const STAGE_SOURCES = Object.freeze({
37
42
  check: { compiles: false, packages: false, source: "snapshot" },
38
- compile: { compiles: true, packages: false, source: "snapshot", interim: "Mac and Dell: the clean-HEAD checkout route until the cutover" },
39
- // Wired in code (row 7.04: build-release.mjs, release.mjs, cli/right-release.mjs, release-source-identity.mjs): right-release builds and seals the selected commit from the snapshot. `wired` becomes true when door-package.test.mjs passes on L and on M and D (dev broker).
40
- package: { compiles: true, packages: true, source: "snapshot", wired: false, blocker: "wired in code (rows 7.04 and 7.04c: release-artifact-path.mjs binds each Cargo manifest's target, one resolver reads every declared artifact path); awaiting 7.04c green on L, M and D: the dev-broker rows need the snapshot carrier (rows 7.20 to 7.24) and RustRight's item-4 dev pins" },
43
+ compile: { compiles: true, packages: false, source: "snapshot" },
44
+ // Implemented in code (row 7.04: build-release.mjs, release.mjs, cli/right-release.mjs, release-source-identity.mjs): right-release builds and seals the selected commit from the snapshot. `witnessed` becomes true (and `pending` goes) when door-package.test.mjs passes on Linux and the native fixture witness passes on the Mac and the Dell (dev broker).
45
+ package: { compiles: true, packages: true, source: "snapshot", implemented: true, witnessed: false, pending: "route implemented in code (rows 7.04 and 7.04c: release-artifact-path.mjs binds each Cargo manifest's target, one resolver reads every declared artifact path); awaiting 7.04c green on L, M and D: the dev-broker rows need the snapshot carrier (rows 7.20 to 7.24) and RustRight's item-4 dev pins" },
41
46
  // Narrowed by decision 2 (7.06 correction): qualification rebuilds nothing, but its source reads (config, QA plan, test code) go through the split.
42
- qualify: { compiles: false, packages: false, source: "snapshot", wired: false, blocker: "qa --stage installed takes its identity from the split (release-qa-command.mjs, row 7.04); awaiting its tests on every host" },
47
+ qualify: { compiles: false, packages: false, source: "snapshot", implemented: true, witnessed: false, pending: "qa --stage installed takes its identity from the split (release-qa-command.mjs, row 7.04); awaiting its tests on every host" },
43
48
  release: { compiles: false, packages: false, source: "publisher", basis: "--to release runs only in the trusted publisher on hosted CI with its normal checkout (docs/GATE-CONTRACT.md), and only uploads sealed bytes" },
44
- dev: { compiles: true, packages: false, source: "snapshot", interim: "Mac and Dell: the checkout route (check --worktree) until the cutover" },
45
- test: { compiles: true, packages: false, source: "snapshot", interim: "Mac and Dell: the checkout route (check --worktree) until the cutover" },
49
+ dev: { compiles: true, packages: false, source: "snapshot" },
50
+ test: { compiles: true, packages: false, source: "snapshot" },
46
51
  stage: { compiles: false, packages: false, source: "sealed", basis: "stage packs the sealed directory and its receipt into the publisher's draft release; it compiles nothing" },
47
- // Row 7.06a: a unit stage builds the selected commit's snapshot (Linux now; Mac and Dell at the cutover commit, until which they take
48
- // the clean-HEAD checkout route, decision 2 "Interim").
49
- "unit:check": { compiles: false, packages: false, source: "snapshot", interim: "Mac and Dell: clean HEAD of the checkout until the cutover" },
50
- "unit:compile": { compiles: true, packages: false, source: "snapshot", interim: "Mac and Dell: clean HEAD of the checkout until the cutover" },
51
- "unit:package": { compiles: true, packages: true, source: "snapshot", interim: "Mac and Dell: clean HEAD of the checkout until the cutover" },
52
- "unit:qualify": { compiles: false, packages: false, source: "snapshot", interim: "Mac and Dell: clean HEAD of the checkout until the cutover" },
52
+ // Row 7.06a: a unit stage builds the selected commit's snapshot on every host (runUnitSnapshot); hosted CI keeps its runner checkout, which is the commit.
53
+ "unit:check": { compiles: false, packages: false, source: "snapshot" },
54
+ "unit:compile": { compiles: true, packages: false, source: "snapshot" },
55
+ "unit:package": { compiles: true, packages: true, source: "snapshot" },
56
+ "unit:qualify": { compiles: false, packages: false, source: "snapshot" },
53
57
  "unit:release": { compiles: false, packages: false, source: "publisher", basis: "a unit release is dispatched to the trusted publisher (releaseUnit); it compiles nothing here" },
54
58
  });
55
59
 
60
+ /**
61
+ * What a row says about readiness: `implemented` (the executable route exists), `witnessed` (host acceptance proved; absent means nothing is pending for the
62
+ * row) and `pending` (what remains). `appFacingReady` is true only for an implemented route with no pending host acceptance.
63
+ */
64
+ export function stageReadiness(stage, table = STAGE_SOURCES) {
65
+ const row = table[stage];
66
+ if (!row) return null;
67
+ const implemented = row.source === "snapshot" ? row.implemented !== false : true;
68
+ const pending = typeof row.pending === "string" && row.pending ? row.pending : null;
69
+ return { stage, implemented, witnessed: pending === null && row.witnessed !== false, pending, appFacingReady: implemented && pending === null && row.witnessed !== false };
70
+ }
71
+
56
72
  /** Rows of `table` that compile or package without a snapshot source, a stated exclusion basis or a registered gap. */
57
73
  export function stageSourceProblems(table = STAGE_SOURCES) {
58
74
  const out = [];
@@ -148,7 +164,7 @@ export function readReceipt({ commonDir, commit, app = ".", readFile = (f) => fs
148
164
  return null;
149
165
  }
150
166
 
151
- export function writeReceipt({ commonDir, receipt, write = fs.writeFileSync, mkdir = fs.mkdirSync }) {
167
+ export function writeReceipt({ commonDir, receipt, write = (file, data) => writeFileAtomic(file, data), mkdir = fs.mkdirSync }) {
152
168
  const dir = path.join(gateStateDir(commonDir), "receipts");
153
169
  mkdir(dir, { recursive: true });
154
170
  write(path.join(dir, `${receiptKey(receipt.commit, receipt.app ?? ".")}.json`), `${JSON.stringify(receipt, null, 2)}\n`);
@@ -173,13 +189,74 @@ export function sameSource(a, b) {
173
189
  return Boolean(a && b) && a.tree === b.tree && a.gate === b.gate && JSON.stringify(a.locks) === JSON.stringify(b.locks);
174
190
  }
175
191
 
192
+ /** Entries a compile-output census walks before it gives up (and the compile is then run again, warm, rather than trusted). */
193
+ export const OUTPUT_CENSUS_LIMIT = 250000;
194
+
195
+ /**
196
+ * What the compile stage produced, as the door can prove it later (GB-13, GATE E6/E7/E16): for each output directory the broker's
197
+ * receipt (or the direct host's resolution) named, every file's relative path and size, digested together. `truncated` means the walk
198
+ * hit `limit`; such a census never proves anything. A missing directory is recorded as missing, never as empty.
199
+ */
200
+ export function outputCensus(dirs = [], { limit = OUTPUT_CENSUS_LIMIT, readdir = (d) => fs.readdirSync(d, { withFileTypes: true }), stat = (f) => fs.statSync(f) } = {}) {
201
+ const unique = [...new Set(dirs.filter((d) => typeof d === "string" && d))].sort();
202
+ const lines = []; let truncated = false; const missing = [];
203
+ for (const dir of unique) {
204
+ const stack = [""];
205
+ let exists = true;
206
+ while (stack.length && !truncated) {
207
+ const rel = stack.pop();
208
+ let entries;
209
+ try { entries = readdir(path.join(dir, rel)); } catch { if (rel === "") exists = false; continue; }
210
+ for (const e of entries) {
211
+ const next = rel ? `${rel}/${e.name}` : e.name;
212
+ if (e.isDirectory()) { stack.push(next); continue; }
213
+ let size = -1; try { size = stat(path.join(dir, next)).size; } catch { /* vanished while walking: recorded as -1 */ }
214
+ lines.push(`${dir}\0${next}\0${size}`);
215
+ if (lines.length > limit) { truncated = true; break; }
216
+ }
217
+ }
218
+ if (!exists) missing.push(dir);
219
+ }
220
+ lines.sort();
221
+ return { dirs: unique, entries: lines.length, truncated, missing, digest: sha256(lines.join("\n")) };
222
+ }
223
+
224
+ /** The environment a Cargo compile reads that picks its profile, target or flags: part of what a compile result is valid for. */
225
+ const COMPILE_ENV = /^(CARGO_(?!HOME$|TARGET_DIR$|BUILD_TARGET_DIR$|REGISTRIES_|NET_|HTTP_|TERM_)|RUSTFLAGS$|RUSTDOCFLAGS$|RUSTC(_|$)|RUSTUP_TOOLCHAIN$)/;
226
+ export function compileKeyOf(env = {}) {
227
+ const picked = Object.entries(env).filter(([k, v]) => COMPILE_ENV.test(k) && v !== undefined).sort(([a], [b]) => a.localeCompare(b));
228
+ return sha256(JSON.stringify(picked));
229
+ }
230
+
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
+ const c = census(dirs);
234
+ return { dirs: c.dirs, entries: c.entries, digest: c.digest, truncated: c.truncated, key: compileKeyOf(env) };
235
+ }
236
+
237
+ /**
238
+ * Whether a recorded compile still stands for the outputs on disk now: null when it does, else the reason it does not. A record with no
239
+ * `outputs` (a receipt from before this binding) proves nothing and does not stand.
240
+ */
241
+ export function compileReuseProblem(recorded, { env = {}, census = outputCensus } = {}) {
242
+ const out = recorded?.outputs;
243
+ if (!out || typeof out !== "object" || !Array.isArray(out.dirs) || typeof out.digest !== "string") return "the compile result records no outputs";
244
+ if (out.truncated) return "the recorded output census was incomplete";
245
+ if (out.key !== compileKeyOf(env)) return "the compile environment (profile, target or flags) differs from the recorded one";
246
+ const now = census(out.dirs);
247
+ if (now.missing.length) return `output directory ${now.missing[0]} is gone`;
248
+ if (now.truncated) return "the output census is too large to prove";
249
+ if (now.digest !== out.digest || now.entries !== out.entries) return "the compile outputs on disk differ from the recorded census (a required output was removed, replaced or added)";
250
+ return null;
251
+ }
252
+
176
253
  /** The sealed release directory build-release wrote for `commit` on `platform`: { id, manifest, digest } or null. */
177
254
  export function sealedFor({ root, commit, platform, readdir = fs.readdirSync, readFile = (f) => fs.readFileSync(f) }) {
178
255
  const base = path.join(root, ".right-release", "sealed");
179
256
  let ids = [];
180
257
  try { ids = readdir(base).filter((id) => id.endsWith(`-${commit.slice(0, 8)}`)); } catch { return null; }
181
258
  for (const id of ids.sort()) {
182
- const file = path.join(base, id, platform, "release-manifest.json");
259
+ const file = path.join(base, id, platformDirOf(platform), "release-manifest.json");
183
260
  try { const bytes = readFile(file); return { id, manifest: file, digest: sha256(bytes) }; } catch { /* not this platform */ }
184
261
  }
185
262
  return null;
@@ -11,6 +11,7 @@ import { verifySealedRelease } from "./release-state.mjs";
11
11
  import { admitInstalledQa } from "./release-qa-audit.mjs";
12
12
  import { addonManifestSha256, validateAddonConfig, validateAddonManifest } from "./addon-contract.mjs";
13
13
  import { RIGHTRELEASE_MANIFEST_SIGNER, verifyManifestCatalogSignature } from "./sign-release-manifest.mjs";
14
+ import { platformDirOf } from "./platform-dir.mjs";
14
15
 
15
16
  const REPO_RE = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
16
17
  const RELEASE_RE = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,159}$/;
@@ -21,7 +22,7 @@ export function prepareGitHubRelease({ repoRoot, releaseId, platform, repo, stat
21
22
  if (!RELEASE_RE.test(releaseId)) throw new Error(`invalid release id: ${releaseId}`);
22
23
  if (platform !== "mac" && platform !== "win") throw new Error("platform must be mac or win");
23
24
  if (!REPO_RE.test(repo)) throw new Error(`invalid GitHub repository: ${repo}`);
24
- const platformDir = platform === "win" ? "windows" : "mac";
25
+ const platformDir = platformDirOf(platform);
25
26
  const sealed = verifySealedRelease(path.join(repoRoot, ".right-release", "sealed", releaseId, platformDir));
26
27
  if (sealed.manifest.platform !== platform) throw new Error("sealed release platform mismatch");
27
28
  const installers = sealed.manifest.files.filter((file) => file.role === "installer");
@@ -18,6 +18,7 @@ import { verifyOwnershipEvidence } from "./ownership-evidence.mjs";
18
18
  import { assertPrimaryReleaseCheckout } from "./release-invocation.mjs";
19
19
  import { verifySealedRelease } from "./release-state.mjs";
20
20
  import { assertCleanSource } from "./source-gate.mjs";
21
+ import { platformDirOf } from "./platform-dir.mjs";
21
22
 
22
23
  /** Throws unless the operator named a tier. There is no default and no inference from the version or the notes. */
23
24
  export function assertUploadTier(tier) {
@@ -63,7 +64,7 @@ export async function authorizeGitHubUpload({
63
64
  status: git(repoRoot, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]),
64
65
  commandId: command,
65
66
  });
66
- const platformDir = platform === "win" ? "windows" : "mac";
67
+ const platformDir = platformDirOf(platform);
67
68
  const sealed = verifySealedRelease(path.join(repoRoot, ".right-release", "sealed", releaseId, platformDir));
68
69
  if (sealed.manifest.commit !== candidateCommit) throw new Error("sealed release was built from a different commit than the current checkout");
69
70