@sleepy_panda_srl/portulan 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/cli/README.md +69 -2
- package/cli/compile.mjs +555 -37
- package/cli/doctor.mjs +5 -1
- package/cli/drills.mjs +1541 -0
- package/cli/fuzz-shell.mjs +880 -0
- package/cli/gate.mjs +3 -2
- package/cli/goldens.mjs +469 -0
- package/cli/init.mjs +4 -1
- package/cli/mutants.mjs +925 -0
- package/cli/pack-identity.mjs +9 -2
- package/cli/pack-version.mjs +7 -0
- package/cli/recipe-set.mjs +6 -0
- package/cli/release-eval.mjs +1068 -0
- package/cli/review-meter.mjs +741 -0
- package/cli/skill-goldens.mjs +590 -0
- package/cli/stop-gate.mjs +107 -4
- package/cli/telemetry.mjs +1047 -0
- package/cli/version-carriers.mjs +13 -0
- package/core/templates/gate-policy.md +27 -8
- package/evals/README.md +910 -0
- package/evals/releases/0.1.3.json +134 -0
- package/evals/releases/0.1.3.md +64 -0
- package/evals/releases/README.md +32 -0
- package/package.json +7 -2
- package/packs/rituals/checkpoints/README.md +8 -1
- package/packs/rituals/checkpoints/pack.json +1 -1
- package/packs/rituals/checkpoints/skills/pre-commit/SKILL.md +32 -2
- package/spec/slots.md +26 -2
|
@@ -0,0 +1,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));
|