@sleepy_panda_srl/portulan 0.1.2 → 0.1.3

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.
@@ -0,0 +1,1068 @@
1
+ #!/usr/bin/env node
2
+ // Portulan — the eval result a release carries.
3
+ //
4
+ // Milestone 8's ninth clause: *a release carries an eval result*. `../docs/plan.md`'s Protocol →
5
+ // Versioning has carried the obligation since the plan was locked — *"from milestone 8, releases carry
6
+ // an eval result"* — and the maintainer's ruling of 2026-08-24 made it bind row 8's close. The
7
+ // legislative history is `../docs/milestones/m08.md`; the criterion itself is the row, and this file
8
+ // deliberately restates neither.
9
+ //
10
+ // ## The three things the amendment left open, and what this module settles
11
+ //
12
+ // The 2026-08-24 amendment named what it did **not** settle: *"what an eval result attached to a
13
+ // release consists of, where it is carried (the changelog entry, the release body, a file in the
14
+ // payload), and whether a release with no eval result is refused by a rail or by a person. Those are
15
+ // this row's remaining work, not this amendment's content."*
16
+ //
17
+ // 1. **What it consists of.** The verdict every recipe the workspace YIELDS returned, measured by
18
+ // running them, plus the identity of the A/B baseline the release ships against. Not a summary
19
+ // anybody types.
20
+ // 2. **Where it is carried.** One file per version, `evals/releases/<version>.md`, rendered from
21
+ // `evals/releases/<version>.json`. `CHANGELOG.md` and the release body CITE it and restate no
22
+ // figure — a rule with two carriers is obeyed at the narrower one.
23
+ // 3. **Rail or person — it is BOTH, and the split is the honest answer.** A rail reaches the tree: a
24
+ // governed version with no record, a record that disagrees with its capture, a record listing a red
25
+ // rail. A rail reaches **no tag and no published release body** — `../.portulan/gate-map.md` keeps
26
+ // `tag-a-release` and `publish-a-release` Gated, and those happen outside any tree a check can
27
+ // read. Answering "a rail" flat would have claimed a reach this has not got. The split is the shape
28
+ // the drill calendar already uses for its own two halves.
29
+ //
30
+ // ## The subject is EVERY governed release, not the tree's current cut state — two wrong designs first
31
+ //
32
+ // The obvious cut-detector is `CHANGELOG.md`'s **top** heading: rename `## Unreleased` to a version and
33
+ // the tree is a cut. **It never fires.** The cut commit RE-SEEDS the accumulator — `b410c020` renamed
34
+ // the heading to `## 0.1.2 — 2026-08-20` and put a fresh `## Unreleased` back above it in the same
35
+ // commit, which `CHANGELOG.md`'s own header requires so the tagged tree contains its own entry. So the
36
+ // top heading is `Unreleased` on every commit including the cut, and a rail keyed to it would have
37
+ // reported *no cut in this tree* on precisely the tree it was built to grade.
38
+ //
39
+ // The second design keyed off `package.json`'s `version` alone — what actually moves at a cut, and what
40
+ // `./version-carriers.mjs` grades the prose against. It fires, and it **protects one release at a
41
+ // time**: once `0.1.4` is declared, `0.1.3`'s record can be deleted or corrupted and nothing reds. A
42
+ // rail over a record layer that only ever grades the newest record is not a rail over the record layer.
43
+ //
44
+ // So the subject is **every version `CHANGELOG.md` records**, from `FIRST_GOVERNED_VERSION` onward,
45
+ // permanently — plus one cut-integrity check that holds between cuts as well as at one: the newest
46
+ // version heading must be the version `package.json` declares. Both findings were the second opinion's,
47
+ // caught before a line of the rail was written and recorded here rather than in a later repair.
48
+
49
+ // ## What this cannot reach, and the one surface that can
50
+ //
51
+ // The heading rename happens *"in a change merged before the tag is created"* — `CHANGELOG.md`'s own
52
+ // header — and that ordering is human-owned prose in a file that says of its neighbouring rule *"nothing
53
+ // checks this"*. A maintainer who tags a commit whose accumulator was never renamed produces a release
54
+ // no tree state ever flagged **where nothing else moved either** — `--verify` is green on such a tree,
55
+ // since `package.json` and the heading still agree with each other. Where the version WAS bumped and a
56
+ // record written, `--verify` reds it here on the pull request; what no rail sees is that same tree
57
+ // reached by a tag rather than by a merge, since `--tagged` below never reads `CHANGELOG.md`.
58
+ //
59
+ // `../.github/workflows/publish-github-packages.yml` fires on `release: published` and checks out the
60
+ // tag — the one mechanical surface at the release act itself — and it runs `--tagged` below against that
61
+ // checkout. That is the half a rail can reach at the moment a person acts; the rest stays Gated and is
62
+ // carried in `../.portulan/gate-map.md`.
63
+ //
64
+ // ## Read the WORKTREE, where `./version-carriers.mjs` reads the index — and the difference is the point
65
+ //
66
+ // That module reads `package.json` and the prose it compares **both** from the git index, because
67
+ // comparing worktree prose against an index version compares two trees. Its recorded residual is that an
68
+ // **unstaged** cut is therefore invisible to it: run against a fully-edited worktree it reported a green
69
+ // about a tree that no longer existed.
70
+ //
71
+ // This module reads the worktree, uniformly, both the version and the record. `../.portulan/dod.md`
72
+ // condition 1 asks that every recipe ran green *in this working copy*, and there is no cross-tree
73
+ // comparison here to protect — the record and the version are read from one tree either way. Inheriting
74
+ // the index read would have inherited that residual for nothing.
75
+ //
76
+ // ## `0.1.0`–`0.1.2` are out of scope, by the Protocol's own words
77
+ //
78
+ // *"**From milestone 8**, releases carry an eval result."* Three releases predate the clause and carry
79
+ // none. `FIRST_GOVERNED_VERSION` is the boundary, and it is a **historical constant** rather than a
80
+ // hand-maintained figure of a moving subject: which releases predate a ruling cannot change. The
81
+ // distinction matters because this repository deletes hand-maintained figures on sight, and the reason
82
+ // it does is that their subject moves. This one's does not.
83
+ //
84
+ // Exit 0 green · 1 a finding · 2 could not run.
85
+
86
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
87
+ import { execFileSync } from "node:child_process";
88
+ import path from "node:path";
89
+ import { pathToFileURL } from "node:url";
90
+
91
+ import { recipeSet, resolverFor } from "./recipe-set.mjs";
92
+
93
+ /** Raised when a precondition fails. Always exit 2 — never a finding about a release. */
94
+ export class CouldNotRun extends Error {}
95
+
96
+ /** Where a version's record lives. Both halves, because one carrier is a pair here as it is for the baseline. */
97
+ export const RECORD_DIR = "evals/releases";
98
+ export const snapshotPath = (version) => `${RECORD_DIR}/${version}.json`;
99
+ export const registerPath = (version) => `${RECORD_DIR}/${version}.md`;
100
+
101
+ /**
102
+ * The first release the clause governs.
103
+ *
104
+ * `../docs/plan.md`'s Protocol → Versioning: *"from milestone 8, releases carry an eval result"*.
105
+ * `v0.1.0`, `v0.1.1` and `v0.1.2` were cut before that clause had an owner — it belonged to no row
106
+ * until 2026-08-24 — so they carry no record and this rail does not invent one for them. A retro-fitted
107
+ * record would be a capture nobody ran, which is the one thing an eval result may not be.
108
+ */
109
+ export const FIRST_GOVERNED_VERSION = "0.1.3";
110
+
111
+ /**
112
+ * This module's own recipe id, excluded from every capture it writes.
113
+ *
114
+ * **The capture cannot be accurate about itself and the exclusion is the only honest resolution.**
115
+ * `release-eval` is a yielded recipe, so its verdict would enter the record it lives inside: captured
116
+ * before the record is written it is red, because the governed version has no record; captured after,
117
+ * the capture is stale the moment the register is rendered from it. Every ordering is wrong. So its row
118
+ * is excluded and the exclusion is PRINTED in the register — a dropped row that says nothing is how a
119
+ * record starts implying a green nobody measured.
120
+ */
121
+ export const SELF = "release-eval";
122
+
123
+ /** `X.Y.Z` and nothing else. A prerelease or a build tag is could-not-run rather than a guess at ordering. */
124
+ const SEMVER = /^(\d+)\.(\d+)\.(\d+)$/;
125
+
126
+ /**
127
+ * Compare two `X.Y.Z` versions. Throws `CouldNotRun` on anything else.
128
+ *
129
+ * Deliberately not a general semver comparator: this repository ships `X.Y.Z` and nothing more, and a
130
+ * partial implementation that silently mis-orders a prerelease would decide whether a release is
131
+ * governed. Refusing is the answer that cannot be quietly wrong.
132
+ */
133
+ export function compareVersions(a, b) {
134
+ const pa = SEMVER.exec(a);
135
+ const pb = SEMVER.exec(b);
136
+ if (!pa) throw new CouldNotRun(`\`${a}\` is not an \`X.Y.Z\` version — this rail does not order prereleases or build tags`);
137
+ if (!pb) throw new CouldNotRun(`\`${b}\` is not an \`X.Y.Z\` version — this rail does not order prereleases or build tags`);
138
+ for (let i = 1; i <= 3; i += 1) {
139
+ const d = Number(pa[i]) - Number(pb[i]);
140
+ if (d !== 0) return d < 0 ? -1 : 1;
141
+ }
142
+ return 0;
143
+ }
144
+
145
+ /** Whether the clause governs this version. */
146
+ export function isGoverned(version) {
147
+ return compareVersions(version, FIRST_GOVERNED_VERSION) >= 0;
148
+ }
149
+
150
+ /** `package.json`'s version, from the worktree. See the header for why not the index. */
151
+ export function declaredVersion(root) {
152
+ let raw;
153
+ try {
154
+ raw = readFileSync(path.join(root, "package.json"), "utf8");
155
+ } catch (e) {
156
+ throw new CouldNotRun(`could not read package.json: ${e.message}`);
157
+ }
158
+ let v;
159
+ try {
160
+ v = JSON.parse(raw).version;
161
+ } catch (e) {
162
+ throw new CouldNotRun(`package.json is not valid JSON: ${e.message}`);
163
+ }
164
+ if (typeof v !== "string" || !v.trim()) throw new CouldNotRun("package.json declares no version string");
165
+ return v.trim();
166
+ }
167
+
168
+ /**
169
+ * Every version `CHANGELOG.md` records, newest first.
170
+ *
171
+ * **This is the released set, and it is the rail's subject.** `../CHANGELOG.md` carries one `## X.Y.Z —
172
+ * DATE` heading per release and one `## Unreleased` accumulator that is re-seeded at every cut, so the
173
+ * version headings are the releases and the accumulator is never one. A heading is matched only in
174
+ * column 0 at depth 2, because the entries below quote their own retired headings — and **fenced
175
+ * regions are skipped**, because a worked example of a cut belongs in one and would otherwise enter the
176
+ * released set as a phantom release. A version-shaped heading that is not `X.Y.Z` is refused rather than
177
+ * skipped, so the subject list cannot decide by omission what `compareVersions` refuses out loud.
178
+ */
179
+ export function changelogVersions(root) {
180
+ let text;
181
+ try {
182
+ text = readFileSync(path.join(root, "CHANGELOG.md"), "utf8");
183
+ } catch (e) {
184
+ throw new CouldNotRun(`could not read CHANGELOG.md: ${e.message}`);
185
+ }
186
+ const out = [];
187
+ let fenced = false;
188
+ for (const line of text.split("\n")) {
189
+ // **Fenced regions are skipped**, because a column-0 fence is exactly where this file would show
190
+ // a reader what a cut looks like — and a worked example naming `## 9.9.9` would enter the
191
+ // released set as a phantom, producing a false red on both the cut-integrity arm and the
192
+ // missing-record arm. Latent today; the docstring above claimed a robustness the fence broke.
193
+ if (/^(```|~~~)/.test(line)) {
194
+ fenced = !fenced;
195
+ continue;
196
+ }
197
+ if (fenced) continue;
198
+ const m = /^## (\d+\.\d+\.\d+)(?:\s|$)/.exec(line);
199
+ if (m) {
200
+ out.push(m[1]);
201
+ continue;
202
+ }
203
+ // **A version-shaped heading this cannot order is REFUSED, never skipped.** `compareVersions`
204
+ // refuses a prerelease loudly on the ground that a partial ordering would silently decide whether
205
+ // a release is governed — and the subject-list builder was dropping `## 0.1.3-rc.1` without a
206
+ // word, which decides the same thing by omission. The two halves now agree.
207
+ if (/^## \d/.test(line)) {
208
+ throw new CouldNotRun(
209
+ `CHANGELOG.md carries the release heading ${JSON.stringify(line.trim())}, which is not \`## X.Y.Z\` — this rail ` +
210
+ "does not order prereleases or build tags, and skipping one would decide by omission whether it is governed",
211
+ );
212
+ }
213
+ }
214
+ if (out.length === 0) throw new CouldNotRun("CHANGELOG.md records no `## X.Y.Z` release heading — refusing to report green over a file nothing could be read from");
215
+ return out;
216
+ }
217
+
218
+ /** The commit this capture was taken at, and whether the tree was clean when it was. */
219
+ export function sourceOf(root) {
220
+ let commit;
221
+ try {
222
+ commit = execFileSync("git", ["-C", root, "rev-parse", "HEAD"], { encoding: "utf8" }).trim();
223
+ } catch (e) {
224
+ throw new CouldNotRun(`could not read HEAD: ${e.message}`);
225
+ }
226
+ let clean;
227
+ try {
228
+ clean = execFileSync("git", ["-C", root, "status", "--porcelain"], { encoding: "utf8" }).trim() === "";
229
+ } catch (e) {
230
+ throw new CouldNotRun(`could not read the working tree's state: ${e.message}`);
231
+ }
232
+ return { commit, clean };
233
+ }
234
+
235
+ /**
236
+ * The recipe set this workspace yields, pinned.
237
+ *
238
+ * The root is **named**, so it replaces every other source and this answer is about the tree rather than
239
+ * about the machine — the same reason `./drills.mjs`'s recipe and `./goldens.mjs`'s pin theirs.
240
+ */
241
+ export function yieldedRecipes({ root, workspace = ".portulan", packRoot = "packs" }) {
242
+ const workspaceDir = path.join(root, workspace);
243
+ let manifest;
244
+ try {
245
+ manifest = JSON.parse(readFileSync(path.join(workspaceDir, "workspace.json"), "utf8"));
246
+ } catch (e) {
247
+ throw new CouldNotRun(`could not read the workspace manifest: ${e.message}`);
248
+ }
249
+ let resolve;
250
+ try {
251
+ resolve = resolverFor({ workspaceDir, manifest, repoRoot: root, named: [path.join(root, packRoot)] });
252
+ } catch (e) {
253
+ throw new CouldNotRun(`could not resolve this workspace's pack roots: ${e.message}`);
254
+ }
255
+ const set = recipeSet(manifest, { resolve });
256
+ if (!set.ok) throw new CouldNotRun(`the workspace yields no readable recipe set: ${set.refusal ?? set.couldNotRun ?? "no reason given"}`);
257
+ return set.recipes.map((r) => ({ id: r.id, run: r.run }));
258
+ }
259
+
260
+ /**
261
+ * The A/B baseline this release ships against — its IDENTITY, never its figures.
262
+ *
263
+ * **Restating the cells here would be the defect this whole module is arranged against.** The figures
264
+ * have a carrier — `evals/ab/baseline.json`, and `evals/ab/baseline.md` rendered from it and
265
+ * byte-compared by the `ab-run` recipe — and a second copy of them in a per-release record is a
266
+ * hand-maintained figure of a moving subject wearing a machine-written costume. What a release record
267
+ * owes its reader is *which* baseline it shipped against and where to read it, so that is what this
268
+ * returns: path, capture date, the commit it was taken at, and whether that tree was clean.
269
+ *
270
+ * Returns `null` where no baseline is committed, which a release may legitimately be in — and the
271
+ * register then says so rather than rendering a hole.
272
+ */
273
+ export function abBaselineIdentity(root) {
274
+ const snapshot = "evals/ab/baseline.json";
275
+ const abs = path.join(root, snapshot);
276
+ if (!existsSync(abs)) return null;
277
+ let snap;
278
+ try {
279
+ snap = JSON.parse(readFileSync(abs, "utf8"));
280
+ } catch (e) {
281
+ throw new CouldNotRun(`the A/B baseline at ${snapshot} is not valid JSON: ${e.message}`);
282
+ }
283
+ // **Read through, and coerce nothing.** A first cut mapped a missing field to `null`, which the
284
+ // renderer then printed as `<undated>` / `<uncommitted>` — so a baseline whose capture lacked those
285
+ // fields produced a perfectly well-formed register naming a placeholder, and the derived probe below
286
+ // saw no hole because a fallback had filled it. Reading through means an absent field arrives as
287
+ // `undefined`, renders as `undefined`, and is refused before anything is written. Fail closed.
288
+ return {
289
+ snapshot,
290
+ register: "evals/ab/baseline.md",
291
+ captured: snap?.captured,
292
+ commit: snap?.source?.commit,
293
+ clean: snap?.source?.clean,
294
+ };
295
+ }
296
+
297
+ /**
298
+ * The limitations, DERIVED from the capture rather than fixed.
299
+ *
300
+ * 6d round 2's finding, and it is this milestone's signature defect: a limitation asserted flat about a
301
+ * field the capture may or may not hold is a false sentence waiting for its first counterexample. That
302
+ * block asserted *"the model is not recorded"* while the harness had just begun recording one.
303
+ */
304
+ export function limitationsFor(snap) {
305
+ const out = [
306
+ "**A green says this record agrees with its own capture — never that the release is good.** Every " +
307
+ "line below is what the recipes returned at one commit, and a recipe's own green establishes only what " +
308
+ "that recipe's documentation says it establishes.",
309
+ // No `??` here either — the fallback survived one round of removing them, in the block whose
310
+ // subject is claims a capture never made, so a null-commit record printed `null` in its table and
311
+ // `<commit>` in its limitations. `verifyShape` refuses such a capture before this is reached.
312
+ `**The recipes were not run at the tag.** They ran at \`${snap?.source?.commit}\`, and this record is ` +
313
+ "committed *in* the cut change — so the tagged tree is this commit plus whatever that change still adds " +
314
+ "after the capture, the record itself at minimum. A record cannot be captured at a commit that does not " +
315
+ "exist yet, and printing the one it was captured at is the only honest form.",
316
+ `**\`${SELF}\` is excluded from the rows above**, because a capture cannot be accurate about the record it is inside. ` +
317
+ "Its verdict for this release is the rail's own run on the pull request that carries this file.",
318
+ // **The record ships inside the npm payload, so it is read where nothing around it says what it
319
+ // is about.** In a consumer's `node_modules` a table headed *"N of M recipes this workspace
320
+ // yielded"* invites being read as a claim about THEIR workspace. It is a claim about this
321
+ // repository's own build and nothing else, and the sentence saying so travels with the document
322
+ // rather than living in a README that does not.
323
+ "**This record measures the Portulan repository's own build**, at the commit named above — never the workspace, " +
324
+ "project or package this release is installed into. Nothing here has looked at your tree.",
325
+ ];
326
+ out.push(
327
+ snap?.source?.clean === true
328
+ ? "**The tree was clean at capture**, so the commit named above is what was measured."
329
+ : "**The tree was NOT clean at capture** — the diff of the cut change is the only record of what was uncommitted, " +
330
+ "which is weaker than a sha and is printed rather than hidden.",
331
+ );
332
+ out.push(
333
+ snap?.abBaseline === null || snap?.abBaseline === undefined
334
+ ? "**No A/B baseline is committed in this tree**, so this release ships against none. That is a state, not a hole."
335
+ : "**The A/B baseline's figures are NOT restated here.** This record names which baseline the release ships " +
336
+ "against; the figures and everything that may not be concluded from them live in that register alone.",
337
+ );
338
+ return out;
339
+ }
340
+
341
+ /** The register, rendered from the capture. The one place a release's eval result is written for a reader. */
342
+ export function renderRegister(snap) {
343
+ const L = [];
344
+ L.push(`# Eval result — Portulan ${snap.version}`);
345
+ L.push("");
346
+ L.push(
347
+ "> **Generated. Do not edit.** Rendered from " +
348
+ `[\`${snapshotPath(snap.version)}\`](${path.basename(snapshotPath(snap.version))}) by ` +
349
+ "[`cli/release-eval.mjs`](../../cli/release-eval.mjs) and byte-compared by the `release-eval` verify recipe. " +
350
+ "This is the eval result milestone 8 requires a release to carry — `docs/plan.md`, Protocol → Versioning.",
351
+ );
352
+ L.push("");
353
+ L.push("## What this release was measured at");
354
+ L.push("");
355
+ L.push("| | |");
356
+ L.push("|---|---|");
357
+ L.push(`| Version | \`${snap.version}\` |`);
358
+ L.push(`| Captured | ${snap.captured} |`);
359
+ L.push(`| Commit | \`${snap.source.commit}\` |`);
360
+ L.push(`| Tree at capture | ${snap.source.clean ? "clean" : "**not clean**"} |`);
361
+ L.push(`| Node | \`${snap.host.node}\` |`);
362
+ L.push(`| Platform | \`${snap.host.platform}\` |`);
363
+ L.push("");
364
+ L.push("## The rails, and what each returned");
365
+ L.push("");
366
+ L.push(
367
+ `${snap.recipes.length} of the ${snap.recipes.length + snap.excluded.length} recipes this workspace yielded at capture. ` +
368
+ "The set is derived from [`cli/recipe-set.mjs`](../../cli/recipe-set.mjs) and never listed by hand.",
369
+ );
370
+ L.push("");
371
+ L.push("| Recipe | Verdict |");
372
+ L.push("|---|---|");
373
+ for (const r of snap.recipes) {
374
+ L.push(`| \`${r.id}\` | ${r.exit === 0 ? "green" : `**exit ${r.exit}**`} |`);
375
+ }
376
+ L.push("");
377
+ for (const e of snap.excluded) {
378
+ L.push(`**\`${e.id}\` is excluded**, and the reason is printed rather than the row silently dropped: ${e.why}`);
379
+ L.push("");
380
+ }
381
+ L.push("## The A/B baseline this release ships against");
382
+ L.push("");
383
+ if (snap.abBaseline === null) {
384
+ L.push("None is committed in this tree.");
385
+ } else {
386
+ // No `??` fallbacks: a missing field must render as a hole the derived check refuses, never as a
387
+ // placeholder that reads like a measurement. `clean` is the exception by nature — it renders as a
388
+ // branch — and is therefore checked by name in `verifyShape`.
389
+ L.push(
390
+ `[\`${snap.abBaseline.register}\`](../../${snap.abBaseline.register}), rendered from ` +
391
+ `\`${snap.abBaseline.snapshot}\` — captured ${snap.abBaseline.captured} at ` +
392
+ `\`${snap.abBaseline.commit}\`, over a tree that was ` +
393
+ `${snap.abBaseline.clean === true ? "clean" : "**not clean**"}.`,
394
+ );
395
+ L.push("");
396
+ L.push("**Its figures are not repeated here.** They have one carrier and this is not it.");
397
+ }
398
+ L.push("");
399
+ L.push("## What this record does not establish");
400
+ L.push("");
401
+ for (const line of limitationsFor(snap)) L.push(`- ${line}`);
402
+ L.push("");
403
+ return L.join("\n");
404
+ }
405
+
406
+ /**
407
+ * The shape check, DERIVED from the renderer — and it runs before any render a caller trusts.
408
+ *
409
+ * 6d rounds 3, 4, 6 and 7 arrived at this shape over five rounds and it is adopted rather than
410
+ * re-derived: report rather than throw; check **before** rendering rather than after; check what is
411
+ * dereferenced rather than what is typed; and derive the list from the renderer instead of hand-writing
412
+ * another function's reads, because a hand-written one goes stale the moment the renderer reads one more
413
+ * thing.
414
+ *
415
+ * **The derived check is blind to anything the renderer supplies a value for**, and that is the whole of
416
+ * its limit rather than "one boolean". A first cut of this module claimed exactly one blind spot and had
417
+ * three: `abBaseline.captured` and `abBaseline.commit` were read through `??` fallbacks, so absence
418
+ * printed `<undated>` and `<uncommitted>` — placeholders that read like measurements — and
419
+ * `abBaseline.clean: null` was *explicitly permitted* by the check whose message said its absence must
420
+ * not publish an unmeasured claim, then rendered as `**not clean**`. Caught by a fresh context deleting
421
+ * every field the renderer reads, one at a time, which is the only way this class is ever found.
422
+ *
423
+ * So there are **four** rules, and each after the first was a further round of this same defect — which
424
+ * is the honest way to carry the count, since a docblock claiming totality is what the last two rounds
425
+ * caught this file doing. (1) The
426
+ * renderer supplies no fallback, so absence renders as a hole. (2) Every field that renders as a
427
+ * **branch** rather than a value is checked here by name, because no hole appears for those — only the
428
+ * meaning is invented. (3) **No leaf may be `null` and no string leaf may be blank**, because a
429
+ * present-degenerate value is neither absent nor a branch: `${null}` renders the four characters `null`
430
+ * and `""` renders nothing at all, so the document comes out well-formed and the probe below — which
431
+ * looks for `undefined` and `NaN` — sees a clean page. Six of these passed a check that had just been
432
+ * hardened against exactly this class and whose comment claimed two rules were the whole of it.
433
+ *
434
+ * Rule 3 is **structural rather than a list of fields**: it walks the capture's own leaves. A
435
+ * hand-written roster of "fields that must be non-empty" is the defect this file has now met at three
436
+ * depths, and it would go stale the moment the renderer read one more. (4) **A field that names a commit
437
+ * must look like one** — rule 3 is a floor under every leaf and a *name* check for none, so
438
+ * `source.commit: "banana"` passed it, and `"HEAD"` passed while reading like an answer. Rules 3 and 4
439
+ * are different questions about the same leaf: *is anything there* and *is it the kind of thing it
440
+ * claims to be*.
441
+ */
442
+ /** `YYYY-MM-DD`. A capture that stamps itself from anything else cannot be reproduced, and it is printed. */
443
+ const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
444
+
445
+ /**
446
+ * A full object name — 40 hex for SHA-1, 64 for a SHA-256 repository.
447
+ *
448
+ * **A commit field is a NAME, and a name-shaped check is the only one that can tell a commit from a
449
+ * word.** `source.commit: "banana"` passed shape, the render probe and the non-blank walk alike, because
450
+ * it is a present, non-empty string that renders perfectly — and `"HEAD"` passed too, which is worse,
451
+ * since it reads like an answer. The record's central claim is that it was measured *at a named commit*;
452
+ * without this the claim is satisfied by any prose at all. Copilot's round 1 on
453
+ * [#381](https://github.com/sleepy-panda-srl/portulan/pull/381), and it is the degenerate-value class one
454
+ * field further in than the round before it reached: rule 3 asks whether a leaf is *present and
455
+ * non-blank*, which is a floor under every field and a name check for none.
456
+ *
457
+ * Abbreviated shas are refused. Nothing here writes one — `sourceOf` runs `git rev-parse HEAD` — and a
458
+ * short name is ambiguous in exactly the way a record meant to be re-derived cannot afford.
459
+ */
460
+ const OBJECT_NAME = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
461
+
462
+ /**
463
+ * Every leaf of the capture, as a dotted path — the subject of rule 3 above.
464
+ *
465
+ * `abBaseline: null` is the one legitimate null and is skipped by the caller rather than exempted here,
466
+ * because an exemption inside a total walk is how a second one gets added later without argument.
467
+ */
468
+ function leafPaths(value, prefix = []) {
469
+ if (value === null || typeof value !== "object") return [[prefix, value]];
470
+ return Object.entries(value).flatMap(([k, v]) => leafPaths(v, [...prefix, k]));
471
+ }
472
+
473
+ export function verifyShape(snap) {
474
+ const red = [];
475
+ if (snap?.portulan?.releaseEval !== "1") red.push("the record does not declare `portulan.releaseEval: \"1\"` — this is not a release eval capture");
476
+ if (typeof snap?.version !== "string" || !SEMVER.test(snap.version)) {
477
+ red.push(`the record's \`version\` is ${JSON.stringify(snap?.version)}, which is not an \`X.Y.Z\` release`);
478
+ }
479
+ if (typeof snap?.captured !== "string" || snap.captured === "") red.push("the record carries no `captured` date");
480
+ if (!Array.isArray(snap?.recipes)) red.push("the record's `recipes` is not an array — the verdicts cannot be read");
481
+ else {
482
+ if (snap.recipes.length === 0) red.push("the record lists no recipes at all — a release measured by nothing is not a release with an eval result");
483
+ for (const r of snap.recipes) {
484
+ if (typeof r?.id !== "string" || r.id === "") red.push("a recipe row carries no id");
485
+ else if (!Number.isInteger(r?.exit)) red.push(`the row for \`${r.id}\` carries no integer \`exit\` — the register cannot print a verdict from it`);
486
+ }
487
+ }
488
+ if (!Array.isArray(snap?.excluded)) red.push("the record's `excluded` is not an array — an exclusion that is not printed is a dropped row");
489
+ else {
490
+ for (const e of snap.excluded) {
491
+ if (typeof e?.id !== "string" || e.id === "") red.push("an exclusion carries no id");
492
+ if (typeof e?.why !== "string" || e.why === "") red.push(`the exclusion of \`${e?.id ?? "?"}\` carries no reason — the register would print a dropped row with no account of it`);
493
+ }
494
+ }
495
+ for (const field of ["source", "host"]) {
496
+ if (snap?.[field] === undefined || snap[field] === null) red.push(`the record has no \`${field}\`, which the register prints among the release's conditions`);
497
+ }
498
+ // **The host's two fields are printed as conditions, so they are strings or they are a claim nobody
499
+ // measured.** Found by sweeping rule 4's *class* — a type-conditional guard — across this file rather
500
+ // than by fixing the site the note named: `host.node: 22` rendered `` `22` `` and passed everything.
501
+ // `../.portulan/proposals/0020-a-fix-is-not-done-at-the-site-it-was-found.md` is the reason the sweep
502
+ // happened at all, and this is the site it turned up that round 4 did not name.
503
+ if (snap?.host !== undefined && snap?.host !== null) {
504
+ for (const k of ["node", "platform"]) {
505
+ if (typeof snap.host[k] !== "string") {
506
+ red.push(`the record's \`host.${k}\` is ${JSON.stringify(snap.host[k])}, not a string — the register prints it as one of the conditions the release was measured under`);
507
+ }
508
+ }
509
+ }
510
+ // **The booleans, explicitly** — the derived check's one blind spot, measured rather than reasoned.
511
+ if (snap?.source !== undefined && snap?.source !== null && typeof snap.source.clean !== "boolean") {
512
+ red.push("the record's `source.clean` is not a boolean — it renders as a branch, so its absence would publish a claim about the tree that the capture never made");
513
+ }
514
+ // **`null` was permitted here and it renders as `**not clean**`** — a claim about the baseline's tree
515
+ // that the capture never made, published by the check whose own message said absence must not do
516
+ // that. Permitting the third state defeated the check in the same line that stated it. Strict.
517
+ if (snap?.abBaseline !== null && snap?.abBaseline !== undefined && typeof snap.abBaseline.clean !== "boolean") {
518
+ red.push("the record's `abBaseline.clean` is not a boolean — it renders as a branch, so anything else would assert a dirty baseline tree the capture never recorded");
519
+ }
520
+ if (snap?.abBaseline === undefined) {
521
+ red.push("the record has no `abBaseline` — `null` is how *no baseline* is recorded, and an absent field renders as one without ever having been measured");
522
+ }
523
+ // **Rule 4: a field that names a commit must BE a string that names one.** See `OBJECT_NAME`.
524
+ //
525
+ // **The guard's shape is the finding, not the pattern.** Written as *"if it is a string, validate
526
+ // it"*, the check skipped every value that was not a string — so `source.commit: 1234567890` rendered
527
+ // `` `1234567890` ``, `true` rendered `` `true` ``, and `["a"]` rendered `` `a` ``, each a plausible
528
+ // measurement passing a check whose whole subject is that field. A type-conditional guard is not a
529
+ // guard over the field; it is a guard over the field *when someone already used the right type*.
530
+ // Copilot round 4, and the same shape as rule 3's own origin: `is anything there` and `is it the kind
531
+ // of thing it claims to be` have to be asked of the value, never of the value's type.
532
+ const commitFields = [
533
+ ["source.commit", snap?.source, "the record's central claim is that it was measured at a named commit"],
534
+ ["abBaseline.commit", snap?.abBaseline, "a baseline cited by a name nothing can resolve is cited by nothing"],
535
+ ];
536
+ for (const [label, holder, why] of commitFields) {
537
+ if (holder === undefined || holder === null) continue;
538
+ const value = holder.commit;
539
+ if (typeof value !== "string" || !OBJECT_NAME.test(value)) {
540
+ red.push(`the record's \`${label}\` is ${JSON.stringify(value)}, which is not a full object name — ${why}`);
541
+ }
542
+ }
543
+ // **No `.trim()`**, and its presence here was the degenerate-value class once more. Trimming before
544
+ // the test accepted `"2026-09-01 "` and the renderer then printed the padding into the table —
545
+ // a value that passes a format check and violates the format it was checked against. A check that
546
+ // normalises its input is checking something other than what will be published. Copilot round 3.
547
+ if (typeof snap?.captured === "string" && !ISO_DATE.test(snap.captured)) {
548
+ red.push(`the record's \`captured\` is ${JSON.stringify(snap.captured)}, which is not a \`YYYY-MM-DD\` date — it is printed as one`);
549
+ }
550
+
551
+ // **Rule 3: no leaf is `null`, no string leaf is blank.** A present-degenerate value renders as a
552
+ // value, so neither the fallback rule nor the branch checks nor the probe below can see it: `null`
553
+ // prints the text `null` into a table cell and `""` prints nothing, and the register comes out
554
+ // looking measured. Structural rather than a field list, for the reason the docblock gives.
555
+ if (snap !== null && typeof snap === "object") {
556
+ const skip = snap.abBaseline === null ? "abBaseline" : null;
557
+ for (const [pathParts, leaf] of leafPaths(snap)) {
558
+ const dotted = pathParts.join(".");
559
+ if (skip !== null && pathParts[0] === skip) continue;
560
+ if (leaf === null) {
561
+ red.push(`the record's \`${dotted}\` is \`null\` — it renders as the text \`null\` in a document that otherwise reads as measured`);
562
+ } else if (typeof leaf === "string" && leaf.trim() === "") {
563
+ red.push(`the record's \`${dotted}\` is blank — it renders as nothing at all, which is a hole no probe can see`);
564
+ }
565
+ }
566
+ }
567
+
568
+ if (red.length > 0) return red;
569
+
570
+ // **The list above is not the check; this is.** Render, and refuse a document that came out carrying
571
+ // `undefined` or `NaN`. Anything the renderer reads and the capture lacks reds here without anybody
572
+ // listing it, including reads added later.
573
+ let rendered;
574
+ try {
575
+ rendered = renderRegister(snap);
576
+ } catch (cause) {
577
+ red.push(`the register cannot be rendered from this record — ${cause.message}`);
578
+ return red;
579
+ }
580
+ for (const hole of ["undefined", "NaN"]) {
581
+ if (rendered.includes(hole)) {
582
+ red.push(`the register rendered from this record contains \`${hole}\` — a field the renderer reads is missing, and a published document with a hole in it is worse than a refusal`);
583
+ }
584
+ }
585
+ return red;
586
+ }
587
+
588
+ /**
589
+ * The rail's verdict over one record.
590
+ *
591
+ * **Totality against the LIVE recipe set is deliberately not checked**, and the reason is that a record
592
+ * is a historical fact. A recipe added after `0.1.3` was cut does not make `0.1.3`'s record wrong; it
593
+ * makes it a record of an earlier tree, which is what it is for. Totality is a **capture-time** property
594
+ * — `--capture` runs whatever the workspace yields at that moment — and the register prints how many
595
+ * that was. A verify-time totality check would turn every past release red on the commit that adds a
596
+ * rail.
597
+ */
598
+ export function verifyRecord(snap, { version, keyedBy = "the file it is named for" }) {
599
+ const red = verifyShape(snap);
600
+ if (red.length > 0) return red;
601
+ if (snap.version !== version) {
602
+ // The source of `version` is named rather than assumed: `--verify` keys every release but the
603
+ // newest from `CHANGELOG.md`, so a message hard-coding *package.json declares* would have been
604
+ // false for all of them — a diagnostic pointing at the wrong file is a diagnostic that sends the
605
+ // next reader to the wrong place.
606
+ red.push(`the record declares version \`${snap.version}\` where ${keyedBy} says \`${version}\` — a record keyed to another release cannot answer for this one`);
607
+ }
608
+ for (const r of snap.recipes) {
609
+ if (r.exit !== 0) {
610
+ red.push(`the record shows \`${r.id}\` at exit ${r.exit} — a release may not carry an eval result that records a rail it did not pass`);
611
+ }
612
+ }
613
+ // **EXACTLY `SELF`, never merely including it.** Requiring presence let a red rail be *relocated*:
614
+ // move `tests` out of `recipes` into `excluded` with any reason string and the record is green, the
615
+ // register prints a smaller denominator and a principled-looking exclusion, and the recipe's own
616
+ // headline — *no record shows a rail at a non-zero exit* — is satisfied by not recording it. A
617
+ // verdict laundered into the exclusion list is worse than a red, because it reads as rigour.
618
+ // `--capture` only ever writes `SELF`, so requiring exactly that costs nothing. Found by a
619
+ // fresh-context reviewer attacking the record layer rather than the code.
620
+ const excludedIds = snap.excluded.map((e) => e.id).sort();
621
+ if (excludedIds.length !== 1 || excludedIds[0] !== SELF) {
622
+ red.push(
623
+ `the record excludes ${excludedIds.length === 0 ? "nothing" : `\`${excludedIds.join("`, `")}\``} where the only ` +
624
+ `admissible exclusion is \`${SELF}\` — anything else is a verdict moved out of the graded set`,
625
+ );
626
+ }
627
+ return red;
628
+ }
629
+
630
+ /** Run every yielded recipe except this module's own, and return the rows. */
631
+ function measure(root, recipes, { stdout }) {
632
+ const rows = [];
633
+ for (const r of recipes) {
634
+ if (r.id === SELF) continue;
635
+ stdout.write(`release-eval: running ${r.id}\n`);
636
+ let exit = 0;
637
+ try {
638
+ execFileSync("bash", ["-c", r.run], { cwd: root, stdio: "ignore" });
639
+ } catch (e) {
640
+ exit = typeof e.status === "number" ? e.status : 2;
641
+ }
642
+ rows.push({ id: r.id, exit });
643
+ }
644
+ return rows;
645
+ }
646
+
647
+ const USAGE = `portulan-release-eval — the eval result a release carries.
648
+
649
+ node cli/release-eval.mjs --capture [--repo-root <dir>] [--date <YYYY-MM-DD>]
650
+ node cli/release-eval.mjs --write [--repo-root <dir>] [--version <X.Y.Z>]
651
+ node cli/release-eval.mjs --verify [--repo-root <dir>]
652
+ node cli/release-eval.mjs --tagged <tag> [--repo-root <dir>]
653
+
654
+ --capture RUN every recipe this workspace yields and write ${RECORD_DIR}/<version>.json and .md
655
+ for the version package.json declares. Spends real time — it is the whole recipe set.
656
+ --write re-render a register from its committed capture. Runs no recipe. \`--version\` names
657
+ which record, and belongs to this mode alone: it is refused elsewhere rather than
658
+ ignored, because it once shared a slot with the tag and silently overrode it.
659
+ --verify the recipe's mode. EVERY governed release CHANGELOG.md records has a record; each
660
+ record's shape; no recorded red; each register byte-compared through this module's own
661
+ renderer; no record for a release that was never cut; and the newest heading agrees
662
+ with package.json.
663
+ --tagged the release act's mode, for a checkout OF THE TAG: does this tree carry a record for
664
+ the version being published, and does the payload declare the version the tag names.
665
+ Takes the TAG (\`v0.1.3\` or \`0.1.3\`). \`.github/workflows/publish-github-packages.yml\`
666
+ runs it.
667
+
668
+ Every governed release stays under the rail permanently, not only the newest: a rail that graded one
669
+ record at a time would let an older one be deleted in silence. Releases before ${FIRST_GOVERNED_VERSION}
670
+ predate the clause and are not graded.
671
+
672
+ Exit 0 green · 1 a finding · 2 could not run.`;
673
+
674
+ const MODES = ["--capture", "--write", "--verify", "--tagged"];
675
+
676
+ function parse(argv) {
677
+ // **`tagged` and `version` are SEPARATE fields, and sharing one was a real defeat of the rail.**
678
+ // Both meanings once lived in `out.version`: `--tagged`'s argument — the tag being published — and
679
+ // `--write`'s optional target. So `--tagged v0.1.3 --version 0.1.2` silently overwrote the tag with
680
+ // the payload's own version, and the tag-versus-payload refusal that `--tagged` exists for became
681
+ // `exit 0`; reversing the two arguments gave the opposite answer, which is the tell that one slot was
682
+ // carrying two meanings. Two names cannot collide. Copilot round 5.
683
+ const out = { mode: null, root: ".", date: null, version: null, tagged: null };
684
+ for (let i = 0; i < argv.length; i += 1) {
685
+ const a = argv[i];
686
+ const need = (what) => {
687
+ const v = argv[i + 1];
688
+ if (v === undefined || v.startsWith("--")) throw new CouldNotRun(`\`${a}\` needs ${what}`);
689
+ i += 1;
690
+ return v;
691
+ };
692
+ if (MODES.includes(a)) {
693
+ if (out.mode !== null) throw new CouldNotRun(`pass exactly one of ${MODES.join(", ")}`);
694
+ out.mode = a.slice(2);
695
+ // `--tagged` names the version being published; the others take it optionally.
696
+ if (a === "--tagged") {
697
+ // Swept with `--version` rather than left because the note named only one of them: the
698
+ // tag also reaches `snapshotPath()` after its `v` is stripped. `isGoverned` would refuse
699
+ // a malformed tag downstream, but a value that reaches a path builder before it reaches
700
+ // a validator is the same shape twice, and only one of the two was reported.
701
+ const t = need("the tag being published, for example `v0.1.3`");
702
+ if (!SEMVER.test(t.replace(/^v/, ""))) throw new CouldNotRun(`\`--tagged\` takes a tag naming an \`X.Y.Z\` version, not \`${t}\``);
703
+ out.tagged = t;
704
+ }
705
+ continue;
706
+ }
707
+ switch (a) {
708
+ case "--repo-root":
709
+ out.root = need("a directory");
710
+ break;
711
+ case "--date": {
712
+ // Validated here rather than trusted: `--date banana` satisfied the parser and the shape
713
+ // check alike, and the usage below promises `YYYY-MM-DD`. The front door was the one
714
+ // reachable route into the degenerate-value class.
715
+ const v = need("a YYYY-MM-DD date");
716
+ if (!ISO_DATE.test(v)) throw new CouldNotRun(`\`--date\` takes a \`YYYY-MM-DD\` date, not \`${v}\``);
717
+ out.date = v;
718
+ break;
719
+ }
720
+ case "--version": {
721
+ // Meaningful for `--write` alone: it names which committed record to re-render. Passing
722
+ // it anywhere else is a refusal rather than a silently ignored argument, because an
723
+ // argument that looks accepted and does nothing is how the collision above went unseen.
724
+ //
725
+ // **And it is validated HERE, because it reaches `path.join` as a path segment.**
726
+ // Unvalidated, `--write --version ../../pwned` rendered into
727
+ // `evals/releases/../../pwned.md` — outside the record directory entirely, overwriting a
728
+ // file two levels up. Demonstrated rather than reasoned about. `X.Y.Z` contains no `/`
729
+ // and no `..`, so validating the shape closes the traversal as a side effect of
730
+ // validating the meaning, which is the better order: the value is refused for not being
731
+ // a version, not for looking like an attack. Copilot round 6.
732
+ const v = need("an `X.Y.Z` version");
733
+ if (!SEMVER.test(v)) throw new CouldNotRun(`\`--version\` takes an \`X.Y.Z\` version, not \`${v}\` — it names a record file, so anything else is a path rather than a version`);
734
+ out.version = v;
735
+ break;
736
+ }
737
+ case "--help":
738
+ case "-h":
739
+ out.mode = "help";
740
+ break;
741
+ default:
742
+ throw new CouldNotRun(`unknown argument \`${a}\``);
743
+ }
744
+ }
745
+ if (out.mode === null) throw new CouldNotRun(`pass one of ${MODES.join(", ")}`);
746
+ if (out.version !== null && out.mode !== "write") {
747
+ throw new CouldNotRun("`--version` names which committed record `--write` re-renders, and means nothing in any other mode — refusing rather than ignoring it");
748
+ }
749
+ return out;
750
+ }
751
+
752
+ export function run(argv = [], { stdout = process.stdout, stderr = process.stderr } = {}) {
753
+ let parsed;
754
+ try {
755
+ parsed = parse(argv);
756
+ } catch (e) {
757
+ stderr.write(`release-eval: ${e.message}\n\n${USAGE}\n`);
758
+ return 2;
759
+ }
760
+ if (parsed.mode === "help") {
761
+ stdout.write(`${USAGE}\n`);
762
+ return 0;
763
+ }
764
+ const root = path.resolve(parsed.root);
765
+
766
+ let version;
767
+ try {
768
+ version = declaredVersion(root);
769
+ } catch (e) {
770
+ stderr.write(`release-eval: ${e.message}\n`);
771
+ return 2;
772
+ }
773
+
774
+ let governed;
775
+ try {
776
+ governed = isGoverned(version);
777
+ } catch (e) {
778
+ stderr.write(`release-eval: ${e.message}\n`);
779
+ return 2;
780
+ }
781
+
782
+ if (parsed.mode === "capture") {
783
+ // **The governance refusal runs FIRST, before any git or workspace read.** It sat after
784
+ // `sourceOf()`, which throws on a tree that is not a git repository — so on the one fixture that
785
+ // exercised it the refusal was never reached, and the case guarding it passed on a
786
+ // `could not read HEAD` it had accidentally allowed through an alternation. A precondition that
787
+ // needs nothing should be checked before the ones that need something.
788
+ if (!governed) {
789
+ stderr.write(
790
+ `release-eval: package.json declares \`${version}\`, which predates \`${FIRST_GOVERNED_VERSION}\` — ` +
791
+ "the clause binds *from milestone 8*, and capturing a record for a release it does not govern would " +
792
+ "manufacture history\n",
793
+ );
794
+ return 2;
795
+ }
796
+ let source;
797
+ let recipes;
798
+ let ab;
799
+ try {
800
+ source = sourceOf(root);
801
+ recipes = yieldedRecipes({ root });
802
+ ab = abBaselineIdentity(root);
803
+ } catch (e) {
804
+ stderr.write(`release-eval: ${e.message}\n`);
805
+ return 2;
806
+ }
807
+ // **The date is an argument, never `new Date()`.** A capture that stamps itself from the clock
808
+ // cannot be reproduced, and this record is byte-compared.
809
+ const captured = parsed.date ?? execFileSync("git", ["-C", root, "log", "-1", "--format=%cs"], { encoding: "utf8" }).trim();
810
+ const rows = measure(root, recipes, { stdout });
811
+ const snap = {
812
+ portulan: { releaseEval: "1" },
813
+ version,
814
+ captured,
815
+ source,
816
+ host: { node: process.version, platform: process.platform },
817
+ recipes: rows,
818
+ excluded: [
819
+ {
820
+ id: SELF,
821
+ why:
822
+ "a capture cannot be accurate about the record it is inside — captured before the record is written it " +
823
+ "is red for the record's absence, and captured after it is stale the moment the register is rendered. " +
824
+ "Its verdict for this release is the rail's own run on the pull request carrying this file.",
825
+ },
826
+ ],
827
+ abBaseline: ab,
828
+ };
829
+ const shape = verifyShape(snap);
830
+ if (shape.length > 0) {
831
+ for (const line of shape) stderr.write(`release-eval: ${line}\n`);
832
+ stderr.write("release-eval: refusing to write a record that cannot be read back\n");
833
+ return 2;
834
+ }
835
+ mkdirSync(path.join(root, RECORD_DIR), { recursive: true });
836
+ writeFileSync(path.join(root, snapshotPath(version)), `${JSON.stringify(snap, null, 4)}\n`);
837
+ writeFileSync(path.join(root, registerPath(version)), renderRegister(snap));
838
+ stdout.write(`release-eval: wrote ${snapshotPath(version)} and ${registerPath(version)}\n`);
839
+ const red = verifyRecord(snap, { version });
840
+ for (const line of red) stdout.write(`release-eval: ${line}\n`);
841
+ return red.length > 0 ? 1 : 0;
842
+ }
843
+
844
+ if (parsed.mode === "write") {
845
+ const target = parsed.version ?? version;
846
+ const read = readRecord(root, target);
847
+ if (read.couldNotRun !== undefined) {
848
+ stderr.write(`release-eval: ${read.couldNotRun}\n`);
849
+ return 2;
850
+ }
851
+ if (read.snap === null) {
852
+ stderr.write(`release-eval: there is no ${snapshotPath(target)} to render from\n`);
853
+ return 2;
854
+ }
855
+ const shape = verifyShape(read.snap);
856
+ if (shape.length > 0) {
857
+ for (const line of shape) stderr.write(`release-eval: ${line}\n`);
858
+ stderr.write("release-eval: refusing to render a register from a capture it could not read\n");
859
+ return 2;
860
+ }
861
+ writeFileSync(path.join(root, registerPath(target)), renderRegister(read.snap));
862
+ stdout.write(`release-eval: re-rendered ${registerPath(target)}\n`);
863
+ return 0;
864
+ }
865
+
866
+ // ---------------------------------------------------------------- `--tagged`, the release act
867
+ //
868
+ // **The one surface a check reaches at the moment a person publishes.** It reads the tag's own
869
+ // checkout and asks only its own question — does the tree being published carry a record for the
870
+ // version being published — because everything else about that tree was already graded on the pull
871
+ // request that made it.
872
+ if (parsed.mode === "tagged") {
873
+ // **The argument is the TAG's version, and reading it from `package.json` instead was a hole that
874
+ // made this check green in every reachable variant of the one case it exists for.** Its motivating
875
+ // scenario is a tag created from a tree whose `## Unreleased` accumulator was never renamed — and
876
+ // in that tree `package.json` still declares the PREVIOUS version, so a step that asked
877
+ // `--tagged $(package.json version)` asked about `0.1.2`, got *predates the clause*, and published.
878
+ // The tag is the only thing in that scenario that names what is being released.
879
+ const tagged = parsed.tagged.replace(/^v/, "");
880
+ let taggedGoverned;
881
+ try {
882
+ taggedGoverned = isGoverned(tagged);
883
+ } catch (e) {
884
+ stderr.write(`release-eval: ${e.message}\n`);
885
+ return 2;
886
+ }
887
+ // **A disagreement is a FINDING, not a note.** It is the scenario itself: the tag says one release
888
+ // and the payload declares another, so one of the two was never moved. Reporting it and continuing
889
+ // would publish the thing this check exists to stop.
890
+ if (tagged !== version) {
891
+ stdout.write(
892
+ `release-eval: the tag names \`${tagged}\` while this tree's package.json declares \`${version}\` — one of the ` +
893
+ "two was never moved, and a release whose payload disagrees with its tag is exactly the cut this check " +
894
+ "exists to refuse.\n",
895
+ );
896
+ return 1;
897
+ }
898
+ if (!taggedGoverned) {
899
+ stdout.write(
900
+ `release-eval: \`${tagged}\` predates \`${FIRST_GOVERNED_VERSION}\` — the clause binds *from ` +
901
+ "milestone 8*, so a republish of it is not asked for a record it never had.\n",
902
+ );
903
+ return 0;
904
+ }
905
+ const read = readRecord(root, tagged);
906
+ if (read.couldNotRun !== undefined) {
907
+ stderr.write(`release-eval: ${read.couldNotRun}\n`);
908
+ return 2;
909
+ }
910
+ if (read.snap === null) {
911
+ stdout.write(
912
+ `release-eval: the tree tagged \`${tagged}\` carries no ${snapshotPath(tagged)}. From ` +
913
+ "milestone 8 a release carries an eval result, and this is the only check that sees the tagged tree — " +
914
+ "the pull request's rail grades a tree, this grades the release.\n",
915
+ );
916
+ return 1;
917
+ }
918
+ const red = gradeOne(root, tagged, read.snap, "the tag");
919
+ for (const line of red) stdout.write(`release-eval: ${line}\n`);
920
+ if (red.length > 0) return 1;
921
+ stdout.write(`release-eval: the tree tagged \`${tagged}\` carries its own eval result, and it agrees with its capture.\n`);
922
+ return 0;
923
+ }
924
+
925
+ // ---------------------------------------------------------------- `--verify`, the recipe's mode
926
+ let released;
927
+ try {
928
+ released = changelogVersions(root);
929
+ } catch (e) {
930
+ stderr.write(`release-eval: ${e.message}\n`);
931
+ return 2;
932
+ }
933
+ const red = [];
934
+
935
+ // **Cut integrity, and it holds between cuts as well as at one.** The newest heading is the last
936
+ // release cut, and `package.json` is bumped in the same change that renames it — so a disagreement
937
+ // means one of the two moved alone.
938
+ if (released[0] !== version) {
939
+ red.push(
940
+ `CHANGELOG.md's newest release heading is \`${released[0]}\` where package.json declares \`${version}\` — one ` +
941
+ "of the two moved without the other, and a cut moves them together",
942
+ );
943
+ }
944
+
945
+ let governedReleases;
946
+ try {
947
+ governedReleases = released.filter((v) => isGoverned(v));
948
+ } catch (e) {
949
+ stderr.write(`release-eval: ${e.message}\n`);
950
+ return 2;
951
+ }
952
+
953
+ for (const v of governedReleases) {
954
+ const read = readRecord(root, v);
955
+ if (read.couldNotRun !== undefined) {
956
+ stderr.write(`release-eval: ${read.couldNotRun}\n`);
957
+ return 2;
958
+ }
959
+ if (read.snap === null) {
960
+ red.push(
961
+ `\`${v}\` is a release from milestone 8 onward and there is no ${snapshotPath(v)}. Run ` +
962
+ "`node cli/release-eval.mjs --capture` in the change that cuts it",
963
+ );
964
+ continue;
965
+ }
966
+ red.push(...gradeOne(root, v, read.snap, v === version ? "package.json" : "CHANGELOG.md"));
967
+ }
968
+
969
+ // **Everything ON DISK is graded, in both extensions, governed or not.** Three holes closed here,
970
+ // all found by a fresh context attacking the record layer rather than the code:
971
+ //
972
+ // * a record for a version the clause does NOT govern was ungraded entirely — `--capture` refuses
973
+ // to write one, so nothing could have produced it honestly, and a hand-written
974
+ // `evals/releases/0.1.2.json` full of garbage beside a register reading *"all 25 recipes green"*
975
+ // was green. A version this rail declines to demand a record for is not a version it permits an
976
+ // unexamined record for;
977
+ // * the sweep enumerated `.json` only, so a fabricated `<version>.md` with no capture beside it
978
+ // was invisible — and the register is the reader-facing half, the one place a release's eval
979
+ // result is written for a reader;
980
+ // * a record for a version `CHANGELOG.md` never cut sits in the tree looking exactly like evidence.
981
+ let present;
982
+ try {
983
+ present = existsSync(path.join(root, RECORD_DIR))
984
+ ? [...new Set(readdirSync(path.join(root, RECORD_DIR)).flatMap((f) => (f.endsWith(".json") ? [f.slice(0, -5)] : f.endsWith(".md") ? [f.slice(0, -3)] : [])))]
985
+ : [];
986
+ } catch (e) {
987
+ stderr.write(`release-eval: could not enumerate ${RECORD_DIR}: ${e.message}\n`);
988
+ return 2;
989
+ }
990
+ for (const v of present.sort()) {
991
+ // `README.md` and anything else not named for a version is prose, not a record.
992
+ if (!SEMVER.test(v)) continue;
993
+ if (!released.includes(v)) {
994
+ red.push(`${RECORD_DIR}/${v}.* records a release CHANGELOG.md never cut — a record for a release that does not exist reads as evidence and is not`);
995
+ continue;
996
+ }
997
+ if (!isGoverned(v)) {
998
+ red.push(
999
+ `${RECORD_DIR}/${v}.* exists for a release that predates \`${FIRST_GOVERNED_VERSION}\` — nothing here writes ` +
1000
+ "one, so it was written by hand, and an unexamined record reads as evidence exactly like a real one",
1001
+ );
1002
+ continue;
1003
+ }
1004
+ if (governedReleases.includes(v)) continue; // already graded above
1005
+ red.push(`${RECORD_DIR}/${v}.* is present but was not graded — this is a bug in the rail, not in the tree`);
1006
+ }
1007
+ // A register with no capture beside it: the reader-facing half standing alone.
1008
+ for (const v of governedReleases) {
1009
+ if (existsSync(path.join(root, registerPath(v))) && !existsSync(path.join(root, snapshotPath(v)))) {
1010
+ red.push(`${registerPath(v)} stands with no capture beside it — the register is what a reader reads, and nothing holds it to anything`);
1011
+ }
1012
+ }
1013
+
1014
+ if (red.length > 0) {
1015
+ for (const line of red) stdout.write(`release-eval: ${line}\n`);
1016
+ return 1;
1017
+ }
1018
+ stdout.write(
1019
+ governedReleases.length === 0
1020
+ ? `release-eval: no release from \`${FIRST_GOVERNED_VERSION}\` onward has been cut yet — the clause binds *from ` +
1021
+ `milestone 8* and the ${released.length} release(s) recorded predate it. CHANGELOG.md's newest heading agrees ` +
1022
+ "with package.json. Nothing else to check, and that is a state rather than a green over a record set.\n"
1023
+ : `release-eval: ${governedReleases.length} governed release(s) — ${governedReleases.join(", ")} — each carry an ` +
1024
+ "eval result whose register is byte-identical to what its capture renders, with no recorded red.\n",
1025
+ );
1026
+ stdout.write(
1027
+ "release-eval: this is the IN-TREE half. Whether a published release body cites its record is not reachable from " +
1028
+ "here — `tag-a-release` and `publish-a-release` are Gated. `--tagged` reaches the tagged tree at the release " +
1029
+ "act, from .github/workflows/publish-github-packages.yml; the rest is .portulan/gate-map.md's.\n",
1030
+ );
1031
+ return 0;
1032
+ }
1033
+
1034
+ /** Read one version's capture. `snap: null` is *no record*; `couldNotRun` is *a record nothing could read*. */
1035
+ function readRecord(root, version) {
1036
+ const abs = path.join(root, snapshotPath(version));
1037
+ if (!existsSync(abs)) return { snap: null };
1038
+ try {
1039
+ return { snap: JSON.parse(readFileSync(abs, "utf8")) };
1040
+ } catch (e) {
1041
+ return { couldNotRun: `${snapshotPath(version)} is not valid JSON: ${e.message}` };
1042
+ }
1043
+ }
1044
+
1045
+ /** One record's full verdict: its contents, and its register byte-compared. */
1046
+ function gradeOne(root, version, snap, keyedBy = "CHANGELOG.md") {
1047
+ const red = verifyRecord(snap, { version, keyedBy });
1048
+ if (red.length > 0) return red;
1049
+ let onDisk;
1050
+ try {
1051
+ onDisk = readFileSync(path.join(root, registerPath(version)), "utf8");
1052
+ } catch (e) {
1053
+ return [`${registerPath(version)} could not be read (${e.message}) — the capture has no register beside it`];
1054
+ }
1055
+ if (onDisk !== renderRegister(snap)) {
1056
+ return [
1057
+ `${registerPath(version)} is not what ${snapshotPath(version)} renders — the published document has drifted ` +
1058
+ `from its own capture. Re-render with \`node cli/release-eval.mjs --write --version ${version}\``,
1059
+ ];
1060
+ }
1061
+ return [];
1062
+ }
1063
+
1064
+ function isMain() {
1065
+ return process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href;
1066
+ }
1067
+
1068
+ if (isMain()) process.exit(run(process.argv.slice(2)));