diff --git a/actions/issueflow-reconcile/issueflow-reconcile.sh b/actions/issueflow-reconcile/issueflow-reconcile.sh index faf6c8d..1d00b4c 100644 --- a/actions/issueflow-reconcile/issueflow-reconcile.sh +++ b/actions/issueflow-reconcile/issueflow-reconcile.sh @@ -208,14 +208,14 @@ claim_reclaim_marker() { # $1 = last activity epoch refs_references() { # PR body on stdin -> local issue numbers named by Refs awk ' { - line = $0 - lower = tolower(line) - if (match(lower, /(^|[^[:alnum:]_-])refs[[:space:]:]+/)) { - line = substr(line, RSTART + RLENGTH) - if (line ~ /^(#|([[:alnum:]_.-]+\/)?[[:alnum:]_.-]+#)[0-9]+/) { - sub(/[.(;].*/, "", line) - print line - } + rest = tolower($0) + while (match(rest, /(^|[^[:alnum:]_-])refs[[:space:]:]+(#|([[:alnum:]_.-]+\/)?[[:alnum:]_.-]+#)[0-9]+/)) { + token = substr(rest, RSTART, RLENGTH) + sub(/^.*refs[[:space:]:]+/, "", token) + print token + # Retain the token final byte so ^ cannot turn a concatenated + # alphanumeric suffix into a fresh keyword boundary (#234). + rest = substr(rest, RSTART + RLENGTH - 1) } } ' | issue_references \ diff --git a/changelog.d/234.md b/changelog.d/234.md new file mode 100644 index 0000000..bbc2ed5 --- /dev/null +++ b/changelog.d/234.md @@ -0,0 +1,3 @@ +### Fixed + +- Refs-based issue-flow transitions now bind each declaration to its immediately following reference token, so later issue prose cannot release or preserve unrelated claims (#234). diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index 0d0d8ed..b09a8ff 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -101,6 +101,28 @@ check "empty labels do not exempt a claimed issue" 0 "SWEEP" claim_clock_exempt refs_body=$'Refs #12\nAlso refs: #8 and heavy-duty/rig#4.\nCloses #99\nNot refs-ish #7\nfix refs parsing from #200\nCloses #40; refs: none\nRefs #175 (split from #150)' check "Refs parser returns only references owned by a valid Refs marker" 0 "" \ test "$(refs_references <<<"$refs_body")" = $'8\n12\n175' +# A Refs declaration binds one token, never the prose that follows it; every +# later occurrence starts a fresh declaration (#234). +# shellcheck disable=SC2016 # backticks are the fixture's literal Markdown +crew_round_line='- **Claude verified the safe `Refs #52`, TDD provenance, and inherited #65 CI failure.**' +check "Refs parser ignores issue prose after the bound token" 0 "" \ + test "$(refs_references <<<"$crew_round_line")" = 52 +check "Refs comma-list narrowing is deliberate" 0 "" \ + test "$(refs_references <<<'Refs #8, #9')" = 8 +check "a cross-repo Refs token stays non-local" 0 "" \ + test -z "$(refs_references <<<'Refs heavy-duty/rig#4')" +check "prose after a cross-repo Refs token stays non-local" 0 "" \ + test -z "$(refs_references <<<'Refs heavy-duty/rig#4, #12')" +check "parenthesized prose after a local Refs token is ignored" 0 "" \ + test "$(refs_references <<<'Refs #175 (split from #150)')" = 175 +check "every Refs occurrence on one line contributes its bound token" 0 "" \ + test "$(refs_references <<<'Refs #8. Refs #9.')" = $'8\n9' +check "a concatenated Refs spelling is not a second keyword occurrence" 0 "" \ + test "$(refs_references <<<'Refs #8Refs #9')" = 8 +check "lowercase refs in unrelated prose declares nothing" 0 "" \ + test -z "$(refs_references <<<'fix refs parsing from #200')" +check "Refs without a following token declares nothing" 0 "" \ + test -z "$(refs_references <<<'Closes #40; refs: none')" open_records=$'BODY\tRefs #5\nCLOSING\t9\nBODY\tRefs heavy-duty/rig#112\nBODY\tRefs #5\nCLOSING\t5' check "open PR linkage unions closing and local Refs body references" 0 $'5\n9' \ open_pr_issues <<<"$open_records"