@sleepy_panda_srl/portulan 0.1.3 → 0.3.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.
- package/README.md +7 -6
- package/cli/README.md +145 -358
- package/cli/advisory.mjs +354 -0
- package/cli/comments.mjs +932 -0
- package/cli/compile.mjs +1719 -1699
- package/cli/context.mjs +883 -0
- package/cli/control-chars.mjs +12 -301
- package/cli/discover.mjs +17 -574
- package/cli/doctor.mjs +269 -1080
- package/cli/drills.mjs +92 -504
- package/cli/feedback.mjs +16 -237
- package/cli/finish.mjs +437 -0
- package/cli/form.mjs +644 -0
- package/cli/fuzz-shell.mjs +20 -305
- package/cli/gate.mjs +7 -199
- package/cli/goldens.mjs +26 -184
- package/cli/index.mjs +305 -655
- package/cli/init.mjs +243 -566
- package/cli/inside.mjs +3 -28
- package/cli/instructions.mjs +829 -0
- package/cli/ledger.mjs +882 -0
- package/cli/librarian.mjs +135 -599
- package/cli/manifest.mjs +2 -39
- package/cli/mutants.mjs +9 -170
- package/cli/new.mjs +12 -243
- package/cli/pack-identity.mjs +5 -74
- package/cli/pack-version.mjs +13 -255
- package/cli/plugin-lint.mjs +42 -658
- package/cli/portulan.mjs +6 -125
- package/cli/recipe-set.mjs +15 -245
- package/cli/release-eval.mjs +28 -348
- package/cli/review-meter.mjs +29 -265
- package/cli/rule-carriers.mjs +12 -175
- package/cli/sessions.mjs +82 -0
- package/cli/skill-goldens.mjs +10 -176
- package/cli/skills-set.mjs +12 -414
- package/cli/stop-gate.mjs +83 -514
- package/cli/symbols.mjs +1132 -0
- package/cli/telemetry.mjs +82 -505
- package/cli/upgrade.mjs +171 -314
- package/cli/vendor.mjs +146 -476
- package/cli/version-carriers.mjs +8 -86
- package/core/README.md +1 -1
- package/core/engine.md +3 -9
- package/core/operating/autonomy.md +1 -1
- package/core/operating/context.md +258 -0
- package/core/operating/evolution.md +3 -3
- package/core/operating/loop.md +13 -20
- package/core/operating/memory.md +12 -3
- package/core/operating/sessions.md +100 -0
- package/core/personas/README.md +33 -0
- package/core/personas/librarian.md +2 -2
- package/core/skills/clarify/SKILL.md +1 -1
- package/core/skills/consolidate/SKILL.md +5 -3
- package/core/templates/handoff.md +28 -15
- package/core/templates/memory-entry.md +1 -0
- package/evals/README.md +271 -845
- package/evals/releases/0.2.0.json +146 -0
- package/evals/releases/0.2.0.md +67 -0
- package/evals/releases/0.3.0.json +150 -0
- package/evals/releases/0.3.0.md +68 -0
- package/evals/releases/README.md +3 -3
- package/package.json +5 -2
- package/packs/.claude-plugin/plugin.json +2 -1
- package/packs/README.md +7 -4
- package/packs/rituals/checkpoints/README.md +1 -1
- package/packs/tools/README.md +8 -8
- package/packs/tools/github/verify/actions-pinned.sh +10 -55
- package/packs/tools/local-llm/README.md +136 -0
- package/packs/tools/local-llm/pack.json +32 -0
- package/packs/tools/local-llm/personas/local-worker.md +44 -0
- package/packs/tools/local-llm/runner/local-llm.mjs +1665 -0
- package/packs/tools/local-llm/skills/delegate-local/SKILL.md +64 -0
- package/packs/tools/local-llm/verify/README.md +18 -0
- package/spec/README.md +113 -6
- package/spec/migrations/0001-repository-declares-its-tree.mjs +1 -26
- package/spec/migrations/0002-bundle-fallback-path.mjs +13 -74
- package/spec/migrations/0003-handoff-index-not-kept.mjs +66 -0
- package/spec/migrations/0004-changelog-fragments.mjs +53 -0
- package/spec/migrations/0005-session-log-retired.mjs +69 -0
- package/spec/migrations/0006-boot-card.mjs +77 -0
- package/spec/migrations/0007-guidance-compiled.mjs +31 -0
- package/spec/migrations/0008-card-reading.mjs +55 -0
- package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -0
- package/spec/migrations/0010-comment-rail.mjs +57 -0
- package/spec/migrations/README.md +45 -10
- package/spec/pack.schema.json +1 -1
- package/spec/slots.md +343 -27
- package/spec/workspace.schema.json +145 -4
package/cli/release-eval.mjs
CHANGED
|
@@ -1,86 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Portulan — the eval result a release carries.
|
|
3
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
4
|
// Exit 0 green · 1 a finding · 2 could not run.
|
|
85
5
|
|
|
86
6
|
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
@@ -90,46 +10,25 @@ import { pathToFileURL } from "node:url";
|
|
|
90
10
|
|
|
91
11
|
import { recipeSet, resolverFor } from "./recipe-set.mjs";
|
|
92
12
|
|
|
93
|
-
/**
|
|
13
|
+
/** A failed precondition: always exit 2, never a finding about a release. */
|
|
94
14
|
export class CouldNotRun extends Error {}
|
|
95
15
|
|
|
96
|
-
/** Where a version's record lives. Both halves, because one carrier is a pair here as it is for the baseline. */
|
|
97
16
|
export const RECORD_DIR = "evals/releases";
|
|
98
17
|
export const snapshotPath = (version) => `${RECORD_DIR}/${version}.json`;
|
|
99
18
|
export const registerPath = (version) => `${RECORD_DIR}/${version}.md`;
|
|
100
19
|
|
|
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
20
|
export const FIRST_GOVERNED_VERSION = "0.1.3";
|
|
110
21
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
*/
|
|
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
|
+
|
|
121
27
|
export const SELF = "release-eval";
|
|
122
28
|
|
|
123
|
-
/** `X.Y.Z` and nothing else. A prerelease or a build tag is could-not-run rather than a guess at ordering. */
|
|
124
29
|
const SEMVER = /^(\d+)\.(\d+)\.(\d+)$/;
|
|
125
30
|
|
|
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
|
-
*/
|
|
31
|
+
/** Throws `CouldNotRun` on anything but `X.Y.Z` rather than guess where a prerelease falls. */
|
|
133
32
|
export function compareVersions(a, b) {
|
|
134
33
|
const pa = SEMVER.exec(a);
|
|
135
34
|
const pb = SEMVER.exec(b);
|
|
@@ -142,12 +41,11 @@ export function compareVersions(a, b) {
|
|
|
142
41
|
return 0;
|
|
143
42
|
}
|
|
144
43
|
|
|
145
|
-
/** Whether the clause governs this version. */
|
|
146
44
|
export function isGoverned(version) {
|
|
147
45
|
return compareVersions(version, FIRST_GOVERNED_VERSION) >= 0;
|
|
148
46
|
}
|
|
149
47
|
|
|
150
|
-
/**
|
|
48
|
+
/** Read from the worktree, not the index, so an unstaged cut is graded too. */
|
|
151
49
|
export function declaredVersion(root) {
|
|
152
50
|
let raw;
|
|
153
51
|
try {
|
|
@@ -165,17 +63,7 @@ export function declaredVersion(root) {
|
|
|
165
63
|
return v.trim();
|
|
166
64
|
}
|
|
167
65
|
|
|
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
|
-
*/
|
|
66
|
+
/** Every `## X.Y.Z` heading in file order, which is newest first. */
|
|
179
67
|
export function changelogVersions(root) {
|
|
180
68
|
let text;
|
|
181
69
|
try {
|
|
@@ -186,10 +74,7 @@ export function changelogVersions(root) {
|
|
|
186
74
|
const out = [];
|
|
187
75
|
let fenced = false;
|
|
188
76
|
for (const line of text.split("\n")) {
|
|
189
|
-
//
|
|
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.
|
|
77
|
+
// A fence may hold a worked example of a cut, and its heading is no release.
|
|
193
78
|
if (/^(```|~~~)/.test(line)) {
|
|
194
79
|
fenced = !fenced;
|
|
195
80
|
continue;
|
|
@@ -200,10 +85,6 @@ export function changelogVersions(root) {
|
|
|
200
85
|
out.push(m[1]);
|
|
201
86
|
continue;
|
|
202
87
|
}
|
|
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
88
|
if (/^## \d/.test(line)) {
|
|
208
89
|
throw new CouldNotRun(
|
|
209
90
|
`CHANGELOG.md carries the release heading ${JSON.stringify(line.trim())}, which is not \`## X.Y.Z\` — this rail ` +
|
|
@@ -215,7 +96,6 @@ export function changelogVersions(root) {
|
|
|
215
96
|
return out;
|
|
216
97
|
}
|
|
217
98
|
|
|
218
|
-
/** The commit this capture was taken at, and whether the tree was clean when it was. */
|
|
219
99
|
export function sourceOf(root) {
|
|
220
100
|
let commit;
|
|
221
101
|
try {
|
|
@@ -232,12 +112,7 @@ export function sourceOf(root) {
|
|
|
232
112
|
return { commit, clean };
|
|
233
113
|
}
|
|
234
114
|
|
|
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
|
-
*/
|
|
115
|
+
/** A named pack root wins outright over discovery, so the set depends on the tree, never on the machine. */
|
|
241
116
|
export function yieldedRecipes({ root, workspace = ".portulan", packRoot = "packs" }) {
|
|
242
117
|
const workspaceDir = path.join(root, workspace);
|
|
243
118
|
let manifest;
|
|
@@ -257,19 +132,7 @@ export function yieldedRecipes({ root, workspace = ".portulan", packRoot = "pack
|
|
|
257
132
|
return set.recipes.map((r) => ({ id: r.id, run: r.run }));
|
|
258
133
|
}
|
|
259
134
|
|
|
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
|
-
*/
|
|
135
|
+
/** The A/B baseline's identity alone, since its figures have their own carrier; `null` when none is committed. */
|
|
273
136
|
export function abBaselineIdentity(root) {
|
|
274
137
|
const snapshot = "evals/ab/baseline.json";
|
|
275
138
|
const abs = path.join(root, snapshot);
|
|
@@ -280,11 +143,7 @@ export function abBaselineIdentity(root) {
|
|
|
280
143
|
} catch (e) {
|
|
281
144
|
throw new CouldNotRun(`the A/B baseline at ${snapshot} is not valid JSON: ${e.message}`);
|
|
282
145
|
}
|
|
283
|
-
//
|
|
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.
|
|
146
|
+
// No fallbacks: an absent field stays `undefined` so that `verifyShape` refuses it.
|
|
288
147
|
return {
|
|
289
148
|
snapshot,
|
|
290
149
|
register: "evals/ab/baseline.md",
|
|
@@ -294,32 +153,17 @@ export function abBaselineIdentity(root) {
|
|
|
294
153
|
};
|
|
295
154
|
}
|
|
296
155
|
|
|
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
156
|
export function limitationsFor(snap) {
|
|
305
157
|
const out = [
|
|
306
158
|
"**A green says this record agrees with its own capture — never that the release is good.** Every " +
|
|
307
159
|
"line below is what the recipes returned at one commit, and a recipe's own green establishes only what " +
|
|
308
160
|
"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
161
|
`**The recipes were not run at the tag.** They ran at \`${snap?.source?.commit}\`, and this record is ` +
|
|
313
162
|
"committed *in* the cut change — so the tagged tree is this commit plus whatever that change still adds " +
|
|
314
163
|
"after the capture, the record itself at minimum. A record cannot be captured at a commit that does not " +
|
|
315
164
|
"exist yet, and printing the one it was captured at is the only honest form.",
|
|
316
165
|
`**\`${SELF}\` is excluded from the rows above**, because a capture cannot be accurate about the record it is inside. ` +
|
|
317
166
|
"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
167
|
"**This record measures the Portulan repository's own build**, at the commit named above — never the workspace, " +
|
|
324
168
|
"project or package this release is installed into. Nothing here has looked at your tree.",
|
|
325
169
|
];
|
|
@@ -338,7 +182,7 @@ export function limitationsFor(snap) {
|
|
|
338
182
|
return out;
|
|
339
183
|
}
|
|
340
184
|
|
|
341
|
-
/**
|
|
185
|
+
/** No fallbacks, here or in `limitationsFor`: a missing field must render as a hole that `verifyShape` refuses. */
|
|
342
186
|
export function renderRegister(snap) {
|
|
343
187
|
const L = [];
|
|
344
188
|
L.push(`# Eval result — Portulan ${snap.version}`);
|
|
@@ -383,11 +227,10 @@ export function renderRegister(snap) {
|
|
|
383
227
|
if (snap.abBaseline === null) {
|
|
384
228
|
L.push("None is committed in this tree.");
|
|
385
229
|
} else {
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
// branch — and is therefore checked by name in `verifyShape`.
|
|
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}`;
|
|
389
232
|
L.push(
|
|
390
|
-
`[\`${snap.abBaseline.register}\`](
|
|
233
|
+
`[\`${snap.abBaseline.register}\`](${href}), rendered from ` +
|
|
391
234
|
`\`${snap.abBaseline.snapshot}\` — captured ${snap.abBaseline.captured} at ` +
|
|
392
235
|
`\`${snap.abBaseline.commit}\`, over a tree that was ` +
|
|
393
236
|
`${snap.abBaseline.clean === true ? "clean" : "**not clean**"}.`,
|
|
@@ -403,73 +246,17 @@ export function renderRegister(snap) {
|
|
|
403
246
|
return L.join("\n");
|
|
404
247
|
}
|
|
405
248
|
|
|
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
249
|
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
444
250
|
|
|
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
|
-
*/
|
|
251
|
+
/** A full SHA-1 or SHA-256 name: an abbreviated one can become ambiguous. */
|
|
460
252
|
const OBJECT_NAME = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
461
253
|
|
|
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
254
|
function leafPaths(value, prefix = []) {
|
|
469
255
|
if (value === null || typeof value !== "object") return [[prefix, value]];
|
|
470
256
|
return Object.entries(value).flatMap(([k, v]) => leafPaths(v, [...prefix, k]));
|
|
471
257
|
}
|
|
472
258
|
|
|
259
|
+
/** Every reason this capture cannot be rendered truthfully; empty when it can. */
|
|
473
260
|
export function verifyShape(snap) {
|
|
474
261
|
const red = [];
|
|
475
262
|
if (snap?.portulan?.releaseEval !== "1") red.push("the record does not declare `portulan.releaseEval: \"1\"` — this is not a release eval capture");
|
|
@@ -495,11 +282,6 @@ export function verifyShape(snap) {
|
|
|
495
282
|
for (const field of ["source", "host"]) {
|
|
496
283
|
if (snap?.[field] === undefined || snap[field] === null) red.push(`the record has no \`${field}\`, which the register prints among the release's conditions`);
|
|
497
284
|
}
|
|
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
285
|
if (snap?.host !== undefined && snap?.host !== null) {
|
|
504
286
|
for (const k of ["node", "platform"]) {
|
|
505
287
|
if (typeof snap.host[k] !== "string") {
|
|
@@ -507,28 +289,15 @@ export function verifyShape(snap) {
|
|
|
507
289
|
}
|
|
508
290
|
}
|
|
509
291
|
}
|
|
510
|
-
// **The booleans, explicitly** — the derived check's one blind spot, measured rather than reasoned.
|
|
511
292
|
if (snap?.source !== undefined && snap?.source !== null && typeof snap.source.clean !== "boolean") {
|
|
512
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");
|
|
513
294
|
}
|
|
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
295
|
if (snap?.abBaseline !== null && snap?.abBaseline !== undefined && typeof snap.abBaseline.clean !== "boolean") {
|
|
518
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");
|
|
519
297
|
}
|
|
520
298
|
if (snap?.abBaseline === undefined) {
|
|
521
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");
|
|
522
300
|
}
|
|
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
301
|
const commitFields = [
|
|
533
302
|
["source.commit", snap?.source, "the record's central claim is that it was measured at a named commit"],
|
|
534
303
|
["abBaseline.commit", snap?.abBaseline, "a baseline cited by a name nothing can resolve is cited by nothing"],
|
|
@@ -540,18 +309,11 @@ export function verifyShape(snap) {
|
|
|
540
309
|
red.push(`the record's \`${label}\` is ${JSON.stringify(value)}, which is not a full object name — ${why}`);
|
|
541
310
|
}
|
|
542
311
|
}
|
|
543
|
-
//
|
|
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.
|
|
312
|
+
// Not trimmed: the value is printed as is, so it is checked as is.
|
|
547
313
|
if (typeof snap?.captured === "string" && !ISO_DATE.test(snap.captured)) {
|
|
548
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`);
|
|
549
315
|
}
|
|
550
316
|
|
|
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
317
|
if (snap !== null && typeof snap === "object") {
|
|
556
318
|
const skip = snap.abBaseline === null ? "abBaseline" : null;
|
|
557
319
|
for (const [pathParts, leaf] of leafPaths(snap)) {
|
|
@@ -567,9 +329,6 @@ export function verifyShape(snap) {
|
|
|
567
329
|
|
|
568
330
|
if (red.length > 0) return red;
|
|
569
331
|
|
|
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
332
|
let rendered;
|
|
574
333
|
try {
|
|
575
334
|
rendered = renderRegister(snap);
|
|
@@ -585,24 +344,11 @@ export function verifyShape(snap) {
|
|
|
585
344
|
return red;
|
|
586
345
|
}
|
|
587
346
|
|
|
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
|
-
*/
|
|
347
|
+
/** Checks a record against its own capture, never the live recipe set: a recipe added later does not make it wrong. */
|
|
598
348
|
export function verifyRecord(snap, { version, keyedBy = "the file it is named for" }) {
|
|
599
349
|
const red = verifyShape(snap);
|
|
600
350
|
if (red.length > 0) return red;
|
|
601
351
|
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
352
|
red.push(`the record declares version \`${snap.version}\` where ${keyedBy} says \`${version}\` — a record keyed to another release cannot answer for this one`);
|
|
607
353
|
}
|
|
608
354
|
for (const r of snap.recipes) {
|
|
@@ -610,13 +356,6 @@ export function verifyRecord(snap, { version, keyedBy = "the file it is named fo
|
|
|
610
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`);
|
|
611
357
|
}
|
|
612
358
|
}
|
|
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
359
|
const excludedIds = snap.excluded.map((e) => e.id).sort();
|
|
621
360
|
if (excludedIds.length !== 1 || excludedIds[0] !== SELF) {
|
|
622
361
|
red.push(
|
|
@@ -627,7 +366,6 @@ export function verifyRecord(snap, { version, keyedBy = "the file it is named fo
|
|
|
627
366
|
return red;
|
|
628
367
|
}
|
|
629
368
|
|
|
630
|
-
/** Run every yielded recipe except this module's own, and return the rows. */
|
|
631
369
|
function measure(root, recipes, { stdout }) {
|
|
632
370
|
const rows = [];
|
|
633
371
|
for (const r of recipes) {
|
|
@@ -674,12 +412,6 @@ Exit 0 green · 1 a finding · 2 could not run.`;
|
|
|
674
412
|
const MODES = ["--capture", "--write", "--verify", "--tagged"];
|
|
675
413
|
|
|
676
414
|
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
415
|
const out = { mode: null, root: ".", date: null, version: null, tagged: null };
|
|
684
416
|
for (let i = 0; i < argv.length; i += 1) {
|
|
685
417
|
const a = argv[i];
|
|
@@ -692,12 +424,8 @@ function parse(argv) {
|
|
|
692
424
|
if (MODES.includes(a)) {
|
|
693
425
|
if (out.mode !== null) throw new CouldNotRun(`pass exactly one of ${MODES.join(", ")}`);
|
|
694
426
|
out.mode = a.slice(2);
|
|
695
|
-
// `--tagged` names the version being published; the others take it optionally.
|
|
696
427
|
if (a === "--tagged") {
|
|
697
|
-
//
|
|
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.
|
|
428
|
+
// Validated here, before the tag reaches a record path.
|
|
701
429
|
const t = need("the tag being published, for example `v0.1.3`");
|
|
702
430
|
if (!SEMVER.test(t.replace(/^v/, ""))) throw new CouldNotRun(`\`--tagged\` takes a tag naming an \`X.Y.Z\` version, not \`${t}\``);
|
|
703
431
|
out.tagged = t;
|
|
@@ -709,26 +437,13 @@ function parse(argv) {
|
|
|
709
437
|
out.root = need("a directory");
|
|
710
438
|
break;
|
|
711
439
|
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
440
|
const v = need("a YYYY-MM-DD date");
|
|
716
441
|
if (!ISO_DATE.test(v)) throw new CouldNotRun(`\`--date\` takes a \`YYYY-MM-DD\` date, not \`${v}\``);
|
|
717
442
|
out.date = v;
|
|
718
443
|
break;
|
|
719
444
|
}
|
|
720
445
|
case "--version": {
|
|
721
|
-
//
|
|
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.
|
|
446
|
+
// Validated here because it becomes a path segment, and `X.Y.Z` admits no `/` or `..`.
|
|
732
447
|
const v = need("an `X.Y.Z` version");
|
|
733
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`);
|
|
734
449
|
out.version = v;
|
|
@@ -780,11 +495,7 @@ export function run(argv = [], { stdout = process.stdout, stderr = process.stder
|
|
|
780
495
|
}
|
|
781
496
|
|
|
782
497
|
if (parsed.mode === "capture") {
|
|
783
|
-
//
|
|
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.
|
|
498
|
+
// Before any git or workspace read, so this refusal is reached outside a repository too.
|
|
788
499
|
if (!governed) {
|
|
789
500
|
stderr.write(
|
|
790
501
|
`release-eval: package.json declares \`${version}\`, which predates \`${FIRST_GOVERNED_VERSION}\` — ` +
|
|
@@ -804,8 +515,7 @@ export function run(argv = [], { stdout = process.stdout, stderr = process.stder
|
|
|
804
515
|
stderr.write(`release-eval: ${e.message}\n`);
|
|
805
516
|
return 2;
|
|
806
517
|
}
|
|
807
|
-
//
|
|
808
|
-
// cannot be reproduced, and this record is byte-compared.
|
|
518
|
+
// Never the clock: the record is byte-compared, so its date must be reproducible.
|
|
809
519
|
const captured = parsed.date ?? execFileSync("git", ["-C", root, "log", "-1", "--format=%cs"], { encoding: "utf8" }).trim();
|
|
810
520
|
const rows = measure(root, recipes, { stdout });
|
|
811
521
|
const snap = {
|
|
@@ -864,18 +574,8 @@ export function run(argv = [], { stdout = process.stdout, stderr = process.stder
|
|
|
864
574
|
}
|
|
865
575
|
|
|
866
576
|
// ---------------------------------------------------------------- `--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
577
|
if (parsed.mode === "tagged") {
|
|
873
|
-
//
|
|
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.
|
|
578
|
+
// The tag's version, never package.json's: a tag cut from an un-bumped tree still names what it releases.
|
|
879
579
|
const tagged = parsed.tagged.replace(/^v/, "");
|
|
880
580
|
let taggedGoverned;
|
|
881
581
|
try {
|
|
@@ -884,9 +584,6 @@ export function run(argv = [], { stdout = process.stdout, stderr = process.stder
|
|
|
884
584
|
stderr.write(`release-eval: ${e.message}\n`);
|
|
885
585
|
return 2;
|
|
886
586
|
}
|
|
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
587
|
if (tagged !== version) {
|
|
891
588
|
stdout.write(
|
|
892
589
|
`release-eval: the tag names \`${tagged}\` while this tree's package.json declares \`${version}\` — one of the ` +
|
|
@@ -932,9 +629,6 @@ export function run(argv = [], { stdout = process.stdout, stderr = process.stder
|
|
|
932
629
|
}
|
|
933
630
|
const red = [];
|
|
934
631
|
|
|
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
632
|
if (released[0] !== version) {
|
|
939
633
|
red.push(
|
|
940
634
|
`CHANGELOG.md's newest release heading is \`${released[0]}\` where package.json declares \`${version}\` — one ` +
|
|
@@ -966,18 +660,6 @@ export function run(argv = [], { stdout = process.stdout, stderr = process.stder
|
|
|
966
660
|
red.push(...gradeOne(root, v, read.snap, v === version ? "package.json" : "CHANGELOG.md"));
|
|
967
661
|
}
|
|
968
662
|
|
|
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
663
|
let present;
|
|
982
664
|
try {
|
|
983
665
|
present = existsSync(path.join(root, RECORD_DIR))
|
|
@@ -1001,10 +683,9 @@ export function run(argv = [], { stdout = process.stdout, stderr = process.stder
|
|
|
1001
683
|
);
|
|
1002
684
|
continue;
|
|
1003
685
|
}
|
|
1004
|
-
if (governedReleases.includes(v)) continue;
|
|
686
|
+
if (governedReleases.includes(v)) continue;
|
|
1005
687
|
red.push(`${RECORD_DIR}/${v}.* is present but was not graded — this is a bug in the rail, not in the tree`);
|
|
1006
688
|
}
|
|
1007
|
-
// A register with no capture beside it: the reader-facing half standing alone.
|
|
1008
689
|
for (const v of governedReleases) {
|
|
1009
690
|
if (existsSync(path.join(root, registerPath(v))) && !existsSync(path.join(root, snapshotPath(v)))) {
|
|
1010
691
|
red.push(`${registerPath(v)} stands with no capture beside it — the register is what a reader reads, and nothing holds it to anything`);
|
|
@@ -1031,7 +712,7 @@ export function run(argv = [], { stdout = process.stdout, stderr = process.stder
|
|
|
1031
712
|
return 0;
|
|
1032
713
|
}
|
|
1033
714
|
|
|
1034
|
-
/**
|
|
715
|
+
/** `snap: null` is no record; `couldNotRun` is a record nothing could read. */
|
|
1035
716
|
function readRecord(root, version) {
|
|
1036
717
|
const abs = path.join(root, snapshotPath(version));
|
|
1037
718
|
if (!existsSync(abs)) return { snap: null };
|
|
@@ -1042,7 +723,6 @@ function readRecord(root, version) {
|
|
|
1042
723
|
}
|
|
1043
724
|
}
|
|
1044
725
|
|
|
1045
|
-
/** One record's full verdict: its contents, and its register byte-compared. */
|
|
1046
726
|
function gradeOne(root, version, snap, keyedBy = "CHANGELOG.md") {
|
|
1047
727
|
const red = verifyRecord(snap, { version, keyedBy });
|
|
1048
728
|
if (red.length > 0) return red;
|