@sleepy_panda_srl/portulan 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +7 -6
  2. package/cli/README.md +142 -291
  3. package/cli/advisory.mjs +354 -0
  4. package/cli/comments.mjs +932 -0
  5. package/cli/compile.mjs +1443 -1210
  6. package/cli/context.mjs +883 -0
  7. package/cli/control-chars.mjs +12 -301
  8. package/cli/discover.mjs +17 -574
  9. package/cli/doctor.mjs +225 -1071
  10. package/cli/drills.mjs +1107 -0
  11. package/cli/feedback.mjs +16 -237
  12. package/cli/finish.mjs +437 -0
  13. package/cli/form.mjs +644 -0
  14. package/cli/fuzz-shell.mjs +595 -0
  15. package/cli/gate.mjs +7 -198
  16. package/cli/goldens.mjs +311 -0
  17. package/cli/index.mjs +305 -655
  18. package/cli/init.mjs +244 -564
  19. package/cli/inside.mjs +3 -28
  20. package/cli/instructions.mjs +829 -0
  21. package/cli/ledger.mjs +778 -0
  22. package/cli/librarian.mjs +135 -599
  23. package/cli/manifest.mjs +2 -39
  24. package/cli/mutants.mjs +764 -0
  25. package/cli/new.mjs +12 -243
  26. package/cli/pack-identity.mjs +6 -68
  27. package/cli/pack-version.mjs +13 -248
  28. package/cli/plugin-lint.mjs +42 -658
  29. package/cli/portulan.mjs +5 -124
  30. package/cli/recipe-set.mjs +16 -240
  31. package/cli/release-eval.mjs +748 -0
  32. package/cli/review-meter.mjs +500 -0
  33. package/cli/rule-carriers.mjs +12 -175
  34. package/cli/sessions.mjs +82 -0
  35. package/cli/skill-goldens.mjs +424 -0
  36. package/cli/skills-set.mjs +12 -414
  37. package/cli/stop-gate.mjs +126 -454
  38. package/cli/symbols.mjs +1132 -0
  39. package/cli/telemetry.mjs +582 -0
  40. package/cli/upgrade.mjs +171 -314
  41. package/cli/vendor.mjs +146 -476
  42. package/cli/version-carriers.mjs +9 -74
  43. package/core/README.md +1 -1
  44. package/core/engine.md +3 -9
  45. package/core/operating/autonomy.md +1 -1
  46. package/core/operating/context.md +183 -0
  47. package/core/operating/evolution.md +3 -3
  48. package/core/operating/loop.md +13 -20
  49. package/core/operating/memory.md +12 -3
  50. package/core/operating/sessions.md +100 -0
  51. package/core/personas/librarian.md +2 -2
  52. package/core/skills/consolidate/SKILL.md +4 -2
  53. package/core/templates/gate-policy.md +27 -8
  54. package/core/templates/handoff.md +28 -15
  55. package/core/templates/memory-entry.md +1 -0
  56. package/evals/README.md +320 -0
  57. package/evals/releases/0.1.3.json +134 -0
  58. package/evals/releases/0.1.3.md +64 -0
  59. package/evals/releases/0.2.0.json +146 -0
  60. package/evals/releases/0.2.0.md +67 -0
  61. package/evals/releases/README.md +32 -0
  62. package/package.json +9 -2
  63. package/packs/rituals/checkpoints/README.md +9 -2
  64. package/packs/rituals/checkpoints/pack.json +1 -1
  65. package/packs/rituals/checkpoints/skills/pre-commit/SKILL.md +32 -2
  66. package/packs/tools/github/verify/actions-pinned.sh +10 -55
  67. package/spec/README.md +112 -6
  68. package/spec/migrations/0001-repository-declares-its-tree.mjs +1 -26
  69. package/spec/migrations/0002-bundle-fallback-path.mjs +13 -74
  70. package/spec/migrations/0003-handoff-index-not-kept.mjs +66 -0
  71. package/spec/migrations/0004-changelog-fragments.mjs +53 -0
  72. package/spec/migrations/0005-session-log-retired.mjs +69 -0
  73. package/spec/migrations/0006-boot-card.mjs +77 -0
  74. package/spec/migrations/0007-guidance-compiled.mjs +31 -0
  75. package/spec/migrations/0008-card-reading.mjs +55 -0
  76. package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -0
  77. package/spec/migrations/0010-comment-rail.mjs +57 -0
  78. package/spec/migrations/README.md +45 -10
  79. package/spec/pack.schema.json +1 -1
  80. package/spec/slots.md +359 -23
  81. package/spec/workspace.schema.json +145 -4
@@ -0,0 +1,748 @@
1
+ #!/usr/bin/env node
2
+ // Portulan — the eval result a release carries.
3
+ //
4
+ // Exit 0 green · 1 a finding · 2 could not run.
5
+
6
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
7
+ import { execFileSync } from "node:child_process";
8
+ import path from "node:path";
9
+ import { pathToFileURL } from "node:url";
10
+
11
+ import { recipeSet, resolverFor } from "./recipe-set.mjs";
12
+
13
+ /** A failed precondition: always exit 2, never a finding about a release. */
14
+ export class CouldNotRun extends Error {}
15
+
16
+ export const RECORD_DIR = "evals/releases";
17
+ export const snapshotPath = (version) => `${RECORD_DIR}/${version}.json`;
18
+ export const registerPath = (version) => `${RECORD_DIR}/${version}.md`;
19
+
20
+ export const FIRST_GOVERNED_VERSION = "0.1.3";
21
+
22
+ // The payload does not carry `evals/ab/`, so from this version a register cites it at the release's tag.
23
+ // Earlier registers keep rendering the relative link npm froze into their tarballs.
24
+ export const PINNED_FROM = "0.2.0";
25
+ export const REPOSITORY = "https://github.com/sleepy-panda-srl/portulan";
26
+
27
+ export const SELF = "release-eval";
28
+
29
+ const SEMVER = /^(\d+)\.(\d+)\.(\d+)$/;
30
+
31
+ /** Throws `CouldNotRun` on anything but `X.Y.Z` rather than guess where a prerelease falls. */
32
+ export function compareVersions(a, b) {
33
+ const pa = SEMVER.exec(a);
34
+ const pb = SEMVER.exec(b);
35
+ if (!pa) throw new CouldNotRun(`\`${a}\` is not an \`X.Y.Z\` version — this rail does not order prereleases or build tags`);
36
+ if (!pb) throw new CouldNotRun(`\`${b}\` is not an \`X.Y.Z\` version — this rail does not order prereleases or build tags`);
37
+ for (let i = 1; i <= 3; i += 1) {
38
+ const d = Number(pa[i]) - Number(pb[i]);
39
+ if (d !== 0) return d < 0 ? -1 : 1;
40
+ }
41
+ return 0;
42
+ }
43
+
44
+ export function isGoverned(version) {
45
+ return compareVersions(version, FIRST_GOVERNED_VERSION) >= 0;
46
+ }
47
+
48
+ /** Read from the worktree, not the index, so an unstaged cut is graded too. */
49
+ export function declaredVersion(root) {
50
+ let raw;
51
+ try {
52
+ raw = readFileSync(path.join(root, "package.json"), "utf8");
53
+ } catch (e) {
54
+ throw new CouldNotRun(`could not read package.json: ${e.message}`);
55
+ }
56
+ let v;
57
+ try {
58
+ v = JSON.parse(raw).version;
59
+ } catch (e) {
60
+ throw new CouldNotRun(`package.json is not valid JSON: ${e.message}`);
61
+ }
62
+ if (typeof v !== "string" || !v.trim()) throw new CouldNotRun("package.json declares no version string");
63
+ return v.trim();
64
+ }
65
+
66
+ /** Every `## X.Y.Z` heading in file order, which is newest first. */
67
+ export function changelogVersions(root) {
68
+ let text;
69
+ try {
70
+ text = readFileSync(path.join(root, "CHANGELOG.md"), "utf8");
71
+ } catch (e) {
72
+ throw new CouldNotRun(`could not read CHANGELOG.md: ${e.message}`);
73
+ }
74
+ const out = [];
75
+ let fenced = false;
76
+ for (const line of text.split("\n")) {
77
+ // A fence may hold a worked example of a cut, and its heading is no release.
78
+ if (/^(```|~~~)/.test(line)) {
79
+ fenced = !fenced;
80
+ continue;
81
+ }
82
+ if (fenced) continue;
83
+ const m = /^## (\d+\.\d+\.\d+)(?:\s|$)/.exec(line);
84
+ if (m) {
85
+ out.push(m[1]);
86
+ continue;
87
+ }
88
+ if (/^## \d/.test(line)) {
89
+ throw new CouldNotRun(
90
+ `CHANGELOG.md carries the release heading ${JSON.stringify(line.trim())}, which is not \`## X.Y.Z\` — this rail ` +
91
+ "does not order prereleases or build tags, and skipping one would decide by omission whether it is governed",
92
+ );
93
+ }
94
+ }
95
+ 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");
96
+ return out;
97
+ }
98
+
99
+ export function sourceOf(root) {
100
+ let commit;
101
+ try {
102
+ commit = execFileSync("git", ["-C", root, "rev-parse", "HEAD"], { encoding: "utf8" }).trim();
103
+ } catch (e) {
104
+ throw new CouldNotRun(`could not read HEAD: ${e.message}`);
105
+ }
106
+ let clean;
107
+ try {
108
+ clean = execFileSync("git", ["-C", root, "status", "--porcelain"], { encoding: "utf8" }).trim() === "";
109
+ } catch (e) {
110
+ throw new CouldNotRun(`could not read the working tree's state: ${e.message}`);
111
+ }
112
+ return { commit, clean };
113
+ }
114
+
115
+ /** A named pack root wins outright over discovery, so the set depends on the tree, never on the machine. */
116
+ export function yieldedRecipes({ root, workspace = ".portulan", packRoot = "packs" }) {
117
+ const workspaceDir = path.join(root, workspace);
118
+ let manifest;
119
+ try {
120
+ manifest = JSON.parse(readFileSync(path.join(workspaceDir, "workspace.json"), "utf8"));
121
+ } catch (e) {
122
+ throw new CouldNotRun(`could not read the workspace manifest: ${e.message}`);
123
+ }
124
+ let resolve;
125
+ try {
126
+ resolve = resolverFor({ workspaceDir, manifest, repoRoot: root, named: [path.join(root, packRoot)] });
127
+ } catch (e) {
128
+ throw new CouldNotRun(`could not resolve this workspace's pack roots: ${e.message}`);
129
+ }
130
+ const set = recipeSet(manifest, { resolve });
131
+ if (!set.ok) throw new CouldNotRun(`the workspace yields no readable recipe set: ${set.refusal ?? set.couldNotRun ?? "no reason given"}`);
132
+ return set.recipes.map((r) => ({ id: r.id, run: r.run }));
133
+ }
134
+
135
+ /** The A/B baseline's identity alone, since its figures have their own carrier; `null` when none is committed. */
136
+ export function abBaselineIdentity(root) {
137
+ const snapshot = "evals/ab/baseline.json";
138
+ const abs = path.join(root, snapshot);
139
+ if (!existsSync(abs)) return null;
140
+ let snap;
141
+ try {
142
+ snap = JSON.parse(readFileSync(abs, "utf8"));
143
+ } catch (e) {
144
+ throw new CouldNotRun(`the A/B baseline at ${snapshot} is not valid JSON: ${e.message}`);
145
+ }
146
+ // No fallbacks: an absent field stays `undefined` so that `verifyShape` refuses it.
147
+ return {
148
+ snapshot,
149
+ register: "evals/ab/baseline.md",
150
+ captured: snap?.captured,
151
+ commit: snap?.source?.commit,
152
+ clean: snap?.source?.clean,
153
+ };
154
+ }
155
+
156
+ export function limitationsFor(snap) {
157
+ const out = [
158
+ "**A green says this record agrees with its own capture — never that the release is good.** Every " +
159
+ "line below is what the recipes returned at one commit, and a recipe's own green establishes only what " +
160
+ "that recipe's documentation says it establishes.",
161
+ `**The recipes were not run at the tag.** They ran at \`${snap?.source?.commit}\`, and this record is ` +
162
+ "committed *in* the cut change — so the tagged tree is this commit plus whatever that change still adds " +
163
+ "after the capture, the record itself at minimum. A record cannot be captured at a commit that does not " +
164
+ "exist yet, and printing the one it was captured at is the only honest form.",
165
+ `**\`${SELF}\` is excluded from the rows above**, because a capture cannot be accurate about the record it is inside. ` +
166
+ "Its verdict for this release is the rail's own run on the pull request that carries this file.",
167
+ "**This record measures the Portulan repository's own build**, at the commit named above — never the workspace, " +
168
+ "project or package this release is installed into. Nothing here has looked at your tree.",
169
+ ];
170
+ out.push(
171
+ snap?.source?.clean === true
172
+ ? "**The tree was clean at capture**, so the commit named above is what was measured."
173
+ : "**The tree was NOT clean at capture** — the diff of the cut change is the only record of what was uncommitted, " +
174
+ "which is weaker than a sha and is printed rather than hidden.",
175
+ );
176
+ out.push(
177
+ snap?.abBaseline === null || snap?.abBaseline === undefined
178
+ ? "**No A/B baseline is committed in this tree**, so this release ships against none. That is a state, not a hole."
179
+ : "**The A/B baseline's figures are NOT restated here.** This record names which baseline the release ships " +
180
+ "against; the figures and everything that may not be concluded from them live in that register alone.",
181
+ );
182
+ return out;
183
+ }
184
+
185
+ /** No fallbacks, here or in `limitationsFor`: a missing field must render as a hole that `verifyShape` refuses. */
186
+ export function renderRegister(snap) {
187
+ const L = [];
188
+ L.push(`# Eval result — Portulan ${snap.version}`);
189
+ L.push("");
190
+ L.push(
191
+ "> **Generated. Do not edit.** Rendered from " +
192
+ `[\`${snapshotPath(snap.version)}\`](${path.basename(snapshotPath(snap.version))}) by ` +
193
+ "[`cli/release-eval.mjs`](../../cli/release-eval.mjs) and byte-compared by the `release-eval` verify recipe. " +
194
+ "This is the eval result milestone 8 requires a release to carry — `docs/plan.md`, Protocol → Versioning.",
195
+ );
196
+ L.push("");
197
+ L.push("## What this release was measured at");
198
+ L.push("");
199
+ L.push("| | |");
200
+ L.push("|---|---|");
201
+ L.push(`| Version | \`${snap.version}\` |`);
202
+ L.push(`| Captured | ${snap.captured} |`);
203
+ L.push(`| Commit | \`${snap.source.commit}\` |`);
204
+ L.push(`| Tree at capture | ${snap.source.clean ? "clean" : "**not clean**"} |`);
205
+ L.push(`| Node | \`${snap.host.node}\` |`);
206
+ L.push(`| Platform | \`${snap.host.platform}\` |`);
207
+ L.push("");
208
+ L.push("## The rails, and what each returned");
209
+ L.push("");
210
+ L.push(
211
+ `${snap.recipes.length} of the ${snap.recipes.length + snap.excluded.length} recipes this workspace yielded at capture. ` +
212
+ "The set is derived from [`cli/recipe-set.mjs`](../../cli/recipe-set.mjs) and never listed by hand.",
213
+ );
214
+ L.push("");
215
+ L.push("| Recipe | Verdict |");
216
+ L.push("|---|---|");
217
+ for (const r of snap.recipes) {
218
+ L.push(`| \`${r.id}\` | ${r.exit === 0 ? "green" : `**exit ${r.exit}**`} |`);
219
+ }
220
+ L.push("");
221
+ for (const e of snap.excluded) {
222
+ L.push(`**\`${e.id}\` is excluded**, and the reason is printed rather than the row silently dropped: ${e.why}`);
223
+ L.push("");
224
+ }
225
+ L.push("## The A/B baseline this release ships against");
226
+ L.push("");
227
+ if (snap.abBaseline === null) {
228
+ L.push("None is committed in this tree.");
229
+ } else {
230
+ const pinned = SEMVER.test(String(snap.version)) && compareVersions(snap.version, PINNED_FROM) >= 0;
231
+ const href = pinned ? `${REPOSITORY}/blob/v${snap.version}/${snap.abBaseline.register}` : `../../${snap.abBaseline.register}`;
232
+ L.push(
233
+ `[\`${snap.abBaseline.register}\`](${href}), rendered from ` +
234
+ `\`${snap.abBaseline.snapshot}\` — captured ${snap.abBaseline.captured} at ` +
235
+ `\`${snap.abBaseline.commit}\`, over a tree that was ` +
236
+ `${snap.abBaseline.clean === true ? "clean" : "**not clean**"}.`,
237
+ );
238
+ L.push("");
239
+ L.push("**Its figures are not repeated here.** They have one carrier and this is not it.");
240
+ }
241
+ L.push("");
242
+ L.push("## What this record does not establish");
243
+ L.push("");
244
+ for (const line of limitationsFor(snap)) L.push(`- ${line}`);
245
+ L.push("");
246
+ return L.join("\n");
247
+ }
248
+
249
+ const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
250
+
251
+ /** A full SHA-1 or SHA-256 name: an abbreviated one can become ambiguous. */
252
+ const OBJECT_NAME = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
253
+
254
+ function leafPaths(value, prefix = []) {
255
+ if (value === null || typeof value !== "object") return [[prefix, value]];
256
+ return Object.entries(value).flatMap(([k, v]) => leafPaths(v, [...prefix, k]));
257
+ }
258
+
259
+ /** Every reason this capture cannot be rendered truthfully; empty when it can. */
260
+ export function verifyShape(snap) {
261
+ const red = [];
262
+ if (snap?.portulan?.releaseEval !== "1") red.push("the record does not declare `portulan.releaseEval: \"1\"` — this is not a release eval capture");
263
+ if (typeof snap?.version !== "string" || !SEMVER.test(snap.version)) {
264
+ red.push(`the record's \`version\` is ${JSON.stringify(snap?.version)}, which is not an \`X.Y.Z\` release`);
265
+ }
266
+ if (typeof snap?.captured !== "string" || snap.captured === "") red.push("the record carries no `captured` date");
267
+ if (!Array.isArray(snap?.recipes)) red.push("the record's `recipes` is not an array — the verdicts cannot be read");
268
+ else {
269
+ 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");
270
+ for (const r of snap.recipes) {
271
+ if (typeof r?.id !== "string" || r.id === "") red.push("a recipe row carries no id");
272
+ 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`);
273
+ }
274
+ }
275
+ 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");
276
+ else {
277
+ for (const e of snap.excluded) {
278
+ if (typeof e?.id !== "string" || e.id === "") red.push("an exclusion carries no id");
279
+ 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`);
280
+ }
281
+ }
282
+ for (const field of ["source", "host"]) {
283
+ if (snap?.[field] === undefined || snap[field] === null) red.push(`the record has no \`${field}\`, which the register prints among the release's conditions`);
284
+ }
285
+ if (snap?.host !== undefined && snap?.host !== null) {
286
+ for (const k of ["node", "platform"]) {
287
+ if (typeof snap.host[k] !== "string") {
288
+ 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`);
289
+ }
290
+ }
291
+ }
292
+ if (snap?.source !== undefined && snap?.source !== null && typeof snap.source.clean !== "boolean") {
293
+ 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");
294
+ }
295
+ if (snap?.abBaseline !== null && snap?.abBaseline !== undefined && typeof snap.abBaseline.clean !== "boolean") {
296
+ 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");
297
+ }
298
+ if (snap?.abBaseline === undefined) {
299
+ 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");
300
+ }
301
+ const commitFields = [
302
+ ["source.commit", snap?.source, "the record's central claim is that it was measured at a named commit"],
303
+ ["abBaseline.commit", snap?.abBaseline, "a baseline cited by a name nothing can resolve is cited by nothing"],
304
+ ];
305
+ for (const [label, holder, why] of commitFields) {
306
+ if (holder === undefined || holder === null) continue;
307
+ const value = holder.commit;
308
+ if (typeof value !== "string" || !OBJECT_NAME.test(value)) {
309
+ red.push(`the record's \`${label}\` is ${JSON.stringify(value)}, which is not a full object name — ${why}`);
310
+ }
311
+ }
312
+ // Not trimmed: the value is printed as is, so it is checked as is.
313
+ if (typeof snap?.captured === "string" && !ISO_DATE.test(snap.captured)) {
314
+ red.push(`the record's \`captured\` is ${JSON.stringify(snap.captured)}, which is not a \`YYYY-MM-DD\` date — it is printed as one`);
315
+ }
316
+
317
+ if (snap !== null && typeof snap === "object") {
318
+ const skip = snap.abBaseline === null ? "abBaseline" : null;
319
+ for (const [pathParts, leaf] of leafPaths(snap)) {
320
+ const dotted = pathParts.join(".");
321
+ if (skip !== null && pathParts[0] === skip) continue;
322
+ if (leaf === null) {
323
+ red.push(`the record's \`${dotted}\` is \`null\` — it renders as the text \`null\` in a document that otherwise reads as measured`);
324
+ } else if (typeof leaf === "string" && leaf.trim() === "") {
325
+ red.push(`the record's \`${dotted}\` is blank — it renders as nothing at all, which is a hole no probe can see`);
326
+ }
327
+ }
328
+ }
329
+
330
+ if (red.length > 0) return red;
331
+
332
+ let rendered;
333
+ try {
334
+ rendered = renderRegister(snap);
335
+ } catch (cause) {
336
+ red.push(`the register cannot be rendered from this record — ${cause.message}`);
337
+ return red;
338
+ }
339
+ for (const hole of ["undefined", "NaN"]) {
340
+ if (rendered.includes(hole)) {
341
+ 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`);
342
+ }
343
+ }
344
+ return red;
345
+ }
346
+
347
+ /** Checks a record against its own capture, never the live recipe set: a recipe added later does not make it wrong. */
348
+ export function verifyRecord(snap, { version, keyedBy = "the file it is named for" }) {
349
+ const red = verifyShape(snap);
350
+ if (red.length > 0) return red;
351
+ if (snap.version !== version) {
352
+ red.push(`the record declares version \`${snap.version}\` where ${keyedBy} says \`${version}\` — a record keyed to another release cannot answer for this one`);
353
+ }
354
+ for (const r of snap.recipes) {
355
+ if (r.exit !== 0) {
356
+ 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`);
357
+ }
358
+ }
359
+ const excludedIds = snap.excluded.map((e) => e.id).sort();
360
+ if (excludedIds.length !== 1 || excludedIds[0] !== SELF) {
361
+ red.push(
362
+ `the record excludes ${excludedIds.length === 0 ? "nothing" : `\`${excludedIds.join("`, `")}\``} where the only ` +
363
+ `admissible exclusion is \`${SELF}\` — anything else is a verdict moved out of the graded set`,
364
+ );
365
+ }
366
+ return red;
367
+ }
368
+
369
+ function measure(root, recipes, { stdout }) {
370
+ const rows = [];
371
+ for (const r of recipes) {
372
+ if (r.id === SELF) continue;
373
+ stdout.write(`release-eval: running ${r.id}\n`);
374
+ let exit = 0;
375
+ try {
376
+ execFileSync("bash", ["-c", r.run], { cwd: root, stdio: "ignore" });
377
+ } catch (e) {
378
+ exit = typeof e.status === "number" ? e.status : 2;
379
+ }
380
+ rows.push({ id: r.id, exit });
381
+ }
382
+ return rows;
383
+ }
384
+
385
+ const USAGE = `portulan-release-eval — the eval result a release carries.
386
+
387
+ node cli/release-eval.mjs --capture [--repo-root <dir>] [--date <YYYY-MM-DD>]
388
+ node cli/release-eval.mjs --write [--repo-root <dir>] [--version <X.Y.Z>]
389
+ node cli/release-eval.mjs --verify [--repo-root <dir>]
390
+ node cli/release-eval.mjs --tagged <tag> [--repo-root <dir>]
391
+
392
+ --capture RUN every recipe this workspace yields and write ${RECORD_DIR}/<version>.json and .md
393
+ for the version package.json declares. Spends real time — it is the whole recipe set.
394
+ --write re-render a register from its committed capture. Runs no recipe. \`--version\` names
395
+ which record, and belongs to this mode alone: it is refused elsewhere rather than
396
+ ignored, because it once shared a slot with the tag and silently overrode it.
397
+ --verify the recipe's mode. EVERY governed release CHANGELOG.md records has a record; each
398
+ record's shape; no recorded red; each register byte-compared through this module's own
399
+ renderer; no record for a release that was never cut; and the newest heading agrees
400
+ with package.json.
401
+ --tagged the release act's mode, for a checkout OF THE TAG: does this tree carry a record for
402
+ the version being published, and does the payload declare the version the tag names.
403
+ Takes the TAG (\`v0.1.3\` or \`0.1.3\`). \`.github/workflows/publish-github-packages.yml\`
404
+ runs it.
405
+
406
+ Every governed release stays under the rail permanently, not only the newest: a rail that graded one
407
+ record at a time would let an older one be deleted in silence. Releases before ${FIRST_GOVERNED_VERSION}
408
+ predate the clause and are not graded.
409
+
410
+ Exit 0 green · 1 a finding · 2 could not run.`;
411
+
412
+ const MODES = ["--capture", "--write", "--verify", "--tagged"];
413
+
414
+ function parse(argv) {
415
+ const out = { mode: null, root: ".", date: null, version: null, tagged: null };
416
+ for (let i = 0; i < argv.length; i += 1) {
417
+ const a = argv[i];
418
+ const need = (what) => {
419
+ const v = argv[i + 1];
420
+ if (v === undefined || v.startsWith("--")) throw new CouldNotRun(`\`${a}\` needs ${what}`);
421
+ i += 1;
422
+ return v;
423
+ };
424
+ if (MODES.includes(a)) {
425
+ if (out.mode !== null) throw new CouldNotRun(`pass exactly one of ${MODES.join(", ")}`);
426
+ out.mode = a.slice(2);
427
+ if (a === "--tagged") {
428
+ // Validated here, before the tag reaches a record path.
429
+ const t = need("the tag being published, for example `v0.1.3`");
430
+ if (!SEMVER.test(t.replace(/^v/, ""))) throw new CouldNotRun(`\`--tagged\` takes a tag naming an \`X.Y.Z\` version, not \`${t}\``);
431
+ out.tagged = t;
432
+ }
433
+ continue;
434
+ }
435
+ switch (a) {
436
+ case "--repo-root":
437
+ out.root = need("a directory");
438
+ break;
439
+ case "--date": {
440
+ const v = need("a YYYY-MM-DD date");
441
+ if (!ISO_DATE.test(v)) throw new CouldNotRun(`\`--date\` takes a \`YYYY-MM-DD\` date, not \`${v}\``);
442
+ out.date = v;
443
+ break;
444
+ }
445
+ case "--version": {
446
+ // Validated here because it becomes a path segment, and `X.Y.Z` admits no `/` or `..`.
447
+ const v = need("an `X.Y.Z` version");
448
+ 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`);
449
+ out.version = v;
450
+ break;
451
+ }
452
+ case "--help":
453
+ case "-h":
454
+ out.mode = "help";
455
+ break;
456
+ default:
457
+ throw new CouldNotRun(`unknown argument \`${a}\``);
458
+ }
459
+ }
460
+ if (out.mode === null) throw new CouldNotRun(`pass one of ${MODES.join(", ")}`);
461
+ if (out.version !== null && out.mode !== "write") {
462
+ throw new CouldNotRun("`--version` names which committed record `--write` re-renders, and means nothing in any other mode — refusing rather than ignoring it");
463
+ }
464
+ return out;
465
+ }
466
+
467
+ export function run(argv = [], { stdout = process.stdout, stderr = process.stderr } = {}) {
468
+ let parsed;
469
+ try {
470
+ parsed = parse(argv);
471
+ } catch (e) {
472
+ stderr.write(`release-eval: ${e.message}\n\n${USAGE}\n`);
473
+ return 2;
474
+ }
475
+ if (parsed.mode === "help") {
476
+ stdout.write(`${USAGE}\n`);
477
+ return 0;
478
+ }
479
+ const root = path.resolve(parsed.root);
480
+
481
+ let version;
482
+ try {
483
+ version = declaredVersion(root);
484
+ } catch (e) {
485
+ stderr.write(`release-eval: ${e.message}\n`);
486
+ return 2;
487
+ }
488
+
489
+ let governed;
490
+ try {
491
+ governed = isGoverned(version);
492
+ } catch (e) {
493
+ stderr.write(`release-eval: ${e.message}\n`);
494
+ return 2;
495
+ }
496
+
497
+ if (parsed.mode === "capture") {
498
+ // Before any git or workspace read, so this refusal is reached outside a repository too.
499
+ if (!governed) {
500
+ stderr.write(
501
+ `release-eval: package.json declares \`${version}\`, which predates \`${FIRST_GOVERNED_VERSION}\` — ` +
502
+ "the clause binds *from milestone 8*, and capturing a record for a release it does not govern would " +
503
+ "manufacture history\n",
504
+ );
505
+ return 2;
506
+ }
507
+ let source;
508
+ let recipes;
509
+ let ab;
510
+ try {
511
+ source = sourceOf(root);
512
+ recipes = yieldedRecipes({ root });
513
+ ab = abBaselineIdentity(root);
514
+ } catch (e) {
515
+ stderr.write(`release-eval: ${e.message}\n`);
516
+ return 2;
517
+ }
518
+ // Never the clock: the record is byte-compared, so its date must be reproducible.
519
+ const captured = parsed.date ?? execFileSync("git", ["-C", root, "log", "-1", "--format=%cs"], { encoding: "utf8" }).trim();
520
+ const rows = measure(root, recipes, { stdout });
521
+ const snap = {
522
+ portulan: { releaseEval: "1" },
523
+ version,
524
+ captured,
525
+ source,
526
+ host: { node: process.version, platform: process.platform },
527
+ recipes: rows,
528
+ excluded: [
529
+ {
530
+ id: SELF,
531
+ why:
532
+ "a capture cannot be accurate about the record it is inside — captured before the record is written it " +
533
+ "is red for the record's absence, and captured after it is stale the moment the register is rendered. " +
534
+ "Its verdict for this release is the rail's own run on the pull request carrying this file.",
535
+ },
536
+ ],
537
+ abBaseline: ab,
538
+ };
539
+ const shape = verifyShape(snap);
540
+ if (shape.length > 0) {
541
+ for (const line of shape) stderr.write(`release-eval: ${line}\n`);
542
+ stderr.write("release-eval: refusing to write a record that cannot be read back\n");
543
+ return 2;
544
+ }
545
+ mkdirSync(path.join(root, RECORD_DIR), { recursive: true });
546
+ writeFileSync(path.join(root, snapshotPath(version)), `${JSON.stringify(snap, null, 4)}\n`);
547
+ writeFileSync(path.join(root, registerPath(version)), renderRegister(snap));
548
+ stdout.write(`release-eval: wrote ${snapshotPath(version)} and ${registerPath(version)}\n`);
549
+ const red = verifyRecord(snap, { version });
550
+ for (const line of red) stdout.write(`release-eval: ${line}\n`);
551
+ return red.length > 0 ? 1 : 0;
552
+ }
553
+
554
+ if (parsed.mode === "write") {
555
+ const target = parsed.version ?? version;
556
+ const read = readRecord(root, target);
557
+ if (read.couldNotRun !== undefined) {
558
+ stderr.write(`release-eval: ${read.couldNotRun}\n`);
559
+ return 2;
560
+ }
561
+ if (read.snap === null) {
562
+ stderr.write(`release-eval: there is no ${snapshotPath(target)} to render from\n`);
563
+ return 2;
564
+ }
565
+ const shape = verifyShape(read.snap);
566
+ if (shape.length > 0) {
567
+ for (const line of shape) stderr.write(`release-eval: ${line}\n`);
568
+ stderr.write("release-eval: refusing to render a register from a capture it could not read\n");
569
+ return 2;
570
+ }
571
+ writeFileSync(path.join(root, registerPath(target)), renderRegister(read.snap));
572
+ stdout.write(`release-eval: re-rendered ${registerPath(target)}\n`);
573
+ return 0;
574
+ }
575
+
576
+ // ---------------------------------------------------------------- `--tagged`, the release act
577
+ if (parsed.mode === "tagged") {
578
+ // The tag's version, never package.json's: a tag cut from an un-bumped tree still names what it releases.
579
+ const tagged = parsed.tagged.replace(/^v/, "");
580
+ let taggedGoverned;
581
+ try {
582
+ taggedGoverned = isGoverned(tagged);
583
+ } catch (e) {
584
+ stderr.write(`release-eval: ${e.message}\n`);
585
+ return 2;
586
+ }
587
+ if (tagged !== version) {
588
+ stdout.write(
589
+ `release-eval: the tag names \`${tagged}\` while this tree's package.json declares \`${version}\` — one of the ` +
590
+ "two was never moved, and a release whose payload disagrees with its tag is exactly the cut this check " +
591
+ "exists to refuse.\n",
592
+ );
593
+ return 1;
594
+ }
595
+ if (!taggedGoverned) {
596
+ stdout.write(
597
+ `release-eval: \`${tagged}\` predates \`${FIRST_GOVERNED_VERSION}\` — the clause binds *from ` +
598
+ "milestone 8*, so a republish of it is not asked for a record it never had.\n",
599
+ );
600
+ return 0;
601
+ }
602
+ const read = readRecord(root, tagged);
603
+ if (read.couldNotRun !== undefined) {
604
+ stderr.write(`release-eval: ${read.couldNotRun}\n`);
605
+ return 2;
606
+ }
607
+ if (read.snap === null) {
608
+ stdout.write(
609
+ `release-eval: the tree tagged \`${tagged}\` carries no ${snapshotPath(tagged)}. From ` +
610
+ "milestone 8 a release carries an eval result, and this is the only check that sees the tagged tree — " +
611
+ "the pull request's rail grades a tree, this grades the release.\n",
612
+ );
613
+ return 1;
614
+ }
615
+ const red = gradeOne(root, tagged, read.snap, "the tag");
616
+ for (const line of red) stdout.write(`release-eval: ${line}\n`);
617
+ if (red.length > 0) return 1;
618
+ stdout.write(`release-eval: the tree tagged \`${tagged}\` carries its own eval result, and it agrees with its capture.\n`);
619
+ return 0;
620
+ }
621
+
622
+ // ---------------------------------------------------------------- `--verify`, the recipe's mode
623
+ let released;
624
+ try {
625
+ released = changelogVersions(root);
626
+ } catch (e) {
627
+ stderr.write(`release-eval: ${e.message}\n`);
628
+ return 2;
629
+ }
630
+ const red = [];
631
+
632
+ if (released[0] !== version) {
633
+ red.push(
634
+ `CHANGELOG.md's newest release heading is \`${released[0]}\` where package.json declares \`${version}\` — one ` +
635
+ "of the two moved without the other, and a cut moves them together",
636
+ );
637
+ }
638
+
639
+ let governedReleases;
640
+ try {
641
+ governedReleases = released.filter((v) => isGoverned(v));
642
+ } catch (e) {
643
+ stderr.write(`release-eval: ${e.message}\n`);
644
+ return 2;
645
+ }
646
+
647
+ for (const v of governedReleases) {
648
+ const read = readRecord(root, v);
649
+ if (read.couldNotRun !== undefined) {
650
+ stderr.write(`release-eval: ${read.couldNotRun}\n`);
651
+ return 2;
652
+ }
653
+ if (read.snap === null) {
654
+ red.push(
655
+ `\`${v}\` is a release from milestone 8 onward and there is no ${snapshotPath(v)}. Run ` +
656
+ "`node cli/release-eval.mjs --capture` in the change that cuts it",
657
+ );
658
+ continue;
659
+ }
660
+ red.push(...gradeOne(root, v, read.snap, v === version ? "package.json" : "CHANGELOG.md"));
661
+ }
662
+
663
+ let present;
664
+ try {
665
+ present = existsSync(path.join(root, RECORD_DIR))
666
+ ? [...new Set(readdirSync(path.join(root, RECORD_DIR)).flatMap((f) => (f.endsWith(".json") ? [f.slice(0, -5)] : f.endsWith(".md") ? [f.slice(0, -3)] : [])))]
667
+ : [];
668
+ } catch (e) {
669
+ stderr.write(`release-eval: could not enumerate ${RECORD_DIR}: ${e.message}\n`);
670
+ return 2;
671
+ }
672
+ for (const v of present.sort()) {
673
+ // `README.md` and anything else not named for a version is prose, not a record.
674
+ if (!SEMVER.test(v)) continue;
675
+ if (!released.includes(v)) {
676
+ 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`);
677
+ continue;
678
+ }
679
+ if (!isGoverned(v)) {
680
+ red.push(
681
+ `${RECORD_DIR}/${v}.* exists for a release that predates \`${FIRST_GOVERNED_VERSION}\` — nothing here writes ` +
682
+ "one, so it was written by hand, and an unexamined record reads as evidence exactly like a real one",
683
+ );
684
+ continue;
685
+ }
686
+ if (governedReleases.includes(v)) continue;
687
+ red.push(`${RECORD_DIR}/${v}.* is present but was not graded — this is a bug in the rail, not in the tree`);
688
+ }
689
+ for (const v of governedReleases) {
690
+ if (existsSync(path.join(root, registerPath(v))) && !existsSync(path.join(root, snapshotPath(v)))) {
691
+ red.push(`${registerPath(v)} stands with no capture beside it — the register is what a reader reads, and nothing holds it to anything`);
692
+ }
693
+ }
694
+
695
+ if (red.length > 0) {
696
+ for (const line of red) stdout.write(`release-eval: ${line}\n`);
697
+ return 1;
698
+ }
699
+ stdout.write(
700
+ governedReleases.length === 0
701
+ ? `release-eval: no release from \`${FIRST_GOVERNED_VERSION}\` onward has been cut yet — the clause binds *from ` +
702
+ `milestone 8* and the ${released.length} release(s) recorded predate it. CHANGELOG.md's newest heading agrees ` +
703
+ "with package.json. Nothing else to check, and that is a state rather than a green over a record set.\n"
704
+ : `release-eval: ${governedReleases.length} governed release(s) — ${governedReleases.join(", ")} — each carry an ` +
705
+ "eval result whose register is byte-identical to what its capture renders, with no recorded red.\n",
706
+ );
707
+ stdout.write(
708
+ "release-eval: this is the IN-TREE half. Whether a published release body cites its record is not reachable from " +
709
+ "here — `tag-a-release` and `publish-a-release` are Gated. `--tagged` reaches the tagged tree at the release " +
710
+ "act, from .github/workflows/publish-github-packages.yml; the rest is .portulan/gate-map.md's.\n",
711
+ );
712
+ return 0;
713
+ }
714
+
715
+ /** `snap: null` is no record; `couldNotRun` is a record nothing could read. */
716
+ function readRecord(root, version) {
717
+ const abs = path.join(root, snapshotPath(version));
718
+ if (!existsSync(abs)) return { snap: null };
719
+ try {
720
+ return { snap: JSON.parse(readFileSync(abs, "utf8")) };
721
+ } catch (e) {
722
+ return { couldNotRun: `${snapshotPath(version)} is not valid JSON: ${e.message}` };
723
+ }
724
+ }
725
+
726
+ function gradeOne(root, version, snap, keyedBy = "CHANGELOG.md") {
727
+ const red = verifyRecord(snap, { version, keyedBy });
728
+ if (red.length > 0) return red;
729
+ let onDisk;
730
+ try {
731
+ onDisk = readFileSync(path.join(root, registerPath(version)), "utf8");
732
+ } catch (e) {
733
+ return [`${registerPath(version)} could not be read (${e.message}) — the capture has no register beside it`];
734
+ }
735
+ if (onDisk !== renderRegister(snap)) {
736
+ return [
737
+ `${registerPath(version)} is not what ${snapshotPath(version)} renders — the published document has drifted ` +
738
+ `from its own capture. Re-render with \`node cli/release-eval.mjs --write --version ${version}\``,
739
+ ];
740
+ }
741
+ return [];
742
+ }
743
+
744
+ function isMain() {
745
+ return process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href;
746
+ }
747
+
748
+ if (isMain()) process.exit(run(process.argv.slice(2)));