diff --git a/packages/loopover-engine/src/signals/predicted-gate-engine.ts b/packages/loopover-engine/src/signals/predicted-gate-engine.ts index 9a350c900a..eb69b9cfc7 100644 --- a/packages/loopover-engine/src/signals/predicted-gate-engine.ts +++ b/packages/loopover-engine/src/signals/predicted-gate-engine.ts @@ -918,18 +918,35 @@ export function tokenize(value: string): string[] { } function extractLinkedIssueNumbers(text: string, repoFullName: string): number[] { - const numbers = [...text.matchAll(/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)\b/gi)].map((match) => Number(match[1])); + // GitHub's native closing-keyword linker does not treat backtick-wrapped text as a real "Closes #N" directive, + // and this repo's own PR template contains "(e.g. `Closes #123`)". Reject regex hits that fall inside an inline + // code span, matching the canonical src/db/repositories.ts extractor; keep the original text (rather than + // blanking spans) so text on either side of a span can't combine into a fake closing reference. + const inlineCodeSpanRanges = [...text.matchAll(/`[^`\n]*`/g)].map((match) => ({ + start: match.index!, + end: match.index! + match[0].length, + })); + const insideCodeSpan = (match: RegExpMatchArray): boolean => { + const matchStart = match.index!; + const matchEnd = matchStart + match[0].length; + return inlineCodeSpanRanges.some((range) => matchStart < range.end && matchEnd > range.start); + }; + const numbers = [...text.matchAll(/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)\b/gi)] + .filter((match) => !insideCodeSpan(match)) + .map((match) => Number(match[1])); // GitHub also auto-closes via the fully-qualified `KEYWORD owner/repo#N` form (e.g. Renovate/Dependabot bodies). // Count it only when owner/repo case-insensitively equals THIS repo — a reference to a different repo closes an // issue elsewhere, not here, so it must not spoof a same-repo link. Same `\b`-anchored keywords as above (#1988). const target = repoFullName.toLowerCase(); for (const match of text.matchAll(/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+([\w.-]+\/[\w.-]+)#(\d+)\b/gi)) { + if (insideCodeSpan(match)) continue; if (match[1]!.toLowerCase() === target) numbers.push(Number(match[2])); } // GitHub's own linker ALSO recognizes the full issue URL form (`KEYWORD https://github.com/owner/repo/issues/N`) // -- a common habit (e.g. pasted from a browser address bar) that the two `#`-anchored forms above never match. // Same same-repo-only rule as the qualified form (#linked-issue-url-form). for (const match of text.matchAll(/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+https?:\/\/(?:www\.)?github\.com\/([\w.-]+\/[\w.-]+)\/issues\/(\d+)\b/gi)) { + if (insideCodeSpan(match)) continue; if (match[1]!.toLowerCase() === target) numbers.push(Number(match[2])); } return [...new Set(numbers.filter((value) => Number.isInteger(value) && value > 0))]; diff --git a/test/unit/predicted-gate-engine-coverage.test.ts b/test/unit/predicted-gate-engine-coverage.test.ts index 7627eed840..ebf34de596 100644 --- a/test/unit/predicted-gate-engine-coverage.test.ts +++ b/test/unit/predicted-gate-engine-coverage.test.ts @@ -1279,6 +1279,13 @@ describe("predicted-gate engine module coverage (#2283)", () => { expect(predictedGateEngineInternals.truncateText("short", 10)).toBe("short"); expect(predictedGateEngineInternals.truncateText("x".repeat(20), 10)).toHaveLength(10); expect(predictedGateEngineInternals.extractLinkedIssueNumbers(`closes ${REPO.fullName}#42`, REPO.fullName)).toContain(42); + // #6630: a backtick-wrapped reference (e.g. the unfilled PR-template boilerplate `Closes #123`) is NOT a real + // linked-issue directive, matching the canonical src/db/repositories.ts extractor; the same reference outside a + // code span still counts. + expect(predictedGateEngineInternals.extractLinkedIssueNumbers("See the template: `Closes #123` for the format.", REPO.fullName)).toEqual([]); + expect(predictedGateEngineInternals.extractLinkedIssueNumbers("Closes #123", REPO.fullName)).toEqual([123]); + expect(predictedGateEngineInternals.extractLinkedIssueNumbers(`ref \`closes ${REPO.fullName}#77\``, REPO.fullName)).toEqual([]); + expect(predictedGateEngineInternals.extractLinkedIssueNumbers(`ref \`closes https://github.com/${REPO.fullName}/issues/88\``, REPO.fullName)).toEqual([]); const failureWithQuality = evaluateGateCheck( { ...advisoryBase, findings: [{ code: "missing_linked_issue", severity: "warning", title: "issue", detail: "issue", action: "link it" }] },