@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.
- package/README.md +2 -2
- package/cli/README.md +69 -2
- package/cli/compile.mjs +555 -37
- package/cli/doctor.mjs +5 -1
- package/cli/drills.mjs +1541 -0
- package/cli/fuzz-shell.mjs +880 -0
- package/cli/gate.mjs +3 -2
- package/cli/goldens.mjs +469 -0
- package/cli/init.mjs +4 -1
- package/cli/mutants.mjs +925 -0
- package/cli/pack-identity.mjs +9 -2
- package/cli/pack-version.mjs +7 -0
- package/cli/recipe-set.mjs +6 -0
- package/cli/release-eval.mjs +1068 -0
- package/cli/review-meter.mjs +741 -0
- package/cli/skill-goldens.mjs +590 -0
- package/cli/stop-gate.mjs +107 -4
- package/cli/telemetry.mjs +1047 -0
- package/cli/version-carriers.mjs +13 -0
- package/core/templates/gate-policy.md +27 -8
- package/evals/README.md +910 -0
- package/evals/releases/0.1.3.json +134 -0
- package/evals/releases/0.1.3.md +64 -0
- package/evals/releases/README.md +32 -0
- package/package.json +7 -2
- package/packs/rituals/checkpoints/README.md +8 -1
- package/packs/rituals/checkpoints/pack.json +1 -1
- package/packs/rituals/checkpoints/skills/pre-commit/SKILL.md +32 -2
- package/spec/slots.md +26 -2
|
@@ -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)));
|