diff --git a/docs/specifications/APR-RELEASE-001-train-and-build-kaizen.md b/docs/specifications/APR-RELEASE-001-train-and-build-kaizen.md index b96f30d350..2263e68c89 100644 --- a/docs/specifications/APR-RELEASE-001-train-and-build-kaizen.md +++ b/docs/specifications/APR-RELEASE-001-train-and-build-kaizen.md @@ -563,7 +563,7 @@ ratio (R-5) is the control on that; capacity only decides what a date is allowed | Predicate | Derive with | Fix | |---|---|---| | R-1 fixed-but-open | open issues cited by a `main` commit since the previous tag whose subject/body uses `Closes/Fixes/Resolves #N` or is `fix(...)`: must be ∅ | close with the commit sha as receipt | -| R-2 closing-reference | merged PRs since the previous tag whose body cites an issue without a closing keyword: must be ∅ going forward (guard `scripts/check_pr_closes_issue.sh` on the PR; `Refs #N` is allowed only with `no-close:` and a reason) | the guard refuses the PR | +| R-2 closing-reference | merged PRs since the previous tag whose body cites an issue without a closing keyword: must be ∅ going forward (guard `scripts/check_pr_closes_issue.sh` on the PR; `Refs #N` is allowed only with `keep-open:` and a reason — not `no-close:`, which GitHub's parser reads as a real closing keyword despite the negating prefix, #3400) | the guard refuses the PR | | R-3 dead branches | remote branches with no open PR and a tip older than 14 d: must be ∅ | archive to `refs/archive/` (objects kept, reversible), then delete the head | | R-4 dirty PRs | PRs `DIRTY` for more than one train: must be ∅ | a verdict per PR in the train: rebase (union tool, or by hand) or close with the reason | | R-5 ratio | `closure / arrival` over the train window, recorded; ratchet as below | — | diff --git a/scripts/check_pr_closes_issue.sh b/scripts/check_pr_closes_issue.sh index cf1c4c69ab..ba375ce879 100755 --- a/scripts/check_pr_closes_issue.sh +++ b/scripts/check_pr_closes_issue.sh @@ -61,7 +61,16 @@ REF_RE='#[0-9]+' # bare "close: #N" (that is CLOSE_RE's job, and is fine -- it really does # close). "no-close:", "wont-fix:", "skip-resolve:" all match; "Closes #123" # does not (nothing precedes "Closes"). -NOCLOSE_LANDMINE_RE='[A-Za-z]+-(close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)[[:space:]]*:[[:space:]]*([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)?#[0-9]+' +# +# The colon is OPTIONAL, matching CLOSE_RE's own `:?` exactly -- an +# independent review of the first cut of this fix (evidence/pr-review/3497) +# found that a colon-less body ("no-fix #123 tracked elsewhere") satisfied +# CLOSE_RE (which never required the colon) while failing to satisfy a +# landmine regex that mandated one, so it was judged a VALID closing +# reference instead of the #3400 landmine it actually is. Confirmed as a real +# gap, not a false alarm: `no-fix #123` reproduces the exact bug this guard +# exists to catch, just spelled without the colon. +NOCLOSE_LANDMINE_RE='[A-Za-z]+-(close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)[[:space:]]*:?[[:space:]]*([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)?#[0-9]+' # A REFERENCE TO A PULL REQUEST IS NOT AN UN-CLOSED ISSUE. # @@ -234,6 +243,11 @@ STUB write_case_landmine_noclose="no-close: #123" write_case_landmine_wontfix=$'Closes #999\nwont-fix: #123 -- tracked separately' write_case_landmine_mixed_with_valid_close=$'Closes #456\nno-close: #123 stays open for the GDN device path' + # PR #3497's own independent review (evidence/pr-review/3497) found this row + # missing: the colon is optional in CLOSE_RE, so it must be optional here too, + # or "no-fix #123" (no colon) reads as a VALID closing reference instead of + # the landmine it is. + write_case_landmine_no_colon="no-fix #123 tracked elsewhere" run_case() { name="$1" @@ -263,6 +277,7 @@ STUB run_case "landmine-noclose" "$write_case_landmine_noclose" 1 run_case "landmine-wontfix" "$write_case_landmine_wontfix" 1 run_case "landmine-mixed-with-valid-close" "$write_case_landmine_mixed_with_valid_close" 1 + run_case "landmine-no-colon" "$write_case_landmine_no_colon" 1 # --- a PR reference is not an un-closed issue --------------------------- run_kind_case() { # run_kind_case NAME BODY WANT [CMD]