@biffo/cli 0.298.26 → 0.298.28

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.
@@ -13,7 +13,9 @@
13
13
  * what this file's lexical scan calls "deliberate" — see
14
14
  * `deliberateClosingReferences` and the "3. Ground truth" section below
15
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.
16
+ * thing that actually acts, rather than trying to out-regex it. Extended
17
+ * in "3b" below (#1732) to a document `closingIssuesReferences` itself
18
+ * cannot see.
17
19
  *
18
20
  * ── Three documents, not one (#1334, #1362) ──────────────────────────────
19
21
  *
@@ -153,6 +155,73 @@
153
155
  * meaning. What IS achievable, and what this does, is refuse to let our own
154
156
  * heuristic's blind spot silently diverge from GitHub's actual behaviour: the
155
157
  * two are reconciled every time, and a mismatch fails rather than passing.
158
+ *
159
+ * ── 3b. Ground truth's own blind spot: it never reads a commit (#1732) ───
160
+ *
161
+ * Section 3 above reconciles this file's lexical model against
162
+ * `closingIssuesReferences` — but that field is itself only a MODEL of one
163
+ * of the three documents GitHub honours: it is GitHub's ground truth for the
164
+ * PR BODY, computed by the same markdown-aware linker that renders the PR
165
+ * page, and it structurally cannot see a commit message at all. This repo's
166
+ * squash-merge strategy is `squash_merge_commit_message = COMMIT_MESSAGES`
167
+ * (confirmed via `gh api repos/{owner}/{repo}` → `squash_merge_commit_title`/
168
+ * `_message`), so the actual merge commit GitHub creates is composed from the
169
+ * branch's own commit messages, verbatim — not from the PR body at all. A
170
+ * closing-keyword hit that lives only in a commit message is therefore
171
+ * something GitHub WILL act on that section 3's check is a structural no-op
172
+ * for, not a considered "safe": `closingIssuesReferences.length > 0` is
173
+ * simply never true for it, no matter how dangerous the commit text is.
174
+ *
175
+ * Real instance: merging PR #1730 (this very guard's own #1686 fix)
176
+ * spuriously closed unrelated issue #1664. Its body quoted the historical
177
+ * bug it was fixing — "the one-word fix #1664 asked for" — inside a markdown
178
+ * code span, so GitHub's PR-body linker correctly ignored it and
179
+ * `closingIssuesReferences` read `[]`, exactly what section 3 checks and
180
+ * exactly what let it through. The identical phrase reached the real squash
181
+ * commit unchanged, in the PR's own commit message — WITHOUT a code span
182
+ * there, because a git commit message has no markdown semantics at all: a
183
+ * backtick in one is two literal characters, not a code-span delimiter, and
184
+ * GitHub's push-based "closes on merge to the default branch" keyword scan
185
+ * is a completely different mechanism from the PR-body linker, with no
186
+ * concept of markdown to respect. #1664 closed one second after merge.
187
+ *
188
+ * This is also why `stripCode` cannot simply be applied to a commit-message
189
+ * document the way it is to the body/title: doing so would make this file's
190
+ * OWN lexical scan (`closingReferences`, `deliberateClosingReferences`,
191
+ * `negatedClosingReferences` — checks 1 and 2 above, not just this one) less
192
+ * sensitive than GitHub's real behaviour for that document, the same
193
+ * "guard reads a different document from the one that acts" shape #1362
194
+ * names, just one level further in: the guard was reading the RIGHT document
195
+ * (#1334 already fixed that) but modelling it with the WRONG renderer's
196
+ * rules. Every function above therefore takes a `{ code: false }` option
197
+ * (see `documentsFor`'s `kind` tag and `assess`'s `rawScan`) that skips
198
+ * `stripCode` for a `'commit'` document — never for `'body'`/`'title'`,
199
+ * where a code span is genuine, GitHub-honoured protection.
200
+ *
201
+ * With that in place, `assess` runs a second, independent ground-truth
202
+ * reconciliation scoped to commit documents alone, using the commit text's
203
+ * own (now un-stripped) lexical hit as the ground truth `closingIssuesReferences`
204
+ * can never supply: on an otherwise-safe (non-deploy-only) path, if a commit
205
+ * document carries a hit and nothing anywhere reads as deliberate, fail —
206
+ * `kind: 'commit-ground-truth-mismatch'`. A hit on a genuinely deploy-only
207
+ * path is still caught by check 1 regardless, exactly as before; this only
208
+ * closes the gap check 1 always had by design (ordinary paths pass) and
209
+ * section 3 could not close for this one document (ground truth never
210
+ * arrives). See `scripts/check-closing-keywords-ground-truth.test.sh` for
211
+ * the fail-first reproduction of PR #1730's exact real shape, plus the
212
+ * corpus cases either side of it.
213
+ *
214
+ * Level of fix: still 3 (fail closed), same reasoning as section 3 — a
215
+ * commit message is prose an author legitimately writes, so intent cannot be
216
+ * derived with certainty here either. Not made MORE strict than GitHub's own
217
+ * closing behaviour: GitHub will act on a commit-message hit regardless of
218
+ * position or backticks, and this check only refuses the ones this file
219
+ * cannot explain as intentional, using the identical `deliberateClosingReferences`
220
+ * heuristic and its identical, already-accepted trade-off (see that
221
+ * function's docstring for two real, intentional, mid-line-parenthetical
222
+ * closes this heuristic already did not recognise before this change,
223
+ * unrelated to commits — this does not introduce a new blind spot, it
224
+ * extends an existing, documented one to a new document).
156
225
  */
157
226
 
158
227
  /** Closing keywords GitHub actually acts on, per its own documentation. */
@@ -207,11 +276,20 @@ export function stripCode(body) {
207
276
  * The issue references a body would close on merge.
208
277
  *
209
278
  * Matches `Closes #12`, `fixes owner/repo#12` and the `Closes: #12` colon
210
- * form. Ignores keywords inside code — see `stripCode`.
279
+ * form. Ignores keywords inside code — see `stripCode` — UNLESS `{ code:
280
+ * false }` is passed, which skips that blanking entirely.
281
+ *
282
+ * `code` must be `false` for a COMMIT MESSAGE document (#1732): a git commit
283
+ * message has no markdown semantics, so a backtick there is two literal
284
+ * characters, not a code-span delimiter, and GitHub's push-based "closes on
285
+ * merge to the default branch" keyword scan reads it exactly that way — it
286
+ * is not the same renderer as the PR body/title, which genuinely are
287
+ * markdown and where `stripCode` correctly models GitHub's own linker. See
288
+ * `assess`'s `rawForDoc` and the module docstring's "3b" section.
211
289
  */
212
- export function closingReferences(body) {
290
+ export function closingReferences(body, { code = true } = {}) {
213
291
  if (!body) return []
214
- const withoutCode = stripCode(body)
292
+ const withoutCode = code ? stripCode(body) : body
215
293
  const pattern = new RegExp(`\\b(${CLOSING_KEYWORDS.join('|')})\\b:?\\s+(${REFERENCE})`, 'gi')
216
294
  return [...withoutCode.matchAll(pattern)].map((m) => m[2])
217
295
  }
@@ -254,14 +332,29 @@ const CLAUSE_DECORATION = '(?:[-*•]\\s+|\\d+[.)]\\s+|#{1,6}\\s+|\\*{1,2})*'
254
332
  * `closingReferences` still does for checks 1 and 2 above.
255
333
  *
256
334
  * 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.
335
+ * mid-line without sentence-ending punctuation before it reads as
336
+ * not-deliberate. That is the conservative direction — it can make the
337
+ * ground-truth check (below) ask for a clarifying reword it didn't strictly
338
+ * need, never the reverse. Two real instances, both intentional and both
339
+ * missed by this heuristic because a parenthesis is not a boundary this
340
+ * function looks for: `chore(core): mark services/pr-signer/ template-owned
341
+ * (closes #243/#548-shaped gap) (#581)` and `feat(cli): publish the CLI to
342
+ * npm as versioned `biffo` (closes #259) (#300)` (both real commit subjects,
343
+ * `git log --all --format='%B'`). Widening the boundary set to also start a
344
+ * clause after `(` was considered and rejected here: it would not even have
345
+ * caught either example (neither open-paren sits at a position this
346
+ * function currently recognises as a clause start), and it is exactly the
347
+ * kind of heuristic change #1628 warns against making without a full case
348
+ * matrix — see AGENTS.md. The remedy is the same as always: a `Closes #N` on
349
+ * its own line or sentence.
350
+ *
351
+ * `code`, same contract as `closingReferences` (#1732): pass `{ code: false
352
+ * }` for a commit-message document, since backticks are not markdown there
353
+ * and must not be treated as protection.
261
354
  */
262
- export function deliberateClosingReferences(text) {
355
+ export function deliberateClosingReferences(text, { code = true } = {}) {
263
356
  if (!text) return []
264
- const stripped = stripCode(text)
357
+ const stripped = code ? stripCode(text) : text
265
358
  const starts = new Set([0])
266
359
  const boundary = /\n|[.!?]\s+/g
267
360
  let m
@@ -333,11 +426,13 @@ const NEGATIONS = [
333
426
  * The negated closing references in a body, each with the line that carries
334
427
  * it — a guard that says only "no" gets worked around.
335
428
  *
336
- * Returns `[{ reference, line, lineNumber }]`, in body order.
429
+ * Returns `[{ reference, line, lineNumber }]`, in body order. `code`, same
430
+ * contract as `closingReferences` — pass `{ code: false }` for a commit
431
+ * message (#1732), since backticks do not protect text there.
337
432
  */
338
- export function negatedClosingReferences(body) {
433
+ export function negatedClosingReferences(body, { code = true } = {}) {
339
434
  if (!body) return []
340
- const text = stripCode(body)
435
+ const text = code ? stripCode(body) : body
341
436
  const authored = body.split('\n')
342
437
  const pattern = new RegExp(
343
438
  `(?:${NEGATIONS.join('|')})\\s+(?:${CLOSING_KEYWORDS.join('|')})\\b:?\\s+(${REFERENCE})`,
@@ -381,18 +476,26 @@ export function deployOnlyPaths(changedFiles) {
381
476
  * `commits` is the shape `gh pr view --json commits` returns: an array of
382
477
  * `{ messageHeadline, messageBody }`. Both are scanned — a keyword can sit
383
478
  * in either, and #1334's own repro had it in the headline.
479
+ *
480
+ * Each doc also carries `kind` — `'body'`, `'title'`, or `'commit'` (#1732).
481
+ * The PR body and title are genuinely markdown, rendered by GitHub's own PR
482
+ * page, so a code span in either is real protection. A commit message is
483
+ * neither: it has no markdown semantics for GitHub's push-based "closes on
484
+ * merge to the default branch" keyword scan, so `assess` must scan `'commit'`
485
+ * documents with `{ code: false }` — see that function and the module
486
+ * docstring's "3b" section.
384
487
  */
385
488
  export function documentsFor({ body, title, commits }) {
386
- const docs = [{ source: 'the PR body', text: body }]
387
- if (title) docs.push({ source: 'the PR title', text: title })
489
+ const docs = [{ source: 'the PR body', text: body, kind: 'body' }]
490
+ if (title) docs.push({ source: 'the PR title', text: title, kind: 'title' })
388
491
  const list = commits ?? []
389
492
  list.forEach((commit, i) => {
390
493
  const label = list.length === 1 ? 'the commit message' : `commit ${i + 1}`
391
494
  if (commit?.messageHeadline) {
392
- docs.push({ source: `${label} (subject)`, text: commit.messageHeadline })
495
+ docs.push({ source: `${label} (subject)`, text: commit.messageHeadline, kind: 'commit' })
393
496
  }
394
497
  if (commit?.messageBody) {
395
- docs.push({ source: `${label} (body)`, text: commit.messageBody })
498
+ docs.push({ source: `${label} (body)`, text: commit.messageBody, kind: 'commit' })
396
499
  }
397
500
  })
398
501
  return docs
@@ -420,28 +523,82 @@ export function documentsFor({ body, title, commits }) {
420
523
  * caller (and every existing test) keeps working unchanged, the same reason
421
524
  * `title`/`commits` are optional — see `documentsFor`.
422
525
  */
526
+ // A document is markdown, and therefore genuinely protected by a code span,
527
+ // only if GitHub's OWN renderer treats it that way. The PR body and title
528
+ // are; a commit message is not — see `documentsFor` and the module
529
+ // docstring's "3b" section (#1732). `closingReferences`, `deliberateClosingReferences`
530
+ // and `negatedClosingReferences` all take `{ code: false }` to mean "scan
531
+ // this raw, backticks are literal characters here".
532
+ const rawScan = (doc) => ({ code: doc.kind !== 'commit' })
533
+
423
534
  export function assess({ body, title, commits, changedFiles, closingIssuesReferences = [] }) {
424
535
  const docs = documentsFor({ body, title, commits })
425
536
 
426
537
  const negated = docs.flatMap((doc) =>
427
- negatedClosingReferences(doc.text).map((n) => ({ ...n, source: doc.source })),
538
+ negatedClosingReferences(doc.text, rawScan(doc)).map((n) => ({ ...n, source: doc.source })),
428
539
  )
429
540
  if (negated.length > 0) return { ok: false, kind: 'negated-keyword', negated }
430
541
 
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
- }
542
+ // Whether ANY document reads as a deliberate closing directive — shared
543
+ // between the two ground-truth checks below, since both ask the identical
544
+ // question ("is this hit something the author actually meant"), just
545
+ // triggered by two different sources of ground truth.
546
+ const deliberate = docs.some(
547
+ (doc) => deliberateClosingReferences(doc.text, rawScan(doc)).length > 0,
548
+ )
549
+
550
+ if (closingIssuesReferences.length > 0 && !deliberate) {
551
+ return { ok: false, kind: 'ground-truth-mismatch', closingIssuesReferences }
436
552
  }
437
553
 
438
554
  const hits = docs
439
- .map((doc) => ({ source: doc.source, references: closingReferences(doc.text) }))
555
+ .map((doc) => ({
556
+ source: doc.source,
557
+ isCommit: doc.kind === 'commit',
558
+ references: closingReferences(doc.text, rawScan(doc)),
559
+ }))
440
560
  .filter((h) => h.references.length > 0)
441
561
  if (hits.length === 0) return { ok: true, reason: 'no-closing-keyword' }
442
562
 
443
563
  const paths = deployOnlyPaths(changedFiles)
444
- if (paths.length === 0) return { ok: true, reason: 'no-deploy-only-paths' }
564
+ if (paths.length === 0) {
565
+ // ── 3b. Ground truth, extended to the document GitHub actually squashes
566
+ // (#1732) ──────────────────────────────────────────────────────────────
567
+ //
568
+ // `closingIssuesReferences` is GitHub's OWN ground truth for what the PR
569
+ // BODY will close — but it structurally cannot see a commit message, and
570
+ // this repo's squash-merge composes the real merge commit from commit
571
+ // messages verbatim (`squash_merge_commit_message = COMMIT_MESSAGES`).
572
+ // A closing-keyword hit that lives only in a commit message is therefore
573
+ // something GitHub WILL act on that `closingIssuesReferences` can never
574
+ // confirm OR deny — the check above is a structural no-op for it, not a
575
+ // considered "safe". Real instance: PR #1730's body quoted the phrase
576
+ // "the one-word fix #1664 asked for" inside a markdown code span, so
577
+ // GitHub's PR-body linker correctly ignored it (closingIssuesReferences
578
+ // read `[]`) — but the identical phrase reached the actual squash commit
579
+ // verbatim from the branch's own commit message, WITHOUT a code span
580
+ // (a git commit message has no markdown semantics: a backtick there is
581
+ // two literal characters, not a code-span delimiter), and closed #1664
582
+ // one second after merge.
583
+ //
584
+ // So a commit-only hit gets the same reconciliation the body already
585
+ // gets from `closingIssuesReferences`, using the commit text itself as
586
+ // the ground truth `closingIssuesReferences` cannot supply: if a commit
587
+ // document carries a hit and nothing anywhere reads as deliberate, fail
588
+ // — regardless of path, and regardless of what `closingIssuesReferences`
589
+ // said, since it was never asked about this document.
590
+ //
591
+ // Known residual gap, same shape and same acceptance as
592
+ // `deliberateClosingReferences`'s own docstring: a deliberate close
593
+ // written as a mid-line parenthetical (`(closes #NNN)`) is not
594
+ // recognised as deliberate either, so it would ask for a reword it did
595
+ // not strictly need. Conservative direction only — see that docstring.
596
+ const commitHits = hits.filter((h) => h.isCommit)
597
+ if (commitHits.length > 0 && !deliberate) {
598
+ return { ok: false, kind: 'commit-ground-truth-mismatch', hits: commitHits }
599
+ }
600
+ return { ok: true, reason: 'no-deploy-only-paths' }
601
+ }
445
602
 
446
603
  if (hasVerifiedTrailer(body)) return { ok: true, reason: 'verified-trailer' }
447
604
 
@@ -452,6 +609,7 @@ export function assess({ body, title, commits, changedFiles, closingIssuesRefere
452
609
  export function formatFailure(result) {
453
610
  if (result.kind === 'negated-keyword') return formatNegatedFailure(result)
454
611
  if (result.kind === 'ground-truth-mismatch') return formatGroundTruthFailure(result)
612
+ if (result.kind === 'commit-ground-truth-mismatch') return formatCommitGroundTruthFailure(result)
455
613
  return formatDeployOnlyFailure(result)
456
614
  }
457
615
 
@@ -518,6 +676,48 @@ function formatGroundTruthFailure({ closingIssuesReferences }) {
518
676
  ].join('\n')
519
677
  }
520
678
 
679
+ function formatCommitGroundTruthFailure({ hits }) {
680
+ const refs = [...new Set(hits.flatMap((h) => h.references))]
681
+ return [
682
+ `A COMMIT message would close ${refs.join(', ')} on merge — found in:`,
683
+ '',
684
+ ...hits.map((h) => ` - ${h.source}: ${h.references.join(', ')}`),
685
+ '',
686
+ "GitHub's own `closingIssuesReferences` cannot see this: that field",
687
+ 'reflects only the PR body as GitHub itself parses it, and this repo',
688
+ "builds the real squash-merge commit from the branch's own commit",
689
+ 'messages verbatim (squash_merge_commit_message = COMMIT_MESSAGES) — a',
690
+ 'separate mechanism GitHub applies to that text with no markdown',
691
+ 'awareness at all: a backtick in a commit message is a literal',
692
+ 'character, not a code-span delimiter, so it does NOT protect a',
693
+ 'closing keyword there the way it would in the PR body.',
694
+ '',
695
+ 'This is #1732: PR #1730\'s body quoted "the one-word fix #1664 asked',
696
+ 'for" inside a markdown code span, so closingIssuesReferences correctly',
697
+ 'read [] — but the identical phrase, without a code span, was already',
698
+ "sitting in the branch's own commit message, and closed #1664 one",
699
+ 'second after merge.',
700
+ '',
701
+ 'Nothing in the PR body, title or commit messages reads as a DELIBERATE',
702
+ 'closing directive (a keyword+reference at the start of the document, a',
703
+ 'line, or a sentence). Either:',
704
+ ' - this close is NOT intended: reword the COMMIT (`git commit --amend`',
705
+ ' or an interactive rebase) so the keyword and reference are not',
706
+ ' adjacent, or move the reference into its own `Refs #N` line, and',
707
+ ' force-push; or',
708
+ ' - this close IS intended: make it a deliberate directive in the',
709
+ ' COMMIT — its own line, its own sentence, e.g. `Closes #1664` — so',
710
+ ' this file, and anyone reading `git log`, can tell the difference.',
711
+ '',
712
+ 'Editing the PR body does NOT fix this: the commit message is what',
713
+ 'reaches the squash-merge commit GitHub actually reads, independent of',
714
+ 'anything in the PR description. Re-run after amending and force-pushing',
715
+ '— commits are read live, so a re-run genuinely re-evaluates:',
716
+ '',
717
+ ' gh run rerun <run-id> --failed',
718
+ ].join('\n')
719
+ }
720
+
521
721
  function formatDeployOnlyFailure({ references, paths, hits }) {
522
722
  const shown = paths.slice(0, 10)
523
723
  const more = paths.length - shown.length
@@ -13,7 +13,9 @@
13
13
  * what this file's lexical scan calls "deliberate" — see
14
14
  * `deliberateClosingReferences` and the "3. Ground truth" section below
15
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.
16
+ * thing that actually acts, rather than trying to out-regex it. Extended
17
+ * in "3b" below (#1732) to a document `closingIssuesReferences` itself
18
+ * cannot see.
17
19
  *
18
20
  * ── Three documents, not one (#1334, #1362) ──────────────────────────────
19
21
  *
@@ -153,6 +155,73 @@
153
155
  * meaning. What IS achievable, and what this does, is refuse to let our own
154
156
  * heuristic's blind spot silently diverge from GitHub's actual behaviour: the
155
157
  * two are reconciled every time, and a mismatch fails rather than passing.
158
+ *
159
+ * ── 3b. Ground truth's own blind spot: it never reads a commit (#1732) ───
160
+ *
161
+ * Section 3 above reconciles this file's lexical model against
162
+ * `closingIssuesReferences` — but that field is itself only a MODEL of one
163
+ * of the three documents GitHub honours: it is GitHub's ground truth for the
164
+ * PR BODY, computed by the same markdown-aware linker that renders the PR
165
+ * page, and it structurally cannot see a commit message at all. This repo's
166
+ * squash-merge strategy is `squash_merge_commit_message = COMMIT_MESSAGES`
167
+ * (confirmed via `gh api repos/{owner}/{repo}` → `squash_merge_commit_title`/
168
+ * `_message`), so the actual merge commit GitHub creates is composed from the
169
+ * branch's own commit messages, verbatim — not from the PR body at all. A
170
+ * closing-keyword hit that lives only in a commit message is therefore
171
+ * something GitHub WILL act on that section 3's check is a structural no-op
172
+ * for, not a considered "safe": `closingIssuesReferences.length > 0` is
173
+ * simply never true for it, no matter how dangerous the commit text is.
174
+ *
175
+ * Real instance: merging PR #1730 (this very guard's own #1686 fix)
176
+ * spuriously closed unrelated issue #1664. Its body quoted the historical
177
+ * bug it was fixing — "the one-word fix #1664 asked for" — inside a markdown
178
+ * code span, so GitHub's PR-body linker correctly ignored it and
179
+ * `closingIssuesReferences` read `[]`, exactly what section 3 checks and
180
+ * exactly what let it through. The identical phrase reached the real squash
181
+ * commit unchanged, in the PR's own commit message — WITHOUT a code span
182
+ * there, because a git commit message has no markdown semantics at all: a
183
+ * backtick in one is two literal characters, not a code-span delimiter, and
184
+ * GitHub's push-based "closes on merge to the default branch" keyword scan
185
+ * is a completely different mechanism from the PR-body linker, with no
186
+ * concept of markdown to respect. #1664 closed one second after merge.
187
+ *
188
+ * This is also why `stripCode` cannot simply be applied to a commit-message
189
+ * document the way it is to the body/title: doing so would make this file's
190
+ * OWN lexical scan (`closingReferences`, `deliberateClosingReferences`,
191
+ * `negatedClosingReferences` — checks 1 and 2 above, not just this one) less
192
+ * sensitive than GitHub's real behaviour for that document, the same
193
+ * "guard reads a different document from the one that acts" shape #1362
194
+ * names, just one level further in: the guard was reading the RIGHT document
195
+ * (#1334 already fixed that) but modelling it with the WRONG renderer's
196
+ * rules. Every function above therefore takes a `{ code: false }` option
197
+ * (see `documentsFor`'s `kind` tag and `assess`'s `rawScan`) that skips
198
+ * `stripCode` for a `'commit'` document — never for `'body'`/`'title'`,
199
+ * where a code span is genuine, GitHub-honoured protection.
200
+ *
201
+ * With that in place, `assess` runs a second, independent ground-truth
202
+ * reconciliation scoped to commit documents alone, using the commit text's
203
+ * own (now un-stripped) lexical hit as the ground truth `closingIssuesReferences`
204
+ * can never supply: on an otherwise-safe (non-deploy-only) path, if a commit
205
+ * document carries a hit and nothing anywhere reads as deliberate, fail —
206
+ * `kind: 'commit-ground-truth-mismatch'`. A hit on a genuinely deploy-only
207
+ * path is still caught by check 1 regardless, exactly as before; this only
208
+ * closes the gap check 1 always had by design (ordinary paths pass) and
209
+ * section 3 could not close for this one document (ground truth never
210
+ * arrives). See `scripts/check-closing-keywords-ground-truth.test.sh` for
211
+ * the fail-first reproduction of PR #1730's exact real shape, plus the
212
+ * corpus cases either side of it.
213
+ *
214
+ * Level of fix: still 3 (fail closed), same reasoning as section 3 — a
215
+ * commit message is prose an author legitimately writes, so intent cannot be
216
+ * derived with certainty here either. Not made MORE strict than GitHub's own
217
+ * closing behaviour: GitHub will act on a commit-message hit regardless of
218
+ * position or backticks, and this check only refuses the ones this file
219
+ * cannot explain as intentional, using the identical `deliberateClosingReferences`
220
+ * heuristic and its identical, already-accepted trade-off (see that
221
+ * function's docstring for two real, intentional, mid-line-parenthetical
222
+ * closes this heuristic already did not recognise before this change,
223
+ * unrelated to commits — this does not introduce a new blind spot, it
224
+ * extends an existing, documented one to a new document).
156
225
  */
157
226
 
158
227
  /** Closing keywords GitHub actually acts on, per its own documentation. */
@@ -207,11 +276,20 @@ export function stripCode(body) {
207
276
  * The issue references a body would close on merge.
208
277
  *
209
278
  * Matches `Closes #12`, `fixes owner/repo#12` and the `Closes: #12` colon
210
- * form. Ignores keywords inside code — see `stripCode`.
279
+ * form. Ignores keywords inside code — see `stripCode` — UNLESS `{ code:
280
+ * false }` is passed, which skips that blanking entirely.
281
+ *
282
+ * `code` must be `false` for a COMMIT MESSAGE document (#1732): a git commit
283
+ * message has no markdown semantics, so a backtick there is two literal
284
+ * characters, not a code-span delimiter, and GitHub's push-based "closes on
285
+ * merge to the default branch" keyword scan reads it exactly that way — it
286
+ * is not the same renderer as the PR body/title, which genuinely are
287
+ * markdown and where `stripCode` correctly models GitHub's own linker. See
288
+ * `assess`'s `rawForDoc` and the module docstring's "3b" section.
211
289
  */
212
- export function closingReferences(body) {
290
+ export function closingReferences(body, { code = true } = {}) {
213
291
  if (!body) return []
214
- const withoutCode = stripCode(body)
292
+ const withoutCode = code ? stripCode(body) : body
215
293
  const pattern = new RegExp(`\\b(${CLOSING_KEYWORDS.join('|')})\\b:?\\s+(${REFERENCE})`, 'gi')
216
294
  return [...withoutCode.matchAll(pattern)].map((m) => m[2])
217
295
  }
@@ -254,14 +332,29 @@ const CLAUSE_DECORATION = '(?:[-*•]\\s+|\\d+[.)]\\s+|#{1,6}\\s+|\\*{1,2})*'
254
332
  * `closingReferences` still does for checks 1 and 2 above.
255
333
  *
256
334
  * 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.
335
+ * mid-line without sentence-ending punctuation before it reads as
336
+ * not-deliberate. That is the conservative direction — it can make the
337
+ * ground-truth check (below) ask for a clarifying reword it didn't strictly
338
+ * need, never the reverse. Two real instances, both intentional and both
339
+ * missed by this heuristic because a parenthesis is not a boundary this
340
+ * function looks for: `chore(core): mark services/pr-signer/ template-owned
341
+ * (closes #243/#548-shaped gap) (#581)` and `feat(cli): publish the CLI to
342
+ * npm as versioned `biffo` (closes #259) (#300)` (both real commit subjects,
343
+ * `git log --all --format='%B'`). Widening the boundary set to also start a
344
+ * clause after `(` was considered and rejected here: it would not even have
345
+ * caught either example (neither open-paren sits at a position this
346
+ * function currently recognises as a clause start), and it is exactly the
347
+ * kind of heuristic change #1628 warns against making without a full case
348
+ * matrix — see AGENTS.md. The remedy is the same as always: a `Closes #N` on
349
+ * its own line or sentence.
350
+ *
351
+ * `code`, same contract as `closingReferences` (#1732): pass `{ code: false
352
+ * }` for a commit-message document, since backticks are not markdown there
353
+ * and must not be treated as protection.
261
354
  */
262
- export function deliberateClosingReferences(text) {
355
+ export function deliberateClosingReferences(text, { code = true } = {}) {
263
356
  if (!text) return []
264
- const stripped = stripCode(text)
357
+ const stripped = code ? stripCode(text) : text
265
358
  const starts = new Set([0])
266
359
  const boundary = /\n|[.!?]\s+/g
267
360
  let m
@@ -333,11 +426,13 @@ const NEGATIONS = [
333
426
  * The negated closing references in a body, each with the line that carries
334
427
  * it — a guard that says only "no" gets worked around.
335
428
  *
336
- * Returns `[{ reference, line, lineNumber }]`, in body order.
429
+ * Returns `[{ reference, line, lineNumber }]`, in body order. `code`, same
430
+ * contract as `closingReferences` — pass `{ code: false }` for a commit
431
+ * message (#1732), since backticks do not protect text there.
337
432
  */
338
- export function negatedClosingReferences(body) {
433
+ export function negatedClosingReferences(body, { code = true } = {}) {
339
434
  if (!body) return []
340
- const text = stripCode(body)
435
+ const text = code ? stripCode(body) : body
341
436
  const authored = body.split('\n')
342
437
  const pattern = new RegExp(
343
438
  `(?:${NEGATIONS.join('|')})\\s+(?:${CLOSING_KEYWORDS.join('|')})\\b:?\\s+(${REFERENCE})`,
@@ -381,18 +476,26 @@ export function deployOnlyPaths(changedFiles) {
381
476
  * `commits` is the shape `gh pr view --json commits` returns: an array of
382
477
  * `{ messageHeadline, messageBody }`. Both are scanned — a keyword can sit
383
478
  * in either, and #1334's own repro had it in the headline.
479
+ *
480
+ * Each doc also carries `kind` — `'body'`, `'title'`, or `'commit'` (#1732).
481
+ * The PR body and title are genuinely markdown, rendered by GitHub's own PR
482
+ * page, so a code span in either is real protection. A commit message is
483
+ * neither: it has no markdown semantics for GitHub's push-based "closes on
484
+ * merge to the default branch" keyword scan, so `assess` must scan `'commit'`
485
+ * documents with `{ code: false }` — see that function and the module
486
+ * docstring's "3b" section.
384
487
  */
385
488
  export function documentsFor({ body, title, commits }) {
386
- const docs = [{ source: 'the PR body', text: body }]
387
- if (title) docs.push({ source: 'the PR title', text: title })
489
+ const docs = [{ source: 'the PR body', text: body, kind: 'body' }]
490
+ if (title) docs.push({ source: 'the PR title', text: title, kind: 'title' })
388
491
  const list = commits ?? []
389
492
  list.forEach((commit, i) => {
390
493
  const label = list.length === 1 ? 'the commit message' : `commit ${i + 1}`
391
494
  if (commit?.messageHeadline) {
392
- docs.push({ source: `${label} (subject)`, text: commit.messageHeadline })
495
+ docs.push({ source: `${label} (subject)`, text: commit.messageHeadline, kind: 'commit' })
393
496
  }
394
497
  if (commit?.messageBody) {
395
- docs.push({ source: `${label} (body)`, text: commit.messageBody })
498
+ docs.push({ source: `${label} (body)`, text: commit.messageBody, kind: 'commit' })
396
499
  }
397
500
  })
398
501
  return docs
@@ -420,28 +523,82 @@ export function documentsFor({ body, title, commits }) {
420
523
  * caller (and every existing test) keeps working unchanged, the same reason
421
524
  * `title`/`commits` are optional — see `documentsFor`.
422
525
  */
526
+ // A document is markdown, and therefore genuinely protected by a code span,
527
+ // only if GitHub's OWN renderer treats it that way. The PR body and title
528
+ // are; a commit message is not — see `documentsFor` and the module
529
+ // docstring's "3b" section (#1732). `closingReferences`, `deliberateClosingReferences`
530
+ // and `negatedClosingReferences` all take `{ code: false }` to mean "scan
531
+ // this raw, backticks are literal characters here".
532
+ const rawScan = (doc) => ({ code: doc.kind !== 'commit' })
533
+
423
534
  export function assess({ body, title, commits, changedFiles, closingIssuesReferences = [] }) {
424
535
  const docs = documentsFor({ body, title, commits })
425
536
 
426
537
  const negated = docs.flatMap((doc) =>
427
- negatedClosingReferences(doc.text).map((n) => ({ ...n, source: doc.source })),
538
+ negatedClosingReferences(doc.text, rawScan(doc)).map((n) => ({ ...n, source: doc.source })),
428
539
  )
429
540
  if (negated.length > 0) return { ok: false, kind: 'negated-keyword', negated }
430
541
 
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
- }
542
+ // Whether ANY document reads as a deliberate closing directive — shared
543
+ // between the two ground-truth checks below, since both ask the identical
544
+ // question ("is this hit something the author actually meant"), just
545
+ // triggered by two different sources of ground truth.
546
+ const deliberate = docs.some(
547
+ (doc) => deliberateClosingReferences(doc.text, rawScan(doc)).length > 0,
548
+ )
549
+
550
+ if (closingIssuesReferences.length > 0 && !deliberate) {
551
+ return { ok: false, kind: 'ground-truth-mismatch', closingIssuesReferences }
436
552
  }
437
553
 
438
554
  const hits = docs
439
- .map((doc) => ({ source: doc.source, references: closingReferences(doc.text) }))
555
+ .map((doc) => ({
556
+ source: doc.source,
557
+ isCommit: doc.kind === 'commit',
558
+ references: closingReferences(doc.text, rawScan(doc)),
559
+ }))
440
560
  .filter((h) => h.references.length > 0)
441
561
  if (hits.length === 0) return { ok: true, reason: 'no-closing-keyword' }
442
562
 
443
563
  const paths = deployOnlyPaths(changedFiles)
444
- if (paths.length === 0) return { ok: true, reason: 'no-deploy-only-paths' }
564
+ if (paths.length === 0) {
565
+ // ── 3b. Ground truth, extended to the document GitHub actually squashes
566
+ // (#1732) ──────────────────────────────────────────────────────────────
567
+ //
568
+ // `closingIssuesReferences` is GitHub's OWN ground truth for what the PR
569
+ // BODY will close — but it structurally cannot see a commit message, and
570
+ // this repo's squash-merge composes the real merge commit from commit
571
+ // messages verbatim (`squash_merge_commit_message = COMMIT_MESSAGES`).
572
+ // A closing-keyword hit that lives only in a commit message is therefore
573
+ // something GitHub WILL act on that `closingIssuesReferences` can never
574
+ // confirm OR deny — the check above is a structural no-op for it, not a
575
+ // considered "safe". Real instance: PR #1730's body quoted the phrase
576
+ // "the one-word fix #1664 asked for" inside a markdown code span, so
577
+ // GitHub's PR-body linker correctly ignored it (closingIssuesReferences
578
+ // read `[]`) — but the identical phrase reached the actual squash commit
579
+ // verbatim from the branch's own commit message, WITHOUT a code span
580
+ // (a git commit message has no markdown semantics: a backtick there is
581
+ // two literal characters, not a code-span delimiter), and closed #1664
582
+ // one second after merge.
583
+ //
584
+ // So a commit-only hit gets the same reconciliation the body already
585
+ // gets from `closingIssuesReferences`, using the commit text itself as
586
+ // the ground truth `closingIssuesReferences` cannot supply: if a commit
587
+ // document carries a hit and nothing anywhere reads as deliberate, fail
588
+ // — regardless of path, and regardless of what `closingIssuesReferences`
589
+ // said, since it was never asked about this document.
590
+ //
591
+ // Known residual gap, same shape and same acceptance as
592
+ // `deliberateClosingReferences`'s own docstring: a deliberate close
593
+ // written as a mid-line parenthetical (`(closes #NNN)`) is not
594
+ // recognised as deliberate either, so it would ask for a reword it did
595
+ // not strictly need. Conservative direction only — see that docstring.
596
+ const commitHits = hits.filter((h) => h.isCommit)
597
+ if (commitHits.length > 0 && !deliberate) {
598
+ return { ok: false, kind: 'commit-ground-truth-mismatch', hits: commitHits }
599
+ }
600
+ return { ok: true, reason: 'no-deploy-only-paths' }
601
+ }
445
602
 
446
603
  if (hasVerifiedTrailer(body)) return { ok: true, reason: 'verified-trailer' }
447
604
 
@@ -452,6 +609,7 @@ export function assess({ body, title, commits, changedFiles, closingIssuesRefere
452
609
  export function formatFailure(result) {
453
610
  if (result.kind === 'negated-keyword') return formatNegatedFailure(result)
454
611
  if (result.kind === 'ground-truth-mismatch') return formatGroundTruthFailure(result)
612
+ if (result.kind === 'commit-ground-truth-mismatch') return formatCommitGroundTruthFailure(result)
455
613
  return formatDeployOnlyFailure(result)
456
614
  }
457
615
 
@@ -518,6 +676,48 @@ function formatGroundTruthFailure({ closingIssuesReferences }) {
518
676
  ].join('\n')
519
677
  }
520
678
 
679
+ function formatCommitGroundTruthFailure({ hits }) {
680
+ const refs = [...new Set(hits.flatMap((h) => h.references))]
681
+ return [
682
+ `A COMMIT message would close ${refs.join(', ')} on merge — found in:`,
683
+ '',
684
+ ...hits.map((h) => ` - ${h.source}: ${h.references.join(', ')}`),
685
+ '',
686
+ "GitHub's own `closingIssuesReferences` cannot see this: that field",
687
+ 'reflects only the PR body as GitHub itself parses it, and this repo',
688
+ "builds the real squash-merge commit from the branch's own commit",
689
+ 'messages verbatim (squash_merge_commit_message = COMMIT_MESSAGES) — a',
690
+ 'separate mechanism GitHub applies to that text with no markdown',
691
+ 'awareness at all: a backtick in a commit message is a literal',
692
+ 'character, not a code-span delimiter, so it does NOT protect a',
693
+ 'closing keyword there the way it would in the PR body.',
694
+ '',
695
+ 'This is #1732: PR #1730\'s body quoted "the one-word fix #1664 asked',
696
+ 'for" inside a markdown code span, so closingIssuesReferences correctly',
697
+ 'read [] — but the identical phrase, without a code span, was already',
698
+ "sitting in the branch's own commit message, and closed #1664 one",
699
+ 'second after merge.',
700
+ '',
701
+ 'Nothing in the PR body, title or commit messages reads as a DELIBERATE',
702
+ 'closing directive (a keyword+reference at the start of the document, a',
703
+ 'line, or a sentence). Either:',
704
+ ' - this close is NOT intended: reword the COMMIT (`git commit --amend`',
705
+ ' or an interactive rebase) so the keyword and reference are not',
706
+ ' adjacent, or move the reference into its own `Refs #N` line, and',
707
+ ' force-push; or',
708
+ ' - this close IS intended: make it a deliberate directive in the',
709
+ ' COMMIT — its own line, its own sentence, e.g. `Closes #1664` — so',
710
+ ' this file, and anyone reading `git log`, can tell the difference.',
711
+ '',
712
+ 'Editing the PR body does NOT fix this: the commit message is what',
713
+ 'reaches the squash-merge commit GitHub actually reads, independent of',
714
+ 'anything in the PR description. Re-run after amending and force-pushing',
715
+ '— commits are read live, so a re-run genuinely re-evaluates:',
716
+ '',
717
+ ' gh run rerun <run-id> --failed',
718
+ ].join('\n')
719
+ }
720
+
521
721
  function formatDeployOnlyFailure({ references, paths, hits }) {
522
722
  const shown = paths.slice(0, 10)
523
723
  const more = paths.length - shown.length
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biffo/cli",
3
- "version": "0.298.26",
3
+ "version": "0.298.28",
4
4
  "description": "Biffo project scaffolding CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",