@sleepy_panda_srl/portulan 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,741 @@
1
+ #!/usr/bin/env node
2
+ // The review-loop meter — the three figures that bound this repository's review loop, derived
3
+ // instead of counted by hand.
4
+ //
5
+ // Milestone 8, clause (c): *review-loop metering in the telemetry clause — rounds per pull request,
6
+ // pushes per round, empty-round rate.* The argument for the clause is in
7
+ // `../docs/milestones/m08.md`; what belongs here is what each figure IS, which of them the API can
8
+ // answer, and — at more length than is comfortable — which of them it cannot.
9
+ //
10
+ // ## Why this tool exists at all
11
+ //
12
+ // `../.portulan/memory/a-review-loop-needs-a-bound.md` bounds the loop on a table of figures — 110
13
+ // submissions over 30 pull requests, 29% of them finding nothing — every one of which was counted by
14
+ // hand on 2026-07-28. That record says of itself, in its own *Why it holds* section, **"Nothing
15
+ // checks it — discipline, not a rail"**, citing
16
+ // `../.portulan/memory/a-mandate-nothing-checks-is-already-broken.md`. The 2026-07-28 amendment
17
+ // answers it in as many words: *"the telemetry clause is where that checker's home is, and naming the
18
+ // home is what this amendment does rather than claiming the checker exists."* This file is that
19
+ // checker arriving at the home the amendment named.
20
+ //
21
+ // A hand-counted figure whose subject keeps growing is this repository's most-repeated defect — it
22
+ // has now been repaired in the recipe counts of `../.portulan/repos/portulan.md`, the CLI roster of
23
+ // `./README.md`, and the operator total in `../evals/README.md`, each time the same way: **delete the
24
+ // tally and name the command that derives it.** The loop's figures are the last hand-maintained set
25
+ // of any consequence, and they are the ones a rule leans on.
26
+ //
27
+ // ## The word "round" moved, and the criterion predates the move
28
+ //
29
+ // This is the first thing to settle, because two of the criterion's three figures are named in a unit
30
+ // that was redefined after the criterion was written, and reading them in today's unit yields a
31
+ // different tool.
32
+ //
33
+ // The criterion was written **2026-07-28**. On **2026-07-30** the maintainer defined a round as *a
34
+ // Copilot review the working session answers with a push*
35
+ // (`../.portulan/handoffs/2026-07-30-a-round-gets-its-definition.md`), and the rule's table was
36
+ // **re-labelled, not re-counted**: its figures had always counted **submissions** — every review
37
+ // Copilot submits, one per push under `review_on_push: true`, including on the branch as opened —
38
+ // and the table's own units note says so. The rule's `Retire when:` line settles it beyond argument,
39
+ // naming its threshold in *"the submission units of the table above, **not fix-rounds**"*.
40
+ //
41
+ // So the criterion's *"rounds per pull request"* is **submissions per pull request**, and that is
42
+ // what this tool computes under that name. It never prints the bare word "rounds" for a figure,
43
+ // because the word has meant two things here and a figure whose unit is ambiguous is the exact defect
44
+ // #119 was opened to repair.
45
+ //
46
+ // ## What the API can answer, and the one thing it cannot
47
+ //
48
+ // **Submissions: exactly.** A review on `/pulls/N/reviews` whose author is the reviewer. Two traps,
49
+ // both measured on this repository rather than reasoned about:
50
+ //
51
+ // * **One actor, two logins.** `copilot-pull-request-reviewer[bot]` on `/reviews`, plain `Copilot`
52
+ // on `/pulls/N/comments`; a filter on either returns zero from the other, which is how #105's
53
+ // count was first mis-measured as zero. Matching is therefore a case-insensitive prefix over both
54
+ // surfaces, never an equality against one spelling.
55
+ // * **Our own reviews are on that endpoint too.** The agent identity's replies and derived verdicts
56
+ // are submitted as REVIEWS, so an unfiltered count is inflated by our own traffic: **at merge,
57
+ // seven of #105's fifteen review objects are `portulan-agent[bot]`, and ninety of #342's hundred
58
+ // and two.** Both figures are stamped *at merge* on purpose — the pre-commit checkpoint measured
59
+ // them and found this file carrying *six of fifteen* and *seventy-four of eighty-one*, which were
60
+ // the counts **mid-loop** (74/81 was true at 17:58:40Z on #342, which merged at 18:49:40Z). A
61
+ // hand-counted figure about a subject still growing is the exact defect this whole tool exists to
62
+ // retire, reproduced inside the fix for it.
63
+ // That is also why every read here is **paginated** — page 1 of a busy pull request is measurably
64
+ // stale, and on #342 page 1 carries three reviewer entries while the one on head is the twelfth.
65
+ //
66
+ // **Pushes: as a floor.** Under `review_on_push: true` each push draws one submission, so the
67
+ // distinct heads the reviewer judged are the pushes it saw. It is a floor rather than a count,
68
+ // because a push that drew no review — the ruleset not requesting one, a review still in flight at
69
+ // merge — leaves no trace here, and a force-push destroys the commits it replaced. Printed as a floor
70
+ // and never as a total.
71
+ //
72
+ // **Fix-rounds: NOT AT ALL, and this is the finding rather than a gap to apologise for.** A fix-round
73
+ // is a push that *answers* a submission, and whether a push answers one is a fact about its contents.
74
+ // Two measured demonstrations, both from the pull request that produced the definition:
75
+ //
76
+ // * On #105, `08d7d10` answered review 4's inline finding and **was never a reviewed head** — it
77
+ // rode inside the next push. Enumerating heads cannot see it. The 2026-07-30 ruling states the
78
+ // method that can: *"Count pushes, then look inside each one."*
79
+ // * Also on #105, the push at `cff3e4e0` follows a submission whose body ran to 4,087 bytes and
80
+ // answers none of it — it carried records. A rule keyed on *"a finding-bearing submission
81
+ // preceded this push"* calls that a fix-round; the maintainer's table calls it *no*.
82
+ //
83
+ // So this tool does not compute fix-rounds, does not estimate them, and does not print a figure that
84
+ // could be mistaken for them. The criterion's *"pushes per round"* is reported in the one unit
85
+ // available without adjudication — **pushes per finding-bearing submission** — under that name, with
86
+ // the difference stated in the output rather than left for a reader to assume away.
87
+ //
88
+ // ## The empty-round rate is reported as a BOUND, and the reason is a layering rule
89
+ //
90
+ // The rule's table counts submissions that found **nothing at all**. Finding nothing has two halves:
91
+ // no inline comment thread, *and* no suppressed low-confidence note in the review body. The first
92
+ // half is structural and this tool computes it exactly. The second is decided by a matcher — the awk
93
+ // in `../.github/workflows/copilot-review.yml`, fixtured in
94
+ // `../.portulan/verify/workflow-filters.mjs` — that is deliberately reduced to **one** carrier, and
95
+ // that carrier is a workspace-layer gate while this file is engine.
96
+ //
97
+ // Re-implementing it here would put a second spelling of one rule on the other side of the
98
+ // engine/workspace boundary, where neither could see the other drift. That is this repository's
99
+ // signature defect and `../.portulan/proposals/0027` exists to refuse it. So the tool reports what it
100
+ // can compute exactly and names the relation:
101
+ //
102
+ // submissions that found nothing ≤ submissions with no inline comment
103
+ //
104
+ // The right-hand side is what this prints, under its own name, as an **upper bound**.
105
+ //
106
+ // **What the layering rule does NOT establish is that the exact rate is out of reach**, and saying so
107
+ // is the difference between a limit and an excuse. The tree already holds a lift-and-run consumer of
108
+ // these very programs: `../.portulan/verify/workflow-filters.mjs` extracts each single-quoted awk
109
+ // program out of the workflow's parsed `run:` scalars and executes it through the real `awk`. `--fetch`
110
+ // already spawns, so a body could be piped through the **lifted** program at capture time and stored
111
+ // as one integer — no second spelling, and still no bodies in the snapshot. That is the closing move,
112
+ // it is a workspace-side consumer rather than a copy, and it is **not built here for budget** (one
113
+ // clause, one session) rather than because the boundary forbids it. Tracked as
114
+ // https://github.com/sleepy-panda-srl/portulan/issues/355 — filed, and the number is here because
115
+ // "filed rather than built" naming no filing is a claim in the past tense about an issue that does
116
+ // not exist, which this repository has already shipped once.
117
+ //
118
+ // ## What this tool is NOT
119
+ //
120
+ // **It is a meter, not a bound.** Rule 4 of the record above stops a loop at two fix-rounds; nothing
121
+ // here stops anything. The record's own honest-limits section already says the judgement it depends
122
+ // on is the interested party's about its own work, and a tool that reports after the merge does not
123
+ // change that. Nor does it adjudicate rule 4's **sibling** exemption, which is a judgement about
124
+ // whether one finding's governing rule was already enforced at another site — so *rounds past the
125
+ // bound* is not computable and is not claimed.
126
+ //
127
+ // **It reports on a snapshot, never on the network.** The computation takes data and the fetch is a
128
+ // separate mode, for the reason `./goldens.mjs` takes fixtures: a figure a verify recipe rails must be
129
+ // re-derivable from the tree, on a machine with no token and no network, and must not move because
130
+ // GitHub was slow. `--fetch` is the only mode that talks to anything.
131
+
132
+ import { spawnSync } from "node:child_process";
133
+ import fs from "node:fs";
134
+ import path from "node:path";
135
+ import { pathToFileURL } from "node:url";
136
+
137
+ export const SNAPSHOT_VERSION = "1";
138
+
139
+ // The reviewer's identity is a PREFIX over a lowercased login, never an equality. Both observed
140
+ // spellings — `copilot-pull-request-reviewer[bot]` and `Copilot` — start with it, and a third
141
+ // spelling of the same actor would too. The cost of the looser test is that a human login beginning
142
+ // "copilot" would be counted; the cost of the tighter one was a whole round read as missing (#154).
143
+ export const REVIEWER_PREFIX = "copilot";
144
+
145
+ // The threshold the record names for its own retirement: *submissions-per-pull-request measures below
146
+ // 2.0 for a full milestone*. Carried here as a number so the tool can say which side of it a window
147
+ // falls on; the record remains the authority on what crossing it means.
148
+ export const RETIRE_THRESHOLD = 2.0;
149
+
150
+ const isReviewer = (login) =>
151
+ typeof login === "string" && login.toLowerCase().startsWith(REVIEWER_PREFIX);
152
+
153
+ // ---------------------------------------------------------------------------------------------
154
+ // The computation. Everything below takes a snapshot object and returns numbers; nothing here reads
155
+ // a file, runs a process, or knows what year it is.
156
+ // ---------------------------------------------------------------------------------------------
157
+
158
+ // A pull request's own figures. `submissions` counts the reviewer's reviews; `pushes` counts the
159
+ // distinct heads those reviews judged, which is a floor for the same reason stated in the header.
160
+ export function meterPullRequest(pr) {
161
+ const submissions = pr.submissions ?? [];
162
+ const heads = new Set();
163
+ let noInline = 0;
164
+ for (const s of submissions) {
165
+ if (typeof s.head === "string" && s.head.length > 0) heads.add(s.head);
166
+ if ((s.inline ?? 0) === 0) noInline += 1;
167
+ }
168
+ return {
169
+ number: pr.number,
170
+ submissions: submissions.length,
171
+ noInline,
172
+ findingBearing: submissions.length - noInline,
173
+ pushes: heads.size,
174
+ };
175
+ }
176
+
177
+ // The aggregate. Ratios are returned as `null` rather than as `0` or `NaN` when their denominator is
178
+ // empty: a corpus with no finding-bearing submission has no pushes-per-submission, and printing 0
179
+ // would be a claim about a loop nobody ran. `verify-preconditions-fail-closed` in prose.
180
+ export function meter(snapshot) {
181
+ const perPullRequest = (snapshot.pullRequests ?? []).map(meterPullRequest);
182
+ const total = (key) => perPullRequest.reduce((sum, p) => sum + p[key], 0);
183
+
184
+ const pullRequests = perPullRequest.length;
185
+ const submissions = total("submissions");
186
+ const noInline = total("noInline");
187
+ const findingBearing = total("findingBearing");
188
+ const pushes = total("pushes");
189
+ const ratio = (num, den) => (den === 0 ? null : num / den);
190
+
191
+ return {
192
+ repository: snapshot.repository ?? null,
193
+ captured: snapshot.captured ?? null,
194
+ window: snapshot.window ?? null,
195
+ pullRequests,
196
+ submissions,
197
+ noInline,
198
+ findingBearing,
199
+ pushes,
200
+ submissionsPerPullRequest: ratio(submissions, pullRequests),
201
+ noInlineRate: ratio(noInline, submissions),
202
+ pushesPerPullRequest: ratio(pushes, pullRequests),
203
+ // **The criterion's own "pushes per round" in submission units, stated rather than inferred.**
204
+ // Under `review_on_push: true` it is 1.00 by construction, which is why the row below it exists
205
+ // at all — but a reader owed the criterion's literal figure should not have to derive it from a
206
+ // paragraph about a coincidence. The pre-commit checkpoint asked for it in as many words.
207
+ pushesPerSubmission: ratio(pushes, submissions),
208
+ pushesPerFindingBearingSubmission: ratio(pushes, findingBearing),
209
+ // **Is the push figure carrying any information the submission figure does not?**
210
+ //
211
+ // Under `review_on_push: true` a push draws a submission, so the two coincide whenever every
212
+ // submission judged its own head — and when they do, `pushesPerFindingBearingSubmission`
213
+ // is not a second measurement at all: it reduces to `1 / (1 - noInlineRate)`, an identity.
214
+ // Measured 2026-08-26 over the thirty most recently merged pull requests: 140 submissions and
215
+ // 140 pushes, exactly equal, and 140/46 = 3.04 = 1/(1 - 0.671).
216
+ //
217
+ // Three figures of which two are algebraically the same figure is a table that reads as more
218
+ // evidence than it holds. So the coincidence is DETECTED and stated rather than left for a
219
+ // reader to notice, which is the same discipline `./goldens.mjs` applies when it prints the
220
+ // per-path census including the zeroes.
221
+ pushesCoincideWithSubmissions: pushes === submissions,
222
+ // Which side of the record's own retirement threshold this window falls on. `null` where the
223
+ // ratio is null — an unmeasured window is not a window measuring below the threshold, and
224
+ // conflating the two is how a rule gets retired on an empty corpus.
225
+ belowRetireThreshold:
226
+ ratio(submissions, pullRequests) === null
227
+ ? null
228
+ : ratio(submissions, pullRequests) < RETIRE_THRESHOLD,
229
+ perPullRequest,
230
+ };
231
+ }
232
+
233
+ // ---------------------------------------------------------------------------------------------
234
+ // The snapshot's own shape is checked before it is metered. A malformed snapshot must be exit 2 —
235
+ // "could not judge" — and never exit 1, which would report a review loop that nobody measured.
236
+ // ---------------------------------------------------------------------------------------------
237
+
238
+ export function validateSnapshot(snapshot) {
239
+ const problems = [];
240
+ if (snapshot === null || typeof snapshot !== "object" || Array.isArray(snapshot)) {
241
+ return ["the snapshot is not a JSON object"];
242
+ }
243
+ const version = snapshot.portulan?.reviewSnapshot;
244
+ if (version !== SNAPSHOT_VERSION) {
245
+ problems.push(
246
+ `portulan.reviewSnapshot is ${JSON.stringify(version)}; this tool reads ${JSON.stringify(SNAPSHOT_VERSION)}`,
247
+ );
248
+ }
249
+ if (typeof snapshot.repository !== "string" || snapshot.repository.length === 0) {
250
+ problems.push("repository is missing");
251
+ }
252
+ if (typeof snapshot.captured !== "string" || snapshot.captured.length === 0) {
253
+ problems.push("captured is missing — a snapshot with no date cannot be read as evidence");
254
+ }
255
+ if (!Array.isArray(snapshot.pullRequests)) {
256
+ problems.push("pullRequests is not an array");
257
+ return problems;
258
+ }
259
+ // The `window` is printed in the register as a claim about what was sampled, so a window that
260
+ // disagrees with the corpus beneath it is a false heading over true figures. Measured at the
261
+ // pre-commit checkpoint: `{merged: 300}` over thirty pull requests rendered *"300 most recently
262
+ // merged"* above *"| Pull requests | count | 30 |"*, rail green.
263
+ //
264
+ // **Zero is a legal window and the floor here used to be one.** A repository with no merged pull
265
+ // requests yet has an empty review loop, which is a true measurement rather than a failed one —
266
+ // and `meter()` already answers it correctly, returning `null` for every ratio and `null` for the
267
+ // retirement verdict, because *"an unmeasured window is not a window measuring below the
268
+ // threshold"*. Refusing it made this tool reject a snapshot **its own `--fetch` can write**, which
269
+ // is a validator disagreeing with its own producer. Copilot round 2 on #357, arriving through the
270
+ // suppressed channel — the half no gate sees until this repository's own promotion step makes it
271
+ // a thread.
272
+ if (!Number.isInteger(snapshot.window?.merged) || snapshot.window.merged < 0) {
273
+ problems.push(
274
+ `window.merged is ${JSON.stringify(snapshot.window?.merged)}; the register prints it as the size of the sample`,
275
+ );
276
+ } else if (snapshot.window.merged !== snapshot.pullRequests.length) {
277
+ problems.push(
278
+ `window.merged says ${snapshot.window.merged} and the snapshot carries ${snapshot.pullRequests.length} pull request(s)`,
279
+ );
280
+ }
281
+ const seen = new Set();
282
+ // **The window is BY MERGE DATE, and the snapshot must prove it.** `gh pr list` orders by pull
283
+ // request NUMBER, and the two disagree: the first capture taken here carried three merge-order
284
+ // inversions and sampled a corpus that was not the one its own register named — found at the
285
+ // pre-commit checkpoint, from evidence inside the committed snapshot. A rail that only checked
286
+ // the arithmetic would have re-published the same wrong window every time it was regenerated.
287
+ let previous = null;
288
+ for (const pr of snapshot.pullRequests) {
289
+ if (!Number.isInteger(pr?.number)) {
290
+ problems.push(`a pull request entry has no integer number: ${JSON.stringify(pr?.number)}`);
291
+ continue;
292
+ }
293
+ if (seen.has(pr.number)) problems.push(`pull request ${pr.number} appears twice`);
294
+ seen.add(pr.number);
295
+ // **`mergedAt` is parsed, not compared as text.** It was compared lexicographically, which is
296
+ // right for ISO-8601 and right for nothing else: any string orders against any other string,
297
+ // so a snapshot carrying `"yesterday"` or a `DD/MM/YYYY` stamp would have ordered cleanly and
298
+ // the register would still have claimed the window was *by merge date*. A check that cannot
299
+ // fail on a malformed input is not checking that input. Copilot round 2 on #357, through the
300
+ // suppressed channel.
301
+ const merged = typeof pr.mergedAt === "string" ? Date.parse(pr.mergedAt) : Number.NaN;
302
+ if (Number.isNaN(merged)) {
303
+ problems.push(
304
+ `pull request ${pr.number} has no parsable mergedAt (${JSON.stringify(pr.mergedAt)}) — ` +
305
+ "the window cannot be shown to be by merge date",
306
+ );
307
+ } else {
308
+ if (previous !== null && merged > previous) {
309
+ problems.push(
310
+ `pull request ${pr.number} merged at ${pr.mergedAt}, after the entry before it — ` +
311
+ "the window is not in descending merge order, so it is not the most recently merged N",
312
+ );
313
+ }
314
+ previous = merged;
315
+ }
316
+ if (!Array.isArray(pr.submissions)) {
317
+ problems.push(`pull request ${pr.number} has no submissions array`);
318
+ continue;
319
+ }
320
+ for (const s of pr.submissions) {
321
+ if (!isReviewer(s?.login)) {
322
+ // A snapshot carrying a non-reviewer review means the fetch filter drifted, and the
323
+ // figures would be inflated by our own traffic exactly as the header describes. That
324
+ // is a snapshot this tool cannot judge, not a loop with a high count.
325
+ problems.push(
326
+ `pull request ${pr.number} carries a submission by ${JSON.stringify(s?.login)}, which is not the reviewer`,
327
+ );
328
+ }
329
+ if (!Number.isInteger(s?.inline) || s.inline < 0) {
330
+ problems.push(
331
+ `pull request ${pr.number} has a submission with no inline count: ${JSON.stringify(s?.inline)}`,
332
+ );
333
+ }
334
+ // **`head` is contracted exactly as `inline` is, and for the same sentence.** It was not,
335
+ // and the gap was measured at the pre-commit checkpoint: strip every `head` and the tool
336
+ // exits 0 printing `pushes 0` and regenerates a register carrying that zero — which is
337
+ // what `meter()`'s own comment forbids one field over, *"printing 0 would be a claim about
338
+ // a loop nobody ran"*. Two of the criterion's three metrics rest on this field.
339
+ if (typeof s?.head !== "string" || s.head.length === 0) {
340
+ problems.push(
341
+ `pull request ${pr.number} has a submission with no head sha: ${JSON.stringify(s?.head)} — pushes are counted from it`,
342
+ );
343
+ }
344
+ }
345
+ }
346
+ return problems;
347
+ }
348
+
349
+ // ---------------------------------------------------------------------------------------------
350
+ // The register: the figures as a committed document, regenerated and byte-compared.
351
+ //
352
+ // The point of writing them down at all is that a snapshot is JSON nobody reads, and the figures are
353
+ // quoted by a rule. The point of byte-comparing them is that a published figure which can drift from
354
+ // its own data is the hand-maintained tally in a new costume — `./index.mjs` holds the memory index
355
+ // this way for the same reason.
356
+ // ---------------------------------------------------------------------------------------------
357
+
358
+ const round2 = (n) => (n === null ? "—" : (Math.round(n * 100) / 100).toFixed(2));
359
+ const pct = (n) => (n === null ? "—" : `${(Math.round(n * 1000) / 10).toFixed(1)}%`);
360
+
361
+ export function renderRegister(m) {
362
+ const lines = [];
363
+ lines.push("# Review-loop register — portulan");
364
+ lines.push("");
365
+ lines.push("> Generated from `snapshot.json` by `node cli/review-meter.mjs`. Do not edit by hand:");
366
+ lines.push("> it is regenerated and byte-compared, so a hand-edit survives exactly until the next run.");
367
+ lines.push(">");
368
+ lines.push("> **Every figure here is in SUBMISSION units** — every review the reviewer submits, one");
369
+ lines.push("> per push, including on the branch as opened. It is not the fix-round unit that");
370
+ lines.push("> `../../.portulan/memory/a-review-loop-needs-a-bound.md` rule 4 bounds, and no figure");
371
+ lines.push("> here may be read as one. See `../../cli/review-meter.mjs` for why fix-rounds are not");
372
+ lines.push("> derivable from the API at all.");
373
+ lines.push("");
374
+ lines.push(`- **Repository:** \`${m.repository}\``);
375
+ lines.push(`- **Captured:** ${m.captured}`);
376
+ lines.push(`- **Window:** ${m.window?.merged ?? "—"} most recently merged pull request(s)`);
377
+ lines.push("");
378
+ lines.push("## The figures");
379
+ lines.push("");
380
+ lines.push("| Measure | Unit | Value |");
381
+ lines.push("|---|---|---|");
382
+ lines.push(`| Pull requests | count | ${m.pullRequests} |`);
383
+ lines.push(`| Submissions | count | ${m.submissions} |`);
384
+ lines.push(`| Submissions per pull request | ratio | ${round2(m.submissionsPerPullRequest)} |`);
385
+ lines.push(`| Submissions with no inline comment | count | ${m.noInline} |`);
386
+ lines.push(`| — as a rate, an **upper bound** on the found-nothing rate | rate | ${pct(m.noInlineRate)} |`);
387
+ lines.push(`| Pushes the reviewer saw | floor | ${m.pushes} |`);
388
+ lines.push(`| Pushes per pull request | ratio | ${round2(m.pushesPerPullRequest)} |`);
389
+ lines.push(`| Pushes per **submission** — the criterion's literal figure | ratio | ${round2(m.pushesPerSubmission)} |`);
390
+ lines.push(`| Pushes per finding-bearing submission | ratio | ${round2(m.pushesPerFindingBearingSubmission)} |`);
391
+ lines.push("");
392
+ if (m.pushesCoincideWithSubmissions) {
393
+ lines.push(
394
+ "**Two of those rows are one row.** Every submission in this window judged its own head, " +
395
+ "so pushes and submissions coincide exactly and the last ratio is not an independent " +
396
+ "measurement: it is `1 / (1 - the no-inline rate)`. That is what `review_on_push: true` " +
397
+ "does to this pair, and it is stated here rather than left to be discovered by a reader " +
398
+ "dividing the columns.",
399
+ );
400
+ lines.push("");
401
+ }
402
+ lines.push("## Against the record's own retirement threshold");
403
+ lines.push("");
404
+ lines.push(
405
+ `\`a-review-loop-needs-a-bound.md\` retires when submissions per pull request measures below ` +
406
+ `**${RETIRE_THRESHOLD.toFixed(1)}** for a full milestone. This window measures ` +
407
+ `**${round2(m.submissionsPerPullRequest)}**, which is ` +
408
+ `${m.belowRetireThreshold === null ? "unmeasured" : m.belowRetireThreshold ? "**below**" : "**at or above**"} it.`,
409
+ );
410
+ lines.push("");
411
+ lines.push(
412
+ "**A window is not a milestone.** Which pull requests belong to which milestone row is not a " +
413
+ "field the API carries, so this tool measures a window of merged pull requests and the " +
414
+ "record's *for a full milestone* clause is not evaluated here. Reading this row as the " +
415
+ "retirement condition met would be reading a different measure than the one the record " +
416
+ "states.",
417
+ );
418
+ lines.push("");
419
+ lines.push("## Per pull request");
420
+ lines.push("");
421
+ lines.push("| PR | Submissions | No inline | Finding-bearing | Pushes (floor) |");
422
+ lines.push("|---|---|---|---|---|");
423
+ for (const p of m.perPullRequest) {
424
+ lines.push(`| #${p.number} | ${p.submissions} | ${p.noInline} | ${p.findingBearing} | ${p.pushes} |`);
425
+ }
426
+ lines.push("");
427
+ return `${lines.join("\n")}\n`;
428
+ }
429
+
430
+ // ---------------------------------------------------------------------------------------------
431
+ // The fetch. The ONE mode that talks to anything.
432
+ // ---------------------------------------------------------------------------------------------
433
+
434
+ const gh = (args) => {
435
+ const out = spawnSync("gh", args, { encoding: "utf8", maxBuffer: 128 * 1024 * 1024 });
436
+ if (out.error) throw new Error(`gh ${args[0]} ${args[1] ?? ""}: ${out.error.message}`);
437
+ if (out.status !== 0) throw new Error(`gh exited ${out.status}: ${(out.stderr || "").trim()}`);
438
+ return out.stdout;
439
+ };
440
+
441
+ // **The window is the most recently MERGED N, and `gh pr list` cannot answer that on its own.** It
442
+ // orders by pull request NUMBER, and number order is not merge order: the first capture taken here
443
+ // carried three inversions — #346 before #345, #343 before #342, #312 before #310 — so the sampled
444
+ // corpus contained #303 and excluded #301, while the register above it claimed *"the 30 most recently
445
+ // merged"*. Every published figure was therefore against a corpus its own heading did not name.
446
+ // Found at the pre-commit checkpoint, from evidence inside the committed snapshot: `mergedAt` was
447
+ // already being captured and nothing was sorting on it.
448
+ //
449
+ // So a POOL is listed and the window is taken from it by merge date. Sorted descending, ties broken by
450
+ // number descending so the order is total and a re-capture of the same data is byte-stable.
451
+ export function selectWindow(listed, limit) {
452
+ // Parsed rather than compared as text, and for the same reason `validateSnapshot` parses it: the
453
+ // producer and the validator must order by the same relation, or the fetch can write a window the
454
+ // validator then refuses. An unparsable stamp sorts last rather than throwing — the validator is
455
+ // where it is reported, and a fetch that crashed on one bad row would lose the other twenty-nine.
456
+ const at = (x) => {
457
+ const t = Date.parse(x?.mergedAt);
458
+ return Number.isNaN(t) ? -Infinity : t;
459
+ };
460
+ return [...listed]
461
+ .sort((a, b) => (at(a) === at(b) ? b.number - a.number : at(b) - at(a)))
462
+ .slice(0, limit);
463
+ }
464
+
465
+ // An inline comment belongs to the review that carried it, and `pull_request_review_id` is what ties
466
+ // the two surfaces together across the login split. Pure, so the grouping and the login filter are
467
+ // testable without a network: they are where two of the measured traps live.
468
+ export function shapeSubmissions(reviews, comments) {
469
+ const inlineByReview = new Map();
470
+ for (const c of comments) {
471
+ if (!isReviewer(c?.user?.login)) continue;
472
+ const id = c.pull_request_review_id;
473
+ inlineByReview.set(id, (inlineByReview.get(id) ?? 0) + 1);
474
+ }
475
+ return reviews
476
+ .filter((r) => isReviewer(r?.user?.login))
477
+ .map((r) => ({
478
+ id: r.id,
479
+ login: r.user.login,
480
+ state: r.state,
481
+ // An inline COMMENT's `commit_id` drifts onto a later head, so the sha a round judged is
482
+ // read from the REVIEW and never from a comment. That a review's own `commit_id` holds
483
+ // still is an assumption with an open issue on it — #253, and the falsifier is stated at
484
+ // length in this file's header rather than only here.
485
+ head: r.commit_id,
486
+ at: r.submitted_at,
487
+ inline: inlineByReview.get(r.id) ?? 0,
488
+ }));
489
+ }
490
+
491
+ // `--paginate` on both surfaces, never a bare read. Page 1 of a busy pull request is measurably stale
492
+ // here: on #342 ninety of a hundred and two review objects at merge were our own, so an unpaginated
493
+ // read returns three reviewer entries and the one on head is the twelfth.
494
+ export function fetchSnapshot({ repository, limit, pool, now }) {
495
+ const listed = JSON.parse(
496
+ gh(["pr", "list", "--repo", repository, "--state", "merged", "--limit", String(pool), "--json", "number,mergedAt"]),
497
+ );
498
+ // **A saturated pool cannot prove the window.** Where the listing came back short of the pool it
499
+ // is every merged pull request there is, and the window is provably the newest N; where it came
500
+ // back full, an older-numbered pull request merged recently could sit outside it. Recorded rather
501
+ // than assumed away, so a reader can tell which of the two they are holding.
502
+ const saturated = listed.length >= pool;
503
+ const window = selectWindow(listed, limit);
504
+ const pullRequests = [];
505
+ for (const { number, mergedAt } of window) {
506
+ const reviews = JSON.parse(gh(["api", "--paginate", `repos/${repository}/pulls/${number}/reviews`]));
507
+ const comments = JSON.parse(gh(["api", "--paginate", `repos/${repository}/pulls/${number}/comments`]));
508
+ // No bodies. The metrics need none, and a snapshot carrying review prose would put quoted
509
+ // content into a committed file for no measurement's sake.
510
+ pullRequests.push({ number, mergedAt, submissions: shapeSubmissions(reviews, comments) });
511
+ }
512
+ return {
513
+ portulan: { reviewSnapshot: SNAPSHOT_VERSION },
514
+ repository,
515
+ captured: now,
516
+ window: { merged: pullRequests.length, pool, poolSaturated: saturated },
517
+ pullRequests,
518
+ };
519
+ }
520
+
521
+ // ---------------------------------------------------------------------------------------------
522
+ // The command line.
523
+ // ---------------------------------------------------------------------------------------------
524
+
525
+ const USAGE = [
526
+ "usage: node cli/review-meter.mjs --snapshot <file> [--register <file>] [--check | --write]",
527
+ " node cli/review-meter.mjs --fetch --repo <owner/name> [--limit N] [--pool N] --out <file>",
528
+ "",
529
+ " --snapshot <file> the captured review data to meter (never the network)",
530
+ " --register <file> also render the register; --check byte-compares it, --write rewrites it",
531
+ " --fetch the one mode that talks to GitHub; needs `gh` on the path",
532
+ " --pool N how many merged pull requests to LIST before taking the newest --limit by",
533
+ " merge date; `gh pr list` orders by number, which is not merge order",
534
+ "",
535
+ "exit 0 green · 1 red · 2 could not run",
536
+ ].join("\n");
537
+
538
+ function parseArgs(argv) {
539
+ const opts = { snapshot: null, register: null, check: false, write: false, fetch: false, repo: null, limit: 30, pool: 200, out: null, help: false };
540
+ for (let i = 0; i < argv.length; i += 1) {
541
+ const a = argv[i];
542
+ const next = () => {
543
+ const v = argv[i + 1];
544
+ if (v === undefined) throw new Error(`${a} needs a value`);
545
+ i += 1;
546
+ return v;
547
+ };
548
+ if (a === "--snapshot") opts.snapshot = next();
549
+ else if (a === "--register") opts.register = next();
550
+ else if (a === "--check") opts.check = true;
551
+ else if (a === "--write") opts.write = true;
552
+ else if (a === "--fetch") opts.fetch = true;
553
+ else if (a === "--repo") opts.repo = next();
554
+ else if (a === "--limit") opts.limit = Number.parseInt(next(), 10);
555
+ else if (a === "--pool") opts.pool = Number.parseInt(next(), 10);
556
+ else if (a === "--out") opts.out = next();
557
+ else if (a === "--help" || a === "-h") opts.help = true;
558
+ else throw new Error(`unrecognised argument ${JSON.stringify(a)}`);
559
+ }
560
+ return opts;
561
+ }
562
+
563
+ export function run(argv = process.argv.slice(2), io = console) {
564
+ let opts;
565
+ try {
566
+ opts = parseArgs(argv);
567
+ } catch (e) {
568
+ io.error(`review-meter: ${e.message}`);
569
+ io.error(USAGE);
570
+ return 2;
571
+ }
572
+ if (opts.help) {
573
+ io.log(USAGE);
574
+ return 0;
575
+ }
576
+ if (opts.check && opts.write) {
577
+ io.error("review-meter: --check and --write ask for opposite things; pick one");
578
+ return 2;
579
+ }
580
+ // **`--check` and `--write` are about the REGISTER, so without one they asked for nothing and got
581
+ // exit 0.** A mistyped invocation — the flag typed, the path forgotten — printed the report and
582
+ // returned green, so a person believing they had byte-compared the register had run no comparison
583
+ // at all. That is a false green in the one place this tool exists to remove one, and it is the same
584
+ // shape as the entry-guard defect further down this file: the command runs, says nothing is wrong,
585
+ // and did not do the thing. Copilot round 1 on #357.
586
+ if ((opts.check || opts.write) && !opts.register) {
587
+ io.error(`review-meter: ${opts.check ? "--check" : "--write"} needs --register <file> — it is the register that is written and compared`);
588
+ io.error("Without it this flag would do nothing and still exit 0.");
589
+ return 2;
590
+ }
591
+
592
+ if (opts.fetch) {
593
+ if (!opts.repo || !opts.out) {
594
+ io.error("review-meter: --fetch needs --repo <owner/name> and --out <file>");
595
+ return 2;
596
+ }
597
+ if (!Number.isInteger(opts.limit) || opts.limit < 1) {
598
+ io.error(`review-meter: --limit must be a positive integer, not ${JSON.stringify(opts.limit)}`);
599
+ return 2;
600
+ }
601
+ // **Strictly greater, and the check used to say `<` while the prose beneath it argued `<=`.**
602
+ // A pool the size of the window cannot contain a pull request outside the window, so it is
603
+ // number order wearing the window's name — which is the defect the pool exists to remove. The
604
+ // guard permitted it anyway, so the suite's own case for this refusal fell straight through
605
+ // into the real fetch path and passed on `gh` failing instead. Copilot round 3 on #357: a test
606
+ // passing for the wrong reason, the same class round 1 found one site over.
607
+ if (!Number.isInteger(opts.pool) || opts.pool <= opts.limit) {
608
+ io.error(`review-meter: --pool must be an integer greater than --limit (${opts.limit}), not ${JSON.stringify(opts.pool)}`);
609
+ io.error("The pool is listed by pull request NUMBER and the window is taken from it by merge date,");
610
+ io.error("so a pool the size of the window is just number order wearing the window's name.");
611
+ return 2;
612
+ }
613
+ let snapshot;
614
+ try {
615
+ snapshot = fetchSnapshot({ repository: opts.repo, limit: opts.limit, pool: opts.pool, now: new Date().toISOString() });
616
+ } catch (e) {
617
+ io.error(`review-meter: the fetch failed — ${e.message}`);
618
+ io.error("A fetch that could not read is never a loop with nothing in it.");
619
+ return 2;
620
+ }
621
+ fs.mkdirSync(path.dirname(path.resolve(opts.out)), { recursive: true });
622
+ fs.writeFileSync(opts.out, `${JSON.stringify(snapshot, null, 2)}\n`);
623
+ io.log(`review-meter: wrote ${opts.out} — ${snapshot.pullRequests.length} pull request(s), newest merged first`);
624
+ if (snapshot.window.poolSaturated) {
625
+ io.log(`review-meter: the pool of ${opts.pool} came back full, so an older-numbered pull request`);
626
+ io.log(" merged recently could sit outside it. Raise --pool to make the window provable.");
627
+ }
628
+ return 0;
629
+ }
630
+
631
+ if (!opts.snapshot) {
632
+ io.error("review-meter: --snapshot <file> is required");
633
+ io.error(USAGE);
634
+ return 2;
635
+ }
636
+ let raw;
637
+ try {
638
+ raw = fs.readFileSync(opts.snapshot, "utf8");
639
+ } catch (e) {
640
+ io.error(`review-meter: cannot read ${opts.snapshot} — ${e.message}`);
641
+ return 2;
642
+ }
643
+ let snapshot;
644
+ try {
645
+ snapshot = JSON.parse(raw);
646
+ } catch (e) {
647
+ io.error(`review-meter: ${opts.snapshot} is not JSON — ${e.message}`);
648
+ return 2;
649
+ }
650
+ const problems = validateSnapshot(snapshot);
651
+ if (problems.length > 0) {
652
+ io.error(`review-meter: ${opts.snapshot} cannot be metered:`);
653
+ for (const p of problems) io.error(` - ${p}`);
654
+ return 2;
655
+ }
656
+
657
+ const m = meter(snapshot);
658
+ io.log(`review-meter: ${m.repository} — ${m.pullRequests} pull request(s) merged, captured ${m.captured}`);
659
+ io.log(` submissions ${m.submissions}`);
660
+ io.log(` submissions per pull request ${round2(m.submissionsPerPullRequest)}`);
661
+ io.log(` submissions with no inline comment ${m.noInline} (${pct(m.noInlineRate)})`);
662
+ io.log(` pushes the reviewer saw (a floor) ${m.pushes}`);
663
+ io.log(` pushes per pull request ${round2(m.pushesPerPullRequest)}`);
664
+ io.log(` pushes per submission ${round2(m.pushesPerSubmission)} <- the criterion's literal figure`);
665
+ io.log(` pushes per finding-bearing submission ${round2(m.pushesPerFindingBearingSubmission)}`);
666
+ io.log(
667
+ ` retire threshold (${RETIRE_THRESHOLD.toFixed(1)} submissions/PR) ` +
668
+ `${m.belowRetireThreshold === null ? "unmeasured" : m.belowRetireThreshold ? "BELOW" : "at or above"}`,
669
+ );
670
+ if (m.pushesCoincideWithSubmissions) {
671
+ io.log("");
672
+ io.log(" Pushes and submissions COINCIDE in this window, so the last ratio is not an");
673
+ io.log(" independent measurement — it is 1 / (1 - the no-inline rate). Two rows, one figure.");
674
+ }
675
+
676
+ // The limits are printed on every run rather than left in a README, for the reason
677
+ // `./goldens.mjs` prints its presence-versus-adequacy limit on every green: an exit code that
678
+ // implies more than it means is how a figure gets quoted as something it is not.
679
+ io.log("");
680
+ io.log(" Every figure above is in SUBMISSION units and none of them is a fix-round count.");
681
+ io.log(" Fix-rounds are not derivable here: a fix can ride inside another push, and a records");
682
+ io.log(" push after a finding-bearing submission answers nothing. See cli/review-meter.mjs.");
683
+ io.log(" The no-inline rate is an UPPER BOUND on the found-nothing rate — a submission carrying");
684
+ io.log(" only suppressed low-confidence notes is counted in it, and separating those needs the");
685
+ io.log(" workspace-layer matcher rather than a second copy of it here.");
686
+ io.log(" This is a meter. It reports; it does not bound, and it adjudicates no exemption.");
687
+
688
+ if (!opts.register) return 0;
689
+
690
+ const rendered = renderRegister(m);
691
+ if (opts.write) {
692
+ fs.mkdirSync(path.dirname(path.resolve(opts.register)), { recursive: true });
693
+ fs.writeFileSync(opts.register, rendered);
694
+ io.log(`review-meter: wrote ${opts.register}`);
695
+ return 0;
696
+ }
697
+ if (!opts.check) {
698
+ io.log(rendered);
699
+ return 0;
700
+ }
701
+ let onDisk;
702
+ try {
703
+ onDisk = fs.readFileSync(opts.register, "utf8");
704
+ } catch (e) {
705
+ io.error(`review-meter: cannot read ${opts.register} — ${e.message}`);
706
+ io.error("The register is generated; run with --write to create it.");
707
+ return 2;
708
+ }
709
+ if (onDisk !== rendered) {
710
+ io.error(`review-meter: ${opts.register} is out of date against the snapshot`);
711
+ io.error("It is generated and byte-compared. Regenerate it with --write; do not edit it by hand.");
712
+ return 1;
713
+ }
714
+ io.log(`review-meter: ${opts.register} is byte-identical to the snapshot's figures`);
715
+ return 0;
716
+ }
717
+
718
+ // The entry guard, in the ONE form `./rule-carriers.mjs` designates. `file://${argv[1]}` is NOT that
719
+ // form, and this file shipped that spelling for exactly one measurement: `--fetch` against the live
720
+ // repository printed nothing, exited 0, and wrote no snapshot, because `import.meta.url`
721
+ // percent-encodes and this working copy lives under a path with spaces. **A green that is the tool
722
+ // never starting** — the fourth time this repository has met it, and the reason the form is copied
723
+ // rather than re-derived.
724
+ //
725
+ // The realpath fallback covers the symlink an npm `bin` produces, in a `try` because a missing path
726
+ // must answer no rather than throw.
727
+ function isMain() {
728
+ const invoked = process.argv[1];
729
+ if (!invoked) return false;
730
+ if (import.meta.url === pathToFileURL(invoked).href) return true;
731
+ try {
732
+ return import.meta.url === pathToFileURL(fs.realpathSync(invoked)).href;
733
+ } catch {
734
+ return false;
735
+ }
736
+ }
737
+
738
+ // `process.exitCode` rather than `process.exit`, which `./control-chars.mjs` settled here: exiting
739
+ // outright can truncate a pipe that has not drained, and a truncated line IS exit 0 with no output —
740
+ // the precise shape of the false green the guard above was fixed for.
741
+ if (isMain()) process.exitCode = run(process.argv.slice(2));