@biffo/cli 0.298.14 → 0.298.16

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
- * Two guards over a PR's closing keywords, asking different questions — and,
4
- * since #1334, applied to every document GitHub actually honours, not just
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
- return result.kind === 'negated-keyword'
319
- ? formatNegatedFailure(result)
320
- : formatDeployOnlyFailure(result)
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 three read live where a token is available (#1174, and #1334 for
581
- // commits specifically) — a re-run genuinely re-evaluates the PR/commits
582
- // as they are now, not as they were when the workflow event fired.
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 error,
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)
@@ -1,14 +1,19 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Two guards over a PR's closing keywords, asking different questions — and,
4
- * since #1334, applied to every document GitHub actually honours, not just
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
- return result.kind === 'negated-keyword'
319
- ? formatNegatedFailure(result)
320
- : formatDeployOnlyFailure(result)
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 three read live where a token is available (#1174, and #1334 for
581
- // commits specifically) — a re-run genuinely re-evaluates the PR/commits
582
- // as they are now, not as they were when the workflow event fired.
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 error,
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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biffo/cli",
3
- "version": "0.298.14",
3
+ "version": "0.298.16",
4
4
  "description": "Biffo project scaffolding CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -303,6 +303,18 @@ if [ "$BIFFO_PG_REAP_HOURS" -gt 0 ] 2>/dev/null && command -v docker >/dev/null
303
303
  # Containers created before this label existed report an empty value and
304
304
  # fall through to the age rule below, exactly as `biffo.ephemeral=1`
305
305
  # migrated in (#1383). Nothing is stranded; they simply age out once.
306
+ #
307
+ # #1683: ownership must be checked BOTH ways, not just the GONE case.
308
+ # The original `if` only ever short-circuited (`continue`) when the
309
+ # checkout was gone -- a LIVE owner matched neither that condition nor
310
+ # any other, so it fell straight through into the unconditional age
311
+ # check below and was destroyed at 24h regardless. That is precisely
312
+ # the outcome this rule was written to prevent: ownership was only
313
+ # ever ACCELERATING reaping of dead checkouts, never protecting live
314
+ # ones. A container whose checkout still exists must never reach the
315
+ # age comparison at all, however old it is -- so that case gets its
316
+ # own explicit branch here rather than falling out of the bottom of
317
+ # this one by omission.
306
318
  _owner=$(docker inspect -f '{{index .Config.Labels "biffo.checkout"}}' "$_c" 2>/dev/null)
307
319
  if [ -n "$_owner" ] && [ "$_owner" != "<no value>" ] && [ ! -d "$_owner" ]; then
308
320
  if docker rm -f -v "$_c" >/dev/null 2>&1; then
@@ -310,6 +322,13 @@ if [ "$BIFFO_PG_REAP_HOURS" -gt 0 ] 2>/dev/null && command -v docker >/dev/null
310
322
  _reaped_gone=$((_reaped_gone + 1))
311
323
  fi
312
324
  continue
325
+ elif [ -n "$_owner" ] && [ "$_owner" != "<no value>" ] && [ -d "$_owner" ]; then
326
+ # Owner known and alive: ownership decides on its own, full stop.
327
+ # An UNLABELLED container (empty/`<no value>`) has no knowable
328
+ # owner and deliberately does NOT take this branch -- it falls
329
+ # through to the age rule below exactly as before, or this change
330
+ # would leak every pre-#1680 container forever.
331
+ continue
313
332
  fi
314
333
  # Both are UTC ISO-8601 to the second, so a string compare IS a time
315
334
  # compare -- no epoch conversion, and portable across date(1) flavours.