@sleepy_panda_srl/portulan 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +7 -6
  2. package/cli/README.md +142 -291
  3. package/cli/advisory.mjs +354 -0
  4. package/cli/comments.mjs +932 -0
  5. package/cli/compile.mjs +1443 -1210
  6. package/cli/context.mjs +883 -0
  7. package/cli/control-chars.mjs +12 -301
  8. package/cli/discover.mjs +17 -574
  9. package/cli/doctor.mjs +225 -1071
  10. package/cli/drills.mjs +1107 -0
  11. package/cli/feedback.mjs +16 -237
  12. package/cli/finish.mjs +437 -0
  13. package/cli/form.mjs +644 -0
  14. package/cli/fuzz-shell.mjs +595 -0
  15. package/cli/gate.mjs +7 -198
  16. package/cli/goldens.mjs +311 -0
  17. package/cli/index.mjs +305 -655
  18. package/cli/init.mjs +244 -564
  19. package/cli/inside.mjs +3 -28
  20. package/cli/instructions.mjs +829 -0
  21. package/cli/ledger.mjs +778 -0
  22. package/cli/librarian.mjs +135 -599
  23. package/cli/manifest.mjs +2 -39
  24. package/cli/mutants.mjs +764 -0
  25. package/cli/new.mjs +12 -243
  26. package/cli/pack-identity.mjs +6 -68
  27. package/cli/pack-version.mjs +13 -248
  28. package/cli/plugin-lint.mjs +42 -658
  29. package/cli/portulan.mjs +5 -124
  30. package/cli/recipe-set.mjs +16 -240
  31. package/cli/release-eval.mjs +748 -0
  32. package/cli/review-meter.mjs +500 -0
  33. package/cli/rule-carriers.mjs +12 -175
  34. package/cli/sessions.mjs +82 -0
  35. package/cli/skill-goldens.mjs +424 -0
  36. package/cli/skills-set.mjs +12 -414
  37. package/cli/stop-gate.mjs +126 -454
  38. package/cli/symbols.mjs +1132 -0
  39. package/cli/telemetry.mjs +582 -0
  40. package/cli/upgrade.mjs +171 -314
  41. package/cli/vendor.mjs +146 -476
  42. package/cli/version-carriers.mjs +9 -74
  43. package/core/README.md +1 -1
  44. package/core/engine.md +3 -9
  45. package/core/operating/autonomy.md +1 -1
  46. package/core/operating/context.md +183 -0
  47. package/core/operating/evolution.md +3 -3
  48. package/core/operating/loop.md +13 -20
  49. package/core/operating/memory.md +12 -3
  50. package/core/operating/sessions.md +100 -0
  51. package/core/personas/librarian.md +2 -2
  52. package/core/skills/consolidate/SKILL.md +4 -2
  53. package/core/templates/gate-policy.md +27 -8
  54. package/core/templates/handoff.md +28 -15
  55. package/core/templates/memory-entry.md +1 -0
  56. package/evals/README.md +320 -0
  57. package/evals/releases/0.1.3.json +134 -0
  58. package/evals/releases/0.1.3.md +64 -0
  59. package/evals/releases/0.2.0.json +146 -0
  60. package/evals/releases/0.2.0.md +67 -0
  61. package/evals/releases/README.md +32 -0
  62. package/package.json +9 -2
  63. package/packs/rituals/checkpoints/README.md +9 -2
  64. package/packs/rituals/checkpoints/pack.json +1 -1
  65. package/packs/rituals/checkpoints/skills/pre-commit/SKILL.md +32 -2
  66. package/packs/tools/github/verify/actions-pinned.sh +10 -55
  67. package/spec/README.md +112 -6
  68. package/spec/migrations/0001-repository-declares-its-tree.mjs +1 -26
  69. package/spec/migrations/0002-bundle-fallback-path.mjs +13 -74
  70. package/spec/migrations/0003-handoff-index-not-kept.mjs +66 -0
  71. package/spec/migrations/0004-changelog-fragments.mjs +53 -0
  72. package/spec/migrations/0005-session-log-retired.mjs +69 -0
  73. package/spec/migrations/0006-boot-card.mjs +77 -0
  74. package/spec/migrations/0007-guidance-compiled.mjs +31 -0
  75. package/spec/migrations/0008-card-reading.mjs +55 -0
  76. package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -0
  77. package/spec/migrations/0010-comment-rail.mjs +57 -0
  78. package/spec/migrations/README.md +45 -10
  79. package/spec/pack.schema.json +1 -1
  80. package/spec/slots.md +359 -23
  81. package/spec/workspace.schema.json +145 -4
@@ -0,0 +1,500 @@
1
+ #!/usr/bin/env node
2
+ // The review-loop meter: the figures that bound this repository's review loop, derived from its reviews.
3
+ //
4
+ // Fix-rounds are not derivable from the API: whether a push answers a submission is a fact about its contents.
5
+
6
+ import { spawnSync } from "node:child_process";
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import { pathToFileURL } from "node:url";
10
+
11
+ export const SNAPSHOT_VERSION = "1";
12
+
13
+ // A prefix, not an equality: the reviewer is `copilot-pull-request-reviewer[bot]` on reviews and `Copilot` on comments.
14
+ export const REVIEWER_PREFIX = "copilot";
15
+
16
+ // The review-loop rule retires once submissions per pull request stay below this for a full milestone.
17
+ export const RETIRE_THRESHOLD = 2.0;
18
+
19
+ const isReviewer = (login) =>
20
+ typeof login === "string" && login.toLowerCase().startsWith(REVIEWER_PREFIX);
21
+
22
+ // ---------------------------------------------------------------------------------------------
23
+ // The computation: pure, a snapshot in and numbers out
24
+ // ---------------------------------------------------------------------------------------------
25
+
26
+ // `pushes` is a floor: a push no review judged leaves no trace here.
27
+ export function meterPullRequest(pr) {
28
+ const submissions = pr.submissions ?? [];
29
+ const heads = new Set();
30
+ let noInline = 0;
31
+ for (const s of submissions) {
32
+ if (typeof s.head === "string" && s.head.length > 0) heads.add(s.head);
33
+ if ((s.inline ?? 0) === 0) noInline += 1;
34
+ }
35
+ return {
36
+ number: pr.number,
37
+ submissions: submissions.length,
38
+ noInline,
39
+ findingBearing: submissions.length - noInline,
40
+ pushes: heads.size,
41
+ };
42
+ }
43
+
44
+ // A ratio over an empty denominator is null, never 0: an unmeasured loop is not a measured zero.
45
+ export function meter(snapshot) {
46
+ const perPullRequest = (snapshot.pullRequests ?? []).map(meterPullRequest);
47
+ const total = (key) => perPullRequest.reduce((sum, p) => sum + p[key], 0);
48
+
49
+ const pullRequests = perPullRequest.length;
50
+ const submissions = total("submissions");
51
+ const noInline = total("noInline");
52
+ const findingBearing = total("findingBearing");
53
+ const pushes = total("pushes");
54
+ const ratio = (num, den) => (den === 0 ? null : num / den);
55
+
56
+ return {
57
+ repository: snapshot.repository ?? null,
58
+ captured: snapshot.captured ?? null,
59
+ window: snapshot.window ?? null,
60
+ pullRequests,
61
+ submissions,
62
+ noInline,
63
+ findingBearing,
64
+ pushes,
65
+ submissionsPerPullRequest: ratio(submissions, pullRequests),
66
+ noInlineRate: ratio(noInline, submissions),
67
+ pushesPerPullRequest: ratio(pushes, pullRequests),
68
+ pushesPerSubmission: ratio(pushes, submissions),
69
+ pushesPerFindingBearingSubmission: ratio(pushes, findingBearing),
70
+ pushesCoincideWithSubmissions: pushes === submissions,
71
+ belowRetireThreshold:
72
+ ratio(submissions, pullRequests) === null
73
+ ? null
74
+ : ratio(submissions, pullRequests) < RETIRE_THRESHOLD,
75
+ perPullRequest,
76
+ };
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------------------------
80
+ // The snapshot's shape, checked before metering: malformed is exit 2, never 1
81
+ // ---------------------------------------------------------------------------------------------
82
+
83
+ export function validateSnapshot(snapshot) {
84
+ const problems = [];
85
+ if (snapshot === null || typeof snapshot !== "object" || Array.isArray(snapshot)) {
86
+ return ["the snapshot is not a JSON object"];
87
+ }
88
+ const version = snapshot.portulan?.reviewSnapshot;
89
+ if (version !== SNAPSHOT_VERSION) {
90
+ problems.push(
91
+ `portulan.reviewSnapshot is ${JSON.stringify(version)}; this tool reads ${JSON.stringify(SNAPSHOT_VERSION)}`,
92
+ );
93
+ }
94
+ if (typeof snapshot.repository !== "string" || snapshot.repository.length === 0) {
95
+ problems.push("repository is missing");
96
+ }
97
+ if (typeof snapshot.captured !== "string" || snapshot.captured.length === 0) {
98
+ problems.push("captured is missing — a snapshot with no date cannot be read as evidence");
99
+ }
100
+ if (!Array.isArray(snapshot.pullRequests)) {
101
+ problems.push("pullRequests is not an array");
102
+ return problems;
103
+ }
104
+ if (!Number.isInteger(snapshot.window?.merged) || snapshot.window.merged < 0) {
105
+ problems.push(
106
+ `window.merged is ${JSON.stringify(snapshot.window?.merged)}; the register prints it as the size of the sample`,
107
+ );
108
+ } else if (snapshot.window.merged !== snapshot.pullRequests.length) {
109
+ problems.push(
110
+ `window.merged says ${snapshot.window.merged} and the snapshot carries ${snapshot.pullRequests.length} pull request(s)`,
111
+ );
112
+ }
113
+ const seen = new Set();
114
+ let previous = null;
115
+ for (const pr of snapshot.pullRequests) {
116
+ if (!Number.isInteger(pr?.number)) {
117
+ problems.push(`a pull request entry has no integer number: ${JSON.stringify(pr?.number)}`);
118
+ continue;
119
+ }
120
+ if (seen.has(pr.number)) problems.push(`pull request ${pr.number} appears twice`);
121
+ seen.add(pr.number);
122
+ const merged = typeof pr.mergedAt === "string" ? Date.parse(pr.mergedAt) : Number.NaN;
123
+ if (Number.isNaN(merged)) {
124
+ problems.push(
125
+ `pull request ${pr.number} has no parsable mergedAt (${JSON.stringify(pr.mergedAt)}) — ` +
126
+ "the window cannot be shown to be by merge date",
127
+ );
128
+ } else {
129
+ if (previous !== null && merged > previous) {
130
+ problems.push(
131
+ `pull request ${pr.number} merged at ${pr.mergedAt}, after the entry before it — ` +
132
+ "the window is not in descending merge order, so it is not the most recently merged N",
133
+ );
134
+ }
135
+ previous = merged;
136
+ }
137
+ if (!Array.isArray(pr.submissions)) {
138
+ problems.push(`pull request ${pr.number} has no submissions array`);
139
+ continue;
140
+ }
141
+ for (const s of pr.submissions) {
142
+ if (!isReviewer(s?.login)) {
143
+ problems.push(
144
+ `pull request ${pr.number} carries a submission by ${JSON.stringify(s?.login)}, which is not the reviewer`,
145
+ );
146
+ }
147
+ if (!Number.isInteger(s?.inline) || s.inline < 0) {
148
+ problems.push(
149
+ `pull request ${pr.number} has a submission with no inline count: ${JSON.stringify(s?.inline)}`,
150
+ );
151
+ }
152
+ if (typeof s?.head !== "string" || s.head.length === 0) {
153
+ problems.push(
154
+ `pull request ${pr.number} has a submission with no head sha: ${JSON.stringify(s?.head)} — pushes are counted from it`,
155
+ );
156
+ }
157
+ }
158
+ }
159
+ return problems;
160
+ }
161
+
162
+ // ---------------------------------------------------------------------------------------------
163
+ // The register: the figures as a committed document, regenerated and byte-compared
164
+ // ---------------------------------------------------------------------------------------------
165
+
166
+ const round2 = (n) => (n === null ? "—" : (Math.round(n * 100) / 100).toFixed(2));
167
+ const pct = (n) => (n === null ? "—" : `${(Math.round(n * 1000) / 10).toFixed(1)}%`);
168
+
169
+ export function renderRegister(m) {
170
+ const lines = [];
171
+ lines.push("# Review-loop register — portulan");
172
+ lines.push("");
173
+ lines.push("> Generated from `snapshot.json` by `node cli/review-meter.mjs`. Do not edit by hand:");
174
+ lines.push("> it is regenerated and byte-compared, so a hand-edit survives exactly until the next run.");
175
+ lines.push(">");
176
+ lines.push("> **Every figure here is in SUBMISSION units** — every review the reviewer submits, one");
177
+ lines.push("> per push, including on the branch as opened. It is not the fix-round unit that");
178
+ lines.push("> `../../.portulan/memory/a-review-loop-needs-a-bound.md` rule 4 bounds, and no figure");
179
+ lines.push("> here may be read as one. See `../../cli/review-meter.mjs` for why fix-rounds are not");
180
+ lines.push("> derivable from the API at all.");
181
+ lines.push("");
182
+ lines.push(`- **Repository:** \`${m.repository}\``);
183
+ lines.push(`- **Captured:** ${m.captured}`);
184
+ lines.push(`- **Window:** ${m.window?.merged ?? "—"} most recently merged pull request(s)`);
185
+ lines.push("");
186
+ lines.push("## The figures");
187
+ lines.push("");
188
+ lines.push("| Measure | Unit | Value |");
189
+ lines.push("|---|---|---|");
190
+ lines.push(`| Pull requests | count | ${m.pullRequests} |`);
191
+ lines.push(`| Submissions | count | ${m.submissions} |`);
192
+ lines.push(`| Submissions per pull request | ratio | ${round2(m.submissionsPerPullRequest)} |`);
193
+ lines.push(`| Submissions with no inline comment | count | ${m.noInline} |`);
194
+ lines.push(`| — as a rate, an **upper bound** on the found-nothing rate | rate | ${pct(m.noInlineRate)} |`);
195
+ lines.push(`| Pushes the reviewer saw | floor | ${m.pushes} |`);
196
+ lines.push(`| Pushes per pull request | ratio | ${round2(m.pushesPerPullRequest)} |`);
197
+ lines.push(`| Pushes per **submission** — the criterion's literal figure | ratio | ${round2(m.pushesPerSubmission)} |`);
198
+ lines.push(`| Pushes per finding-bearing submission | ratio | ${round2(m.pushesPerFindingBearingSubmission)} |`);
199
+ lines.push("");
200
+ if (m.pushesCoincideWithSubmissions) {
201
+ lines.push(
202
+ "**Two of those rows are one row.** Every submission in this window judged its own head, " +
203
+ "so pushes and submissions coincide exactly and the last ratio is not an independent " +
204
+ "measurement: it is `1 / (1 - the no-inline rate)`. That is what `review_on_push: true` " +
205
+ "does to this pair, and it is stated here rather than left to be discovered by a reader " +
206
+ "dividing the columns.",
207
+ );
208
+ lines.push("");
209
+ }
210
+ lines.push("## Against the record's own retirement threshold");
211
+ lines.push("");
212
+ lines.push(
213
+ `\`a-review-loop-needs-a-bound.md\` retires when submissions per pull request measures below ` +
214
+ `**${RETIRE_THRESHOLD.toFixed(1)}** for a full milestone. This window measures ` +
215
+ `**${round2(m.submissionsPerPullRequest)}**, which is ` +
216
+ `${m.belowRetireThreshold === null ? "unmeasured" : m.belowRetireThreshold ? "**below**" : "**at or above**"} it.`,
217
+ );
218
+ lines.push("");
219
+ lines.push(
220
+ "**A window is not a milestone.** Which pull requests belong to which milestone row is not a " +
221
+ "field the API carries, so this tool measures a window of merged pull requests and the " +
222
+ "record's *for a full milestone* clause is not evaluated here. Reading this row as the " +
223
+ "retirement condition met would be reading a different measure than the one the record " +
224
+ "states.",
225
+ );
226
+ lines.push("");
227
+ lines.push("## Per pull request");
228
+ lines.push("");
229
+ lines.push("| PR | Submissions | No inline | Finding-bearing | Pushes (floor) |");
230
+ lines.push("|---|---|---|---|---|");
231
+ for (const p of m.perPullRequest) {
232
+ lines.push(`| #${p.number} | ${p.submissions} | ${p.noInline} | ${p.findingBearing} | ${p.pushes} |`);
233
+ }
234
+ lines.push("");
235
+ return `${lines.join("\n")}\n`;
236
+ }
237
+
238
+ // ---------------------------------------------------------------------------------------------
239
+ // The fetch. The ONE mode that talks to anything.
240
+ // ---------------------------------------------------------------------------------------------
241
+
242
+ const gh = (args) => {
243
+ const out = spawnSync("gh", args, { encoding: "utf8", maxBuffer: 128 * 1024 * 1024 });
244
+ if (out.error) throw new Error(`gh ${args[0]} ${args[1] ?? ""}: ${out.error.message}`);
245
+ if (out.status !== 0) throw new Error(`gh exited ${out.status}: ${(out.stderr || "").trim()}`);
246
+ return out.stdout;
247
+ };
248
+
249
+ // By merge date, which `gh pr list`'s number order is not; ties by number, so a re-capture is byte-stable.
250
+ export function selectWindow(listed, limit) {
251
+ // An unparsable stamp sorts last rather than throwing: the validator reports it, and the other rows still land.
252
+ const at = (x) => {
253
+ const t = Date.parse(x?.mergedAt);
254
+ return Number.isNaN(t) ? -Infinity : t;
255
+ };
256
+ return [...listed]
257
+ .sort((a, b) => (at(a) === at(b) ? b.number - a.number : at(b) - at(a)))
258
+ .slice(0, limit);
259
+ }
260
+
261
+ // `pull_request_review_id` ties an inline comment to its review across the two logins.
262
+ export function shapeSubmissions(reviews, comments) {
263
+ const inlineByReview = new Map();
264
+ for (const c of comments) {
265
+ if (!isReviewer(c?.user?.login)) continue;
266
+ const id = c.pull_request_review_id;
267
+ inlineByReview.set(id, (inlineByReview.get(id) ?? 0) + 1);
268
+ }
269
+ return reviews
270
+ .filter((r) => isReviewer(r?.user?.login))
271
+ .map((r) => ({
272
+ id: r.id,
273
+ login: r.user.login,
274
+ state: r.state,
275
+ // From the review, never a comment: a comment's `commit_id` drifts onto later heads.
276
+ head: r.commit_id,
277
+ at: r.submitted_at,
278
+ inline: inlineByReview.get(r.id) ?? 0,
279
+ }));
280
+ }
281
+
282
+ // `--paginate` on both surfaces: page 1 of a busy pull request misses its latest reviews.
283
+ export function fetchSnapshot({ repository, limit, pool, now }) {
284
+ const listed = JSON.parse(
285
+ gh(["pr", "list", "--repo", repository, "--state", "merged", "--limit", String(pool), "--json", "number,mergedAt"]),
286
+ );
287
+ const saturated = listed.length >= pool;
288
+ const window = selectWindow(listed, limit);
289
+ const pullRequests = [];
290
+ for (const { number, mergedAt } of window) {
291
+ const reviews = JSON.parse(gh(["api", "--paginate", `repos/${repository}/pulls/${number}/reviews`]));
292
+ const comments = JSON.parse(gh(["api", "--paginate", `repos/${repository}/pulls/${number}/comments`]));
293
+ // No bodies: review prose has no place in a committed snapshot.
294
+ pullRequests.push({ number, mergedAt, submissions: shapeSubmissions(reviews, comments) });
295
+ }
296
+ return {
297
+ portulan: { reviewSnapshot: SNAPSHOT_VERSION },
298
+ repository,
299
+ captured: now,
300
+ window: { merged: pullRequests.length, pool, poolSaturated: saturated },
301
+ pullRequests,
302
+ };
303
+ }
304
+
305
+ // ---------------------------------------------------------------------------------------------
306
+ // The command line.
307
+ // ---------------------------------------------------------------------------------------------
308
+
309
+ const USAGE = [
310
+ "usage: node cli/review-meter.mjs --snapshot <file> [--register <file>] [--check | --write]",
311
+ " node cli/review-meter.mjs --fetch --repo <owner/name> [--limit N] [--pool N] --out <file>",
312
+ "",
313
+ " --snapshot <file> the captured review data to meter (never the network)",
314
+ " --register <file> also render the register; --check byte-compares it, --write rewrites it",
315
+ " --fetch the one mode that talks to GitHub; needs `gh` on the path",
316
+ " --pool N how many merged pull requests to LIST before taking the newest --limit by",
317
+ " merge date; `gh pr list` orders by number, which is not merge order",
318
+ "",
319
+ "exit 0 green · 1 red · 2 could not run",
320
+ ].join("\n");
321
+
322
+ function parseArgs(argv) {
323
+ const opts = { snapshot: null, register: null, check: false, write: false, fetch: false, repo: null, limit: 30, pool: 200, out: null, help: false };
324
+ for (let i = 0; i < argv.length; i += 1) {
325
+ const a = argv[i];
326
+ const next = () => {
327
+ const v = argv[i + 1];
328
+ if (v === undefined) throw new Error(`${a} needs a value`);
329
+ i += 1;
330
+ return v;
331
+ };
332
+ if (a === "--snapshot") opts.snapshot = next();
333
+ else if (a === "--register") opts.register = next();
334
+ else if (a === "--check") opts.check = true;
335
+ else if (a === "--write") opts.write = true;
336
+ else if (a === "--fetch") opts.fetch = true;
337
+ else if (a === "--repo") opts.repo = next();
338
+ else if (a === "--limit") opts.limit = Number.parseInt(next(), 10);
339
+ else if (a === "--pool") opts.pool = Number.parseInt(next(), 10);
340
+ else if (a === "--out") opts.out = next();
341
+ else if (a === "--help" || a === "-h") opts.help = true;
342
+ else throw new Error(`unrecognised argument ${JSON.stringify(a)}`);
343
+ }
344
+ return opts;
345
+ }
346
+
347
+ export function run(argv = process.argv.slice(2), io = console) {
348
+ let opts;
349
+ try {
350
+ opts = parseArgs(argv);
351
+ } catch (e) {
352
+ io.error(`review-meter: ${e.message}`);
353
+ io.error(USAGE);
354
+ return 2;
355
+ }
356
+ if (opts.help) {
357
+ io.log(USAGE);
358
+ return 0;
359
+ }
360
+ if (opts.check && opts.write) {
361
+ io.error("review-meter: --check and --write ask for opposite things; pick one");
362
+ return 2;
363
+ }
364
+ if ((opts.check || opts.write) && !opts.register) {
365
+ io.error(`review-meter: ${opts.check ? "--check" : "--write"} needs --register <file> — it is the register that is written and compared`);
366
+ io.error("Without it this flag would do nothing and still exit 0.");
367
+ return 2;
368
+ }
369
+
370
+ if (opts.fetch) {
371
+ if (!opts.repo || !opts.out) {
372
+ io.error("review-meter: --fetch needs --repo <owner/name> and --out <file>");
373
+ return 2;
374
+ }
375
+ if (!Number.isInteger(opts.limit) || opts.limit < 1) {
376
+ io.error(`review-meter: --limit must be a positive integer, not ${JSON.stringify(opts.limit)}`);
377
+ return 2;
378
+ }
379
+ if (!Number.isInteger(opts.pool) || opts.pool <= opts.limit) {
380
+ io.error(`review-meter: --pool must be an integer greater than --limit (${opts.limit}), not ${JSON.stringify(opts.pool)}`);
381
+ io.error("The pool is listed by pull request NUMBER and the window is taken from it by merge date,");
382
+ io.error("so a pool the size of the window is just number order wearing the window's name.");
383
+ return 2;
384
+ }
385
+ let snapshot;
386
+ try {
387
+ snapshot = fetchSnapshot({ repository: opts.repo, limit: opts.limit, pool: opts.pool, now: new Date().toISOString() });
388
+ } catch (e) {
389
+ io.error(`review-meter: the fetch failed — ${e.message}`);
390
+ io.error("A fetch that could not read is never a loop with nothing in it.");
391
+ return 2;
392
+ }
393
+ fs.mkdirSync(path.dirname(path.resolve(opts.out)), { recursive: true });
394
+ fs.writeFileSync(opts.out, `${JSON.stringify(snapshot, null, 2)}\n`);
395
+ io.log(`review-meter: wrote ${opts.out} — ${snapshot.pullRequests.length} pull request(s), newest merged first`);
396
+ if (snapshot.window.poolSaturated) {
397
+ io.log(`review-meter: the pool of ${opts.pool} came back full, so an older-numbered pull request`);
398
+ io.log(" merged recently could sit outside it. Raise --pool to make the window provable.");
399
+ }
400
+ return 0;
401
+ }
402
+
403
+ if (!opts.snapshot) {
404
+ io.error("review-meter: --snapshot <file> is required");
405
+ io.error(USAGE);
406
+ return 2;
407
+ }
408
+ let raw;
409
+ try {
410
+ raw = fs.readFileSync(opts.snapshot, "utf8");
411
+ } catch (e) {
412
+ io.error(`review-meter: cannot read ${opts.snapshot} — ${e.message}`);
413
+ return 2;
414
+ }
415
+ let snapshot;
416
+ try {
417
+ snapshot = JSON.parse(raw);
418
+ } catch (e) {
419
+ io.error(`review-meter: ${opts.snapshot} is not JSON — ${e.message}`);
420
+ return 2;
421
+ }
422
+ const problems = validateSnapshot(snapshot);
423
+ if (problems.length > 0) {
424
+ io.error(`review-meter: ${opts.snapshot} cannot be metered:`);
425
+ for (const p of problems) io.error(` - ${p}`);
426
+ return 2;
427
+ }
428
+
429
+ const m = meter(snapshot);
430
+ io.log(`review-meter: ${m.repository} — ${m.pullRequests} pull request(s) merged, captured ${m.captured}`);
431
+ io.log(` submissions ${m.submissions}`);
432
+ io.log(` submissions per pull request ${round2(m.submissionsPerPullRequest)}`);
433
+ io.log(` submissions with no inline comment ${m.noInline} (${pct(m.noInlineRate)})`);
434
+ io.log(` pushes the reviewer saw (a floor) ${m.pushes}`);
435
+ io.log(` pushes per pull request ${round2(m.pushesPerPullRequest)}`);
436
+ io.log(` pushes per submission ${round2(m.pushesPerSubmission)} <- the criterion's literal figure`);
437
+ io.log(` pushes per finding-bearing submission ${round2(m.pushesPerFindingBearingSubmission)}`);
438
+ io.log(
439
+ ` retire threshold (${RETIRE_THRESHOLD.toFixed(1)} submissions/PR) ` +
440
+ `${m.belowRetireThreshold === null ? "unmeasured" : m.belowRetireThreshold ? "BELOW" : "at or above"}`,
441
+ );
442
+ if (m.pushesCoincideWithSubmissions) {
443
+ io.log("");
444
+ io.log(" Pushes and submissions COINCIDE in this window, so the last ratio is not an");
445
+ io.log(" independent measurement — it is 1 / (1 - the no-inline rate). Two rows, one figure.");
446
+ }
447
+
448
+ io.log("");
449
+ io.log(" Every figure above is in SUBMISSION units and none of them is a fix-round count.");
450
+ io.log(" Fix-rounds are not derivable here: a fix can ride inside another push, and a records");
451
+ io.log(" push after a finding-bearing submission answers nothing. See cli/review-meter.mjs.");
452
+ io.log(" The no-inline rate is an UPPER BOUND on the found-nothing rate — a submission carrying");
453
+ io.log(" only suppressed low-confidence notes is counted in it, and separating those needs the");
454
+ io.log(" workspace-layer matcher rather than a second copy of it here.");
455
+ io.log(" This is a meter. It reports; it does not bound, and it adjudicates no exemption.");
456
+
457
+ if (!opts.register) return 0;
458
+
459
+ const rendered = renderRegister(m);
460
+ if (opts.write) {
461
+ fs.mkdirSync(path.dirname(path.resolve(opts.register)), { recursive: true });
462
+ fs.writeFileSync(opts.register, rendered);
463
+ io.log(`review-meter: wrote ${opts.register}`);
464
+ return 0;
465
+ }
466
+ if (!opts.check) {
467
+ io.log(rendered);
468
+ return 0;
469
+ }
470
+ let onDisk;
471
+ try {
472
+ onDisk = fs.readFileSync(opts.register, "utf8");
473
+ } catch (e) {
474
+ io.error(`review-meter: cannot read ${opts.register} — ${e.message}`);
475
+ io.error("The register is generated; run with --write to create it.");
476
+ return 2;
477
+ }
478
+ if (onDisk !== rendered) {
479
+ io.error(`review-meter: ${opts.register} is out of date against the snapshot`);
480
+ io.error("It is generated and byte-compared. Regenerate it with --write; do not edit it by hand.");
481
+ return 1;
482
+ }
483
+ io.log(`review-meter: ${opts.register} is byte-identical to the snapshot's figures`);
484
+ return 0;
485
+ }
486
+
487
+ // As file URLs, since `import.meta.url` percent-encodes a space; through realpath too, for an npm `bin` symlink.
488
+ function isMain() {
489
+ const invoked = process.argv[1];
490
+ if (!invoked) return false;
491
+ if (import.meta.url === pathToFileURL(invoked).href) return true;
492
+ try {
493
+ return import.meta.url === pathToFileURL(fs.realpathSync(invoked)).href;
494
+ } catch {
495
+ return false;
496
+ }
497
+ }
498
+
499
+ // `process.exitCode` rather than `process.exit`, so a pipe that has not drained is not cut short.
500
+ if (isMain()) process.exitCode = run(process.argv.slice(2));