diff --git a/packages/loopover-engine/src/signals/predicted-gate-engine.ts b/packages/loopover-engine/src/signals/predicted-gate-engine.ts index fa2de78173..7360d4ed22 100644 --- a/packages/loopover-engine/src/signals/predicted-gate-engine.ts +++ b/packages/loopover-engine/src/signals/predicted-gate-engine.ts @@ -362,7 +362,11 @@ export function buildPreflightResult( registryEverSynced = true, ): PreflightResult { const lane = buildLaneAdvice(repo, input.repoFullName); - const linkedIssues = [...new Set([...(input.linkedIssues ?? []), ...extractLinkedIssueNumbers(truncateText(input.body ?? "", PREFLIGHT_LIMITS.bodyChars), input.repoFullName)])].sort( + const linkedIssueExtraction = extractLinkedIssueNumbersWithOverflow( + truncateText(input.body ?? "", PREFLIGHT_LIMITS.bodyChars), + input.repoFullName, + ); + const linkedIssues = [...new Set([...(input.linkedIssues ?? []), ...linkedIssueExtraction.numbers])].sort( (left, right) => left - right, ); // Flag an existing open-work cluster as a possible duplicate when it shares a @@ -421,6 +425,19 @@ export function buildPreflightResult( action: "Link the issue being solved, or explicitly explain why this is a no-issue PR.", }); } + // Mirror the maintainer-side linked-issue hard rule (src/review/linked-issue-hard-rules.ts's + // extractLinkedIssueNumbersWithOverflow branch): a PR body citing more than the cap of closing references is + // hard-failed upstream because too many to verify safely. Raise the same blocker locally so miners see the + // failure before pushing instead of a silently-truncated linkedIssues list that looks fine (#8868). + if (linkedIssueExtraction.overflow) { + findings.push({ + code: "linked_issue_overflow", + severity: "critical", + title: "PR body links more issues than the maintainer gate can safely verify", + detail: "PR body links more issues than LoopOver can safely verify automatically; please reduce linked closing references or request maintainer review.", + action: `Reduce closing references to at most ${MAX_LINKED_ISSUE_NUMBERS} or request maintainer review.`, + }); + } if (collisions.length > 0) { findings.push({ code: "possible_duplicate_work", @@ -921,7 +938,12 @@ export function tokenize(value: string): string[] { * 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[] { +type LinkedIssueExtractionResult = { + numbers: number[]; + overflow: boolean; +}; + +function extractLinkedIssueNumbersWithOverflow(text: string, repoFullName: string): LinkedIssueExtractionResult { // 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 @@ -956,9 +978,18 @@ function extractLinkedIssueNumbers(text: string, repoFullName: string): number[] // 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); + // Also SURFACE overflow (not just silently truncate, #8868): the maintainer gate hard-fails a body with more + // than the cap of closing references, so the local prediction has to raise the same blocker instead of + // returning a truncated list that looks fine. + const unique = [...new Set(numbers.filter((value) => Number.isInteger(value) && value > 0))]; + return { + numbers: unique.slice(0, MAX_LINKED_ISSUE_NUMBERS), + overflow: unique.length > MAX_LINKED_ISSUE_NUMBERS, + }; +} + +function extractLinkedIssueNumbers(text: string, repoFullName: string): number[] { + return extractLinkedIssueNumbersWithOverflow(text, repoFullName).numbers; } function isMaintainerAssociation(value: string | null | undefined): boolean { @@ -1015,6 +1046,7 @@ export const predictedGateEngineInternals = { sharesMeaningfulFile, truncateText, extractLinkedIssueNumbers, + extractLinkedIssueNumbersWithOverflow, changeScopeEvidence, reviewLoadComponentScore, validationComponent, diff --git a/test/unit/predicted-gate-engine.test.ts b/test/unit/predicted-gate-engine.test.ts index 3698e42710..0b70e31423 100644 --- a/test/unit/predicted-gate-engine.test.ts +++ b/test/unit/predicted-gate-engine.test.ts @@ -360,6 +360,60 @@ describe("predicted-gate engine module coverage (#2283)", () => { expect(preflight.linkedIssues.every((n) => Number.isInteger(n) && n > 0)).toBe(true); }); + // #8868: the local extractor used to silently truncate at MAX_LINKED_ISSUE_NUMBERS while the maintainer's own + // linked-issue hard rule (src/review/linked-issue-hard-rules.ts) hard-FAILS a body carrying more references + // than the cap. So a miner's local prediction looked ready even though the upstream gate would close the PR + // outright — the whole point of the predicted-gate mirror is to catch that before the push. + it("REGRESSION (#8868): emits the maintainer gate's linked-issue overflow blocker when a body carries more than the cap of closing references", () => { + // 51 distinct same-repo closing references — one over the cap, the smallest overflow the extractor can see. + const body = Array.from({ length: 51 }, (_, index) => `Closes #${index + 1}`).join("\n"); + + const preflight = buildPreflightResult( + { repoFullName: "acme/widgets", title: "Too many links", body, linkedIssues: [] }, + REPO, + [], + [], + ); + + // The parse still truncates at the cap (leaving downstream signal building unchanged), but overflow is now + // surfaced as a critical blocker matching the maintainer gate's own message so miners see it before pushing. + expect(preflight.linkedIssues).toHaveLength(50); + const overflow = preflight.findings.find((finding) => finding.code === "linked_issue_overflow"); + expect(overflow).toBeDefined(); + expect(overflow?.severity).toBe("critical"); + expect(overflow?.detail).toBe( + "PR body links more issues than LoopOver can safely verify automatically; please reduce linked closing references or request maintainer review.", + ); + // Critical findings must lift the preflight status above "ready" so the miner treats the run as blocked. + expect(preflight.status).toBe("needs_work"); + // Direct extractor call also reports the overflow flag, matching the maintainer helper's shape. + expect(predictedGateEngineInternals.extractLinkedIssueNumbersWithOverflow(body, "acme/widgets").overflow).toBe(true); + }); + + it("does NOT flag overflow when the body sits at or under the cap of unique closing references", () => { + // Exactly at the cap: the maintainer helper returns overflow=false for length === cap. + const atCap = Array.from({ length: 50 }, (_, index) => `Closes #${index + 1}`).join("\n"); + const preflightAtCap = buildPreflightResult( + { repoFullName: "acme/widgets", title: "At cap", body: atCap, linkedIssues: [] }, + REPO, + [], + [], + ); + expect(preflightAtCap.findings.some((finding) => finding.code === "linked_issue_overflow")).toBe(false); + expect(predictedGateEngineInternals.extractLinkedIssueNumbersWithOverflow(atCap, "acme/widgets").overflow).toBe(false); + + // Dedupe happens BEFORE the overflow test: 100 lines that resolve to 50 unique issues do not overflow either. + const dedupedBody = Array.from({ length: 100 }, (_, index) => `Closes #${(index % 50) + 1}`).join("\n"); + const preflightDeduped = buildPreflightResult( + { repoFullName: "acme/widgets", title: "Deduped", body: dedupedBody, linkedIssues: [] }, + REPO, + [], + [], + ); + expect(preflightDeduped.findings.some((finding) => finding.code === "linked_issue_overflow")).toBe(false); + expect(predictedGateEngineInternals.extractLinkedIssueNumbersWithOverflow(dedupedBody, "acme/widgets").overflow).toBe(false); + }); + it("never leaks public-unsafe wantedPaths/preferredLabels into contributor-facing guidance (#6770)", () => { // wantedPaths and preferredLabels are freeform maintainer-authored text that is never public-safety-checked // at parse time, so buildFocusManifestGuidance must filter them before interpolating them into a finding.