diff --git a/packages/loopover-engine/src/signals/predicted-gate-engine.ts b/packages/loopover-engine/src/signals/predicted-gate-engine.ts index e07f11fd20..717755d9fb 100644 --- a/packages/loopover-engine/src/signals/predicted-gate-engine.ts +++ b/packages/loopover-engine/src/signals/predicted-gate-engine.ts @@ -917,6 +917,10 @@ export function tokenize(value: string): string[] { .filter((term) => term.length > 2 && !STOPWORDS.has(term)); } +/** Mirrors `MAX_LINKED_ISSUE_NUMBERS` in `src/db/repositories.ts` — the ceiling the canonical extractor stops + * collecting at. Kept as a local literal because this module stays free of host imports by design (#6771). */ +const MAX_LINKED_ISSUE_NUMBERS = 50; + function extractLinkedIssueNumbers(text: string, repoFullName: string): number[] { // 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 @@ -949,7 +953,12 @@ function extractLinkedIssueNumbers(text: string, repoFullName: string): number[] 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))]; + // Cap at the same ceiling the canonical extractor enforces (#6771): src/db/repositories.ts's + // MAX_LINKED_ISSUE_NUMBERS = 50, which stops collecting once reached. Duplicated as a literal rather than + // imported because this module is host-import-free by design; the cross-reference above is the drift guard. + // Without it, a body with 50+ short closing references (easily within the 20k-char truncation this runs on) + // made the miner's local prediction diverge from the maintainer-side gate it exists to mirror. + return [...new Set(numbers.filter((value) => Number.isInteger(value) && value > 0))].slice(0, MAX_LINKED_ISSUE_NUMBERS); } function isMaintainerAssociation(value: string | null | undefined): boolean { diff --git a/test/unit/predicted-gate-engine-coverage.test.ts b/test/unit/predicted-gate-engine-coverage.test.ts index 94e0b19ae8..180c1212d9 100644 --- a/test/unit/predicted-gate-engine-coverage.test.ts +++ b/test/unit/predicted-gate-engine-coverage.test.ts @@ -275,6 +275,32 @@ describe("predicted-gate engine module coverage (#2283)", () => { expect(inactive.lane.lane).toBe("inactive"); }); + // #6771: the local extractor's own comment claims it matches the canonical src/db/repositories.ts extractor, + // which stops at MAX_LINKED_ISSUE_NUMBERS = 50 — but it collected every match uncapped. A body can easily fit + // 50+ short closing refs inside the 20k-char truncation this runs on, so the miner's local prediction could + // diverge from the maintainer-side gate it exists to mirror. + it("REGRESSION (#6771): caps extracted linked issues at the canonical 50, across all three reference forms", () => { + // 66 DISTINCT closing references: 30 bare `#N`, 18 qualified `owner/repo#N`, 18 full-URL — all same-repo. + const bare = Array.from({ length: 30 }, (_, i) => `Closes #${i + 1}`); + const qualified = Array.from({ length: 18 }, (_, i) => `Fixes acme/widgets#${i + 101}`); + const urls = Array.from({ length: 18 }, (_, i) => `Resolves https://github.com/acme/widgets/issues/${i + 201}`); + const body = [...bare, ...qualified, ...urls].join("\n"); + + const preflight = buildPreflightResult( + { repoFullName: "acme/widgets", title: "Many links", body, linkedIssues: [] }, + REPO, + [], + [], + ); + + // The body genuinely carries more than the cap, and the extractor's contribution is bounded at 50. + expect(bare.length + qualified.length + urls.length).toBeGreaterThan(50); + expect(preflight.linkedIssues).toHaveLength(50); + // Still real, deduped, positive issue numbers — the cap truncates, it doesn't corrupt. + expect(new Set(preflight.linkedIssues).size).toBe(50); + expect(preflight.linkedIssues.every((n) => Number.isInteger(n) && n > 0)).toBe(true); + }); + it("exercises manifest globstar path matching", () => { const manifest: FocusManifest = { present: true,