diff --git a/src/signals/local-branch.ts b/src/signals/local-branch.ts index a564ef01fc..dda18172f5 100644 --- a/src/signals/local-branch.ts +++ b/src/signals/local-branch.ts @@ -246,6 +246,7 @@ export function buildLocalBranchAnalysis(args: { testFiles, linkedIssueCount: preflight.linkedIssues.length, linkedIssueContext, + duplicateRiskCount: preflight.collisions.filter((cluster) => cluster.risk === "high").length, roleContext, outcomeHistory: args.outcomeHistory, repoOutcome, @@ -388,6 +389,7 @@ function buildLocalScoreInput(args: { testFiles: string[]; linkedIssueCount: number; linkedIssueContext?: LinkedIssueMultiplierContext | undefined; + duplicateRiskCount: number; roleContext: RoleContext; outcomeHistory: ContributorOutcomeHistory; repoOutcome?: ContributorOutcomeHistory["repoOutcomes"][number] | undefined; @@ -424,6 +426,7 @@ function buildLocalScoreInput(args: { observedDraftPrCount: args.observedPullRequestScenarios.draft, observedBlockedPrCount: args.observedPullRequestScenarios.blocked, observedMaintainerPrCount: args.observedPullRequestScenarios.maintainerLane, + duplicateRiskCount: args.duplicateRiskCount, expectedOpenPrCountAfterMerge: args.input.expectedOpenPrCountAfterMerge, projectedCredibility: args.input.projectedCredibility, scenarioNotes: args.input.scenarioNotes, diff --git a/src/signals/reward-risk.ts b/src/signals/reward-risk.ts index 96447f3d8c..9c17cf8b57 100644 --- a/src/signals/reward-risk.ts +++ b/src/signals/reward-risk.ts @@ -197,6 +197,7 @@ export function buildRepoRewardRisk(args: { existingContributorTokenScore: 0, credibility, metadataOnly: true, + duplicateRiskCount: collisions.summary.highRiskCount, }; const currentPreview = buildScorePreview({ input: { ...commonPreviewInput, openPrCount: currentOpenPrCount }, diff --git a/test/unit/local-branch.test.ts b/test/unit/local-branch.test.ts index 9e218b26a8..43d16474d4 100644 --- a/test/unit/local-branch.test.ts +++ b/test/unit/local-branch.test.ts @@ -63,6 +63,36 @@ describe("local branch analysis", () => { expect(JSON.stringify(analysis.prPacket)).not.toMatch(/reward|score|wallet|hotkey|farming|payout|ranking|trust score/i); }); + it("threads duplicate collision risk into private scoreability blockers", () => { + const analysis = buildLocalBranchAnalysis({ + input: { + login: "oktofeesh1", + repoFullName: repo.fullName, + branchName: "fix-cache-duplicate", + title: "Fix dashboard cache refresh after reconnect", + linkedIssues: [7], + changedFiles: [{ path: "src/cache.ts", additions: 12, deletions: 2, status: "modified" }], + localScorer: { mode: "external_command", sourceTokenScore: 20, totalTokenScore: 30, sourceLines: 14 }, + }, + repo, + issues: [{ repoFullName: repo.fullName, number: 7, title: "Dashboard cache refresh fails after reconnect", state: "open", labels: ["bug"], linkedPrs: [10, 11] }], + pullRequests: [ + { repoFullName: repo.fullName, number: 10, title: "Fix dashboard cache refresh", state: "open", authorLogin: "other-dev", authorAssociation: "NONE", labels: [], linkedIssues: [7], body: "", updatedAt: "2026-05-25T00:00:00.000Z" }, + { repoFullName: repo.fullName, number: 11, title: "Repair dashboard reconnect cache", state: "open", authorLogin: "third-dev", authorAssociation: "NONE", labels: [], linkedIssues: [7], body: "", updatedAt: "2026-05-25T00:00:00.000Z" }, + ], + profile, + outcomeHistory, + scoringSnapshot, + scoringProfile, + }); + + expect(analysis.preflight.collisions.filter((cluster) => cluster.risk === "high").length).toBeGreaterThan(0); + expect(analysis.scorePreview.blockedBy).toEqual( + expect.arrayContaining([expect.objectContaining({ code: "duplicate_risk", severity: "reducer" })]), + ); + expect(analysis.scorePreview.warnings.join(" ")).toMatch(/duplicate-risk/i); + }); + it("bounds local scorer warnings before adding local findings", () => { const analysis = buildLocalBranchAnalysis({ input: { diff --git a/test/unit/signals-coverage.test.ts b/test/unit/signals-coverage.test.ts index 583bb8b206..c59969de0e 100644 --- a/test/unit/signals-coverage.test.ts +++ b/test/unit/signals-coverage.test.ts @@ -511,6 +511,39 @@ describe("signal coverage edge cases", () => { expect(issueCleanup.actions.map((action) => action.actionKind)).not.toContain("land_existing_prs"); }); + it("threads duplicate collision risk into reward-risk scoreability blockers", () => { + const targetRepo = repo("owner/duplicates"); + const profile = buildContributorProfile("dev", { login: "dev", topLanguages: ["TypeScript"], source: "github" }, [], []); + const history = buildContributorOutcomeHistory({ + login: "dev", + profile, + repositories: [targetRepo], + pullRequests: [], + issues: [], + repoStats: [], + }); + const fit = buildContributorFit(profile, [targetRepo], [], [], [], []); + const result = buildRepoRewardRisk({ + login: "dev", + repo: targetRepo, + repoFullName: targetRepo.fullName, + profile, + outcomeHistory: history, + scoringSnapshot: scoringSnapshot(), + scoringProfile: buildContributorScoringProfile({ login: "dev", fit, scoringSnapshot: scoringSnapshot() }), + issues: [issue(targetRepo.fullName, 7, "Dashboard cache refresh fails after reconnect", { linkedPrs: [10, 11] })], + pullRequests: [ + pr(targetRepo.fullName, 10, "Fix dashboard cache refresh", { linkedIssues: [7] }), + pr(targetRepo.fullName, 11, "Repair dashboard reconnect cache", { linkedIssues: [7] }), + ], + }); + + expect(result.riskBreakdown.highRiskDuplicateClusters).toBeGreaterThan(0); + expect(result.currentPreview.blockedBy).toEqual( + expect.arrayContaining([expect.objectContaining({ code: "duplicate_risk", severity: "reducer" })]), + ); + }); + it("flags possible duplicate work when the planned title overlaps an existing cluster", () => { // Regression: previously the preflight used `item.title.includes(input.title)`, // so a longer/more descriptive planned title never matched a shorter existing