@biffo/cli 0.298.13 → 0.298.15
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.
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
* since #1334, applied to every document GitHub actually honours, not
|
|
5
|
-
* the PR body.
|
|
3
|
+
* Three guards over a PR's closing keywords, asking different questions —
|
|
4
|
+
* and, since #1334, applied to every document GitHub actually honours, not
|
|
5
|
+
* just the PR body.
|
|
6
6
|
*
|
|
7
7
|
* 1. Refuse `Closes #N` on a change whose behaviour only shows up once
|
|
8
8
|
* deployed — a path-scoped check, documented immediately below.
|
|
9
9
|
* 2. Refuse a NEGATED closing keyword anywhere, on any path — see
|
|
10
10
|
* `negatedClosingReferences`. GitHub's linker has no concept of negation,
|
|
11
11
|
* so `Does not close #N` closes #N.
|
|
12
|
+
* 3. Refuse a mismatch between GitHub's OWN `closingIssuesReferences` and
|
|
13
|
+
* what this file's lexical scan calls "deliberate" — see
|
|
14
|
+
* `deliberateClosingReferences` and the "3. Ground truth" section below
|
|
15
|
+
* (#1686). This is the one that reconciles the guard's model against the
|
|
16
|
+
* thing that actually acts, rather than trying to out-regex it.
|
|
12
17
|
*
|
|
13
18
|
* ── Three documents, not one (#1334, #1362) ──────────────────────────────
|
|
14
19
|
*
|
|
@@ -96,6 +101,58 @@
|
|
|
96
101
|
* stale payload. Verified stale on #1172. See `resolveBody` for the fallback
|
|
97
102
|
* to a direct `PR_BODY` (local runs and every test in this suite) and why an
|
|
98
103
|
* unreadable live body fails the guard rather than passing it.
|
|
104
|
+
*
|
|
105
|
+
* ── 3. Ground truth: reconciling against `closingIssuesReferences` (#1686) ──
|
|
106
|
+
*
|
|
107
|
+
* Checks 1 and 2 above both infer intent from a regex over prose — and a
|
|
108
|
+
* regex over prose can only ever be a MODEL of what GitHub's own linker does,
|
|
109
|
+
* never the thing itself. PR #1680's body read (in full context) "This is
|
|
110
|
+
* the one-word fix #1664 asked for" — ordinary mid-sentence prose, not a
|
|
111
|
+
* deliberate `Closes #N` trailer — alongside its own explicit `Refs #1664`
|
|
112
|
+
* elsewhere in the same body. GitHub's `closingIssuesReferences` nonetheless
|
|
113
|
+
* read `totalCount: 1 -> #1664` while the PR was in that state: the lexical
|
|
114
|
+
* shape GitHub's linker looks for does not care about sentence position, and
|
|
115
|
+
* this file's `closingReferences` (check 1's hit detector) doesn't either —
|
|
116
|
+
* so `assess` correctly recorded a hit, but `changedFiles` for that PR were
|
|
117
|
+
* `cli/src/lib/pg-test-db-reaper.test.ts` and `scripts/pg-test-db.sh` — no
|
|
118
|
+
* `DEPLOY_ONLY_PREFIXES` entry — so check 1 returned
|
|
119
|
+
* `{ ok: true, reason: 'no-deploy-only-paths' }`. Release Guards reported
|
|
120
|
+
* SUCCESS. Only a human rewording the body before merge kept #1664 open.
|
|
121
|
+
*
|
|
122
|
+
* The deploy-only-path scoping is not wrong and is NOT removed here: a
|
|
123
|
+
* genuinely deliberate `Closes #N` on a path whose correctness a green suite
|
|
124
|
+
* already proves is exactly the case it exists to let through. What was
|
|
125
|
+
* wrong is narrower — a hit was silently PASSED whenever the paths were
|
|
126
|
+
* ordinary, with nothing checking whether GitHub was actually about to act on
|
|
127
|
+
* it. `deliberateClosingReferences` narrows check 1's hit detector to
|
|
128
|
+
* keyword+reference pairs that read as a genuine directive — at the start of
|
|
129
|
+
* the document, a line, or a sentence, optionally after a list/heading/bold
|
|
130
|
+
* marker — as opposed to buried mid-sentence. If GitHub's own
|
|
131
|
+
* `closingIssuesReferences` is non-empty and NOTHING in the PR's documents
|
|
132
|
+
* carries a deliberate closing keyword, that is a closing-keyword hit GitHub
|
|
133
|
+
* will act on that this file cannot explain as intentional — fail regardless
|
|
134
|
+
* of path, because the path-scoped hazard this file was built to catch is a
|
|
135
|
+
* SUBSET of "GitHub is about to close something nobody asked for", not a
|
|
136
|
+
* replacement for it.
|
|
137
|
+
*
|
|
138
|
+
* This also happens to close a gap #1686 flagged but explicitly did NOT ask
|
|
139
|
+
* to be fixed here: a closing shape GitHub's linker recognises that this
|
|
140
|
+
* file's own regex does not (e.g. a reference before its keyword) would
|
|
141
|
+
* previously have returned `no-closing-keyword` — hits.length === 0 — with
|
|
142
|
+
* nothing to catch it. Asking GitHub directly, rather than trying to widen
|
|
143
|
+
* the regex to match its exact recognition rules, structurally covers that
|
|
144
|
+
* case too: `deliberateClosingReferences` would find nothing "deliberate"
|
|
145
|
+
* either, and the ground-truth check would still fire. This is a consequence
|
|
146
|
+
* of the design, not a claim that the widened-adjacency shape was reproduced
|
|
147
|
+
* — it was not, deliberately (see #1686's own "UNCONFIRMED SECONDARY CLAIM").
|
|
148
|
+
*
|
|
149
|
+
* Level of fix: 3 (fail closed), not 1 or 2. The invalid state cannot be made
|
|
150
|
+
* unrepresentable, because the closing keyword lives in prose an author
|
|
151
|
+
* legitimately writes and there is no way to derive intent from it with
|
|
152
|
+
* certainty — `deliberateClosingReferences` is a heuristic, not a parser of
|
|
153
|
+
* meaning. What IS achievable, and what this does, is refuse to let our own
|
|
154
|
+
* heuristic's blind spot silently diverge from GitHub's actual behaviour: the
|
|
155
|
+
* two are reconciled every time, and a mismatch fails rather than passing.
|
|
99
156
|
*/
|
|
100
157
|
|
|
101
158
|
/** Closing keywords GitHub actually acts on, per its own documentation. */
|
|
@@ -159,6 +216,69 @@ export function closingReferences(body) {
|
|
|
159
216
|
return [...withoutCode.matchAll(pattern)].map((m) => m[2])
|
|
160
217
|
}
|
|
161
218
|
|
|
219
|
+
/**
|
|
220
|
+
* Markdown decoration a clause may legitimately start with before the
|
|
221
|
+
* keyword itself: a list marker (`-`, `*`, `1.`, `1)`), heading hashes, or
|
|
222
|
+
* bold (`**`). Real shapes from this repo's own history: `- tabsii-
|
|
223
|
+
* platform#511, today: \`Closes #511\`` (list item; the keyword itself was
|
|
224
|
+
* inside backticks there and so already blanked by `stripCode`, but plain
|
|
225
|
+
* `- Closes #42` is the same shape without the backticks) and `**Fixes
|
|
226
|
+
* #10**` (bold trailer).
|
|
227
|
+
*/
|
|
228
|
+
const CLAUSE_DECORATION = '(?:[-*•]\\s+|\\d+[.)]\\s+|#{1,6}\\s+|\\*{1,2})*'
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The closing-keyword references that read as a DELIBERATE directive rather
|
|
232
|
+
* than incidental prose — the keyword+reference sits at the start of the
|
|
233
|
+
* document, a line, or a sentence (optionally after `CLAUSE_DECORATION`),
|
|
234
|
+
* rather than buried mid-sentence.
|
|
235
|
+
*
|
|
236
|
+
* Real corpus evidence for both shapes, from this repo's own commit history
|
|
237
|
+
* (`git log --all --format='%B'`):
|
|
238
|
+
*
|
|
239
|
+
* - Deliberate — hundreds of `Closes #1234` lines used as commit-message
|
|
240
|
+
* trailers, plus `warnings on both commands. Closes #201.` (a trailer
|
|
241
|
+
* sentence following prose on the SAME physical line, which is why this
|
|
242
|
+
* splits on sentence-ending punctuation too, not only on newlines).
|
|
243
|
+
* - NOT deliberate — `This is the one-word fix #1664 asked for` (PR
|
|
244
|
+
* #1680's real, pre-reword text — the shape #1686 is filed over): `fix`
|
|
245
|
+
* is a real closing keyword immediately followed by a real reference,
|
|
246
|
+
* but it is the predicate of an ordinary sentence, not a directive.
|
|
247
|
+
* Likewise `That closes #422 by construction rather than policing it`
|
|
248
|
+
* and `` `--fix` exists to close #714 and #715 `` (both real lines from
|
|
249
|
+
* this repo's own history) — mid-sentence, not clause-initial.
|
|
250
|
+
*
|
|
251
|
+
* This is intentionally a narrower, less permissive detector than
|
|
252
|
+
* `closingReferences` — it exists only to ask "does this file have a
|
|
253
|
+
* confident READING of author intent", not to replace the lexical scan
|
|
254
|
+
* `closingReferences` still does for checks 1 and 2 above.
|
|
255
|
+
*
|
|
256
|
+
* Known residual gap, accepted rather than solved: a trailer that starts
|
|
257
|
+
* mid-line without sentence-ending punctuation before it (no case found in
|
|
258
|
+
* this repo's history) reads as not-deliberate. That is the conservative
|
|
259
|
+
* direction — it can make the ground-truth check (below) ask for a
|
|
260
|
+
* clarifying reword it didn't strictly need, never the reverse.
|
|
261
|
+
*/
|
|
262
|
+
export function deliberateClosingReferences(text) {
|
|
263
|
+
if (!text) return []
|
|
264
|
+
const stripped = stripCode(text)
|
|
265
|
+
const starts = new Set([0])
|
|
266
|
+
const boundary = /\n|[.!?]\s+/g
|
|
267
|
+
let m
|
|
268
|
+
while ((m = boundary.exec(stripped))) starts.add(m.index + m[0].length)
|
|
269
|
+
|
|
270
|
+
const pattern = new RegExp(
|
|
271
|
+
`^${CLAUSE_DECORATION}\\s*(${CLOSING_KEYWORDS.join('|')})\\b:?\\s+(${REFERENCE})`,
|
|
272
|
+
'i',
|
|
273
|
+
)
|
|
274
|
+
const found = []
|
|
275
|
+
for (const start of starts) {
|
|
276
|
+
const mm = stripped.slice(start).match(pattern)
|
|
277
|
+
if (mm) found.push(mm[2])
|
|
278
|
+
}
|
|
279
|
+
return [...new Set(found)]
|
|
280
|
+
}
|
|
281
|
+
|
|
162
282
|
/**
|
|
163
283
|
* ── 2. Negated closing keywords, on every path ───────────────────────────
|
|
164
284
|
*
|
|
@@ -291,8 +411,16 @@ export function documentsFor({ body, title, commits }) {
|
|
|
291
411
|
* a special case of the deploy-path check: a `Verified-on-deploy:` trailer
|
|
292
412
|
* cannot excuse it either, because the author is not claiming the issue is
|
|
293
413
|
* verified, they are saying it is not being closed at all.
|
|
414
|
+
*
|
|
415
|
+
* The ground-truth check (#1686) runs SECOND, before the deploy-path check,
|
|
416
|
+
* and also ignores `changedFiles`: it is not asking "is this a hazard here",
|
|
417
|
+
* it is asking "is GitHub about to do something this file cannot explain as
|
|
418
|
+
* intentional" — see the module docstring's "3. Ground truth" section.
|
|
419
|
+
* `closingIssuesReferences` defaults to `[]` so every existing body-only
|
|
420
|
+
* caller (and every existing test) keeps working unchanged, the same reason
|
|
421
|
+
* `title`/`commits` are optional — see `documentsFor`.
|
|
294
422
|
*/
|
|
295
|
-
export function assess({ body, title, commits, changedFiles }) {
|
|
423
|
+
export function assess({ body, title, commits, changedFiles, closingIssuesReferences = [] }) {
|
|
296
424
|
const docs = documentsFor({ body, title, commits })
|
|
297
425
|
|
|
298
426
|
const negated = docs.flatMap((doc) =>
|
|
@@ -300,6 +428,13 @@ export function assess({ body, title, commits, changedFiles }) {
|
|
|
300
428
|
)
|
|
301
429
|
if (negated.length > 0) return { ok: false, kind: 'negated-keyword', negated }
|
|
302
430
|
|
|
431
|
+
if (closingIssuesReferences.length > 0) {
|
|
432
|
+
const deliberate = docs.some((doc) => deliberateClosingReferences(doc.text).length > 0)
|
|
433
|
+
if (!deliberate) {
|
|
434
|
+
return { ok: false, kind: 'ground-truth-mismatch', closingIssuesReferences }
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
303
438
|
const hits = docs
|
|
304
439
|
.map((doc) => ({ source: doc.source, references: closingReferences(doc.text) }))
|
|
305
440
|
.filter((h) => h.references.length > 0)
|
|
@@ -315,9 +450,9 @@ export function assess({ body, title, commits, changedFiles }) {
|
|
|
315
450
|
}
|
|
316
451
|
|
|
317
452
|
export function formatFailure(result) {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
453
|
+
if (result.kind === 'negated-keyword') return formatNegatedFailure(result)
|
|
454
|
+
if (result.kind === 'ground-truth-mismatch') return formatGroundTruthFailure(result)
|
|
455
|
+
return formatDeployOnlyFailure(result)
|
|
321
456
|
}
|
|
322
457
|
|
|
323
458
|
function formatNegatedFailure({ negated }) {
|
|
@@ -346,6 +481,43 @@ function formatNegatedFailure({ negated }) {
|
|
|
346
481
|
].join('\n')
|
|
347
482
|
}
|
|
348
483
|
|
|
484
|
+
function formatGroundTruthFailure({ closingIssuesReferences }) {
|
|
485
|
+
const refs = closingIssuesReferences.map((r) =>
|
|
486
|
+
r?.number !== undefined ? `#${r.number}` : (r?.url ?? JSON.stringify(r)),
|
|
487
|
+
)
|
|
488
|
+
return [
|
|
489
|
+
`GitHub's own closingIssuesReferences says this PR will close ${refs.join(', ')} on`,
|
|
490
|
+
'merge — but nothing in the PR body, title or commit messages reads as a',
|
|
491
|
+
'DELIBERATE closing directive (a keyword+reference at the start of the',
|
|
492
|
+
'document, a line, or a sentence). GitHub\'s linker does not care about',
|
|
493
|
+
'paths or sentence position; it only needs the lexical shape, wherever it',
|
|
494
|
+
'sits.',
|
|
495
|
+
'',
|
|
496
|
+
'This is #1686: PR #1680\'s body read "This is the one-word fix #1664',
|
|
497
|
+
'asked for" — ordinary prose, not a directive — alongside its own',
|
|
498
|
+
'explicit `Refs #1664` elsewhere in the same body. closingIssuesReferences',
|
|
499
|
+
'nonetheless read #1664 while the PR was in that state, and Release Guards',
|
|
500
|
+
'reported SUCCESS: the deploy-only-path check only fires on a hazardous',
|
|
501
|
+
'PATH, and this PR touched none. Only a human rewording the body before',
|
|
502
|
+
'merge kept #1664 open.',
|
|
503
|
+
'',
|
|
504
|
+
'Either:',
|
|
505
|
+
' - this close is NOT intended: reword the offending line so the keyword',
|
|
506
|
+
' and reference are not adjacent (e.g. "the fix requested in #1664"',
|
|
507
|
+
' rather than "fix #1664"), or move the reference into a `Refs #N`',
|
|
508
|
+
' line; or',
|
|
509
|
+
' - this close IS intended: make it a deliberate directive — its own',
|
|
510
|
+
' line, its own sentence, or after a list/heading/bold marker, e.g.',
|
|
511
|
+
' `Closes #1664` — so this file, and anyone reading the PR, can tell',
|
|
512
|
+
' the difference.',
|
|
513
|
+
'',
|
|
514
|
+
'Re-run after editing — the body, title and commits are all read live, so',
|
|
515
|
+
'a re-run genuinely re-evaluates them (do not push an empty commit):',
|
|
516
|
+
'',
|
|
517
|
+
' gh run rerun <run-id> --failed',
|
|
518
|
+
].join('\n')
|
|
519
|
+
}
|
|
520
|
+
|
|
349
521
|
function formatDeployOnlyFailure({ references, paths, hits }) {
|
|
350
522
|
const shown = paths.slice(0, 10)
|
|
351
523
|
const more = paths.length - shown.length
|
|
@@ -562,6 +734,82 @@ export async function resolveCommits({
|
|
|
562
734
|
}
|
|
563
735
|
}
|
|
564
736
|
|
|
737
|
+
/**
|
|
738
|
+
* Fetch a PR's `closingIssuesReferences` via the GitHub CLI — GitHub's own
|
|
739
|
+
* ground truth for which issues this PR will close on merge (#1686). Same
|
|
740
|
+
* split as the other fetchers so tests can inject a fake. Each element is
|
|
741
|
+
* the shape `gh pr view --json closingIssuesReferences` returns:
|
|
742
|
+
* `{ id, number, repository: {...}, url }` (confirmed live against PR #1417,
|
|
743
|
+
* which genuinely closes an issue).
|
|
744
|
+
*/
|
|
745
|
+
export async function fetchPrClosingIssuesReferencesViaGh({ GH_TOKEN, PR_NUMBER, GH_REPO }) {
|
|
746
|
+
const { execFileSync } = await import('node:child_process')
|
|
747
|
+
const raw = execFileSync(
|
|
748
|
+
'gh',
|
|
749
|
+
[
|
|
750
|
+
'pr',
|
|
751
|
+
'view',
|
|
752
|
+
String(PR_NUMBER),
|
|
753
|
+
'--repo',
|
|
754
|
+
GH_REPO,
|
|
755
|
+
'--json',
|
|
756
|
+
'closingIssuesReferences',
|
|
757
|
+
'--jq',
|
|
758
|
+
'.closingIssuesReferences',
|
|
759
|
+
],
|
|
760
|
+
{ encoding: 'utf8', env: { ...process.env, GH_TOKEN } },
|
|
761
|
+
).trim()
|
|
762
|
+
return raw ? JSON.parse(raw) : []
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* Resolve the PR's `closingIssuesReferences` to assess — the ground-truth
|
|
767
|
+
* check's own input, and the reason it needs no new CI wiring: it reads via
|
|
768
|
+
* the same `GH_TOKEN`/`PR_NUMBER`/`GH_REPO` trio `resolveTitle` and
|
|
769
|
+
* `resolveCommits` already use, already present wherever this script runs
|
|
770
|
+
* as a PR check.
|
|
771
|
+
*
|
|
772
|
+
* Same three-path shape as the other resolvers:
|
|
773
|
+
*
|
|
774
|
+
* - `PR_CLOSING_ISSUES` set (including `''`, read as none): a JSON array,
|
|
775
|
+
* used as-is, no network — the local-run and test path.
|
|
776
|
+
* - `PR_CLOSING_ISSUES` unset, `GH_TOKEN`/`PR_NUMBER`/`GH_REPO` all set:
|
|
777
|
+
* live fetch, so a re-run sees the current linkage, not the one at the
|
|
778
|
+
* moment the workflow event fired (the exact staleness #1174 fixed for
|
|
779
|
+
* the body).
|
|
780
|
+
* - Neither: not a PR — nothing to reconcile against.
|
|
781
|
+
*
|
|
782
|
+
* Fails CLOSED on a half-configured trio or a failed fetch, same as the
|
|
783
|
+
* other resolvers: a silent empty-array fallback here would make an API
|
|
784
|
+
* outage read as "GitHub confirms nothing closes", which is the opposite of
|
|
785
|
+
* cautious for a check whose whole job is to catch what OUR OWN scan missed.
|
|
786
|
+
*/
|
|
787
|
+
export async function resolveClosingIssuesReferences({
|
|
788
|
+
env = process.env,
|
|
789
|
+
fetchLiveClosingIssuesReferences = fetchPrClosingIssuesReferencesViaGh,
|
|
790
|
+
} = {}) {
|
|
791
|
+
if (env.PR_CLOSING_ISSUES !== undefined) {
|
|
792
|
+
return env.PR_CLOSING_ISSUES === '' ? [] : JSON.parse(env.PR_CLOSING_ISSUES)
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
const { GH_TOKEN, PR_NUMBER, GH_REPO } = env
|
|
796
|
+
const trio = [GH_TOKEN, PR_NUMBER, GH_REPO]
|
|
797
|
+
if (trio.some(Boolean) && !trio.every(Boolean)) {
|
|
798
|
+
throw new Error(
|
|
799
|
+
'GH_TOKEN, PR_NUMBER and GH_REPO must all be set together for the live closing-issues fetch; got only some of them.',
|
|
800
|
+
)
|
|
801
|
+
}
|
|
802
|
+
if (!trio.every(Boolean)) return []
|
|
803
|
+
|
|
804
|
+
try {
|
|
805
|
+
return await fetchLiveClosingIssuesReferences({ GH_TOKEN, PR_NUMBER, GH_REPO })
|
|
806
|
+
} catch (err) {
|
|
807
|
+
throw new Error(
|
|
808
|
+
`could not fetch the closing-issues references of PR #${PR_NUMBER} in ${GH_REPO}: ${err?.message ?? err}`,
|
|
809
|
+
)
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
565
813
|
// ── CLI ───────────────────────────────────────────────────────────────────
|
|
566
814
|
// Bare node, no install, matching practices-monotonic.mjs — so this runs in
|
|
567
815
|
// the Release Guards job without depending on the pnpm install step.
|
|
@@ -575,17 +823,19 @@ if (import.meta.url === `file://${process.argv[1]}`) {
|
|
|
575
823
|
process.exit(0)
|
|
576
824
|
}
|
|
577
825
|
|
|
578
|
-
let body, title, commits
|
|
826
|
+
let body, title, commits, closingIssuesReferences
|
|
579
827
|
try {
|
|
580
|
-
// All
|
|
581
|
-
// commits
|
|
582
|
-
// as they are now, not as they were
|
|
828
|
+
// All four read live where a token is available (#1174, #1334 for
|
|
829
|
+
// commits, #1686 for closingIssuesReferences) — a re-run genuinely
|
|
830
|
+
// re-evaluates the PR/commits/linkage as they are now, not as they were
|
|
831
|
+
// when the workflow event fired.
|
|
583
832
|
body = await resolveBody()
|
|
584
833
|
title = await resolveTitle()
|
|
585
834
|
commits = await resolveCommits()
|
|
835
|
+
closingIssuesReferences = await resolveClosingIssuesReferences()
|
|
586
836
|
} catch (err) {
|
|
587
|
-
// Fail closed (#1174): an unreadable body/title/commits is an
|
|
588
|
-
// never a silent "no closing keyword found".
|
|
837
|
+
// Fail closed (#1174): an unreadable body/title/commits/linkage is an
|
|
838
|
+
// error, never a silent "no closing keyword found".
|
|
589
839
|
console.error(`✘ closing-keyword guard: ${err.message}`)
|
|
590
840
|
process.exit(1)
|
|
591
841
|
}
|
|
@@ -604,7 +854,7 @@ if (import.meta.url === `file://${process.argv[1]}`) {
|
|
|
604
854
|
process.exit(1)
|
|
605
855
|
}
|
|
606
856
|
|
|
607
|
-
const result = assess({ body, title, commits, changedFiles })
|
|
857
|
+
const result = assess({ body, title, commits, changedFiles, closingIssuesReferences })
|
|
608
858
|
if (result.ok) {
|
|
609
859
|
console.log(`✓ closing-keyword guard: ${result.reason}.`)
|
|
610
860
|
process.exit(0)
|