diff --git a/src/scoring/preview.ts b/src/scoring/preview.ts index 6ef7de7540..94e5fa2c49 100644 --- a/src/scoring/preview.ts +++ b/src/scoring/preview.ts @@ -965,7 +965,11 @@ function inferCredibility(evidence?: ContributorEvidenceRecord | null): number { const merged = Number(payload?.mergedPullRequests ?? 0); const stale = Number(payload?.stalePullRequests ?? 0); const unlinked = Number(payload?.unlinkedPullRequests ?? 0); - if (!Number.isFinite(merged)) return 0.8; + // The payload is a loosely-typed cache (`Record`), so any count can arrive as a + // non-numeric value. A non-finite `stale`/`unlinked` propagates NaN through the arithmetic below — + // and `clamp` cannot rescue NaN — poisoning the whole credibility multiplier and score, so guard all + // three counts, not just `merged`, falling back to the neutral credibility. + if (!Number.isFinite(merged) || !Number.isFinite(stale) || !Number.isFinite(unlinked)) return 0.8; return clamp(0.75 + merged * 0.04 - stale * 0.03 - unlinked * 0.02, 0.25, 1); } diff --git a/test/unit/scoring.test.ts b/test/unit/scoring.test.ts index 7592bdbde7..0cfc09e2d7 100644 --- a/test/unit/scoring.test.ts +++ b/test/unit/scoring.test.ts @@ -4,7 +4,7 @@ import { DEFAULT_ISSUE_DISCOVERY_SHARE, DEFAULT_SCORING_CONSTANTS, detectActiveM import { buildScorePreview, calculateTimeDecay, makeScorePreviewRecord, resolveTimeDecay } from "../../src/scoring/preview"; import { unmodeledScoringConstantsFingerprint } from "../../src/upstream/unmodeled-scoring-drift"; import type { ScorePreviewInput } from "../../src/scoring/preview"; -import type { RepositoryRecord, ScoringModelSnapshotRecord } from "../../src/types"; +import type { JsonValue, RepositoryRecord, ScoringModelSnapshotRecord } from "../../src/types"; import { createTestEnv } from "../helpers/d1"; // A realistic constants.py body — at least MIN_RECOGNIZED_SCORING_CONSTANTS (8) recognized constants — so a @@ -1213,6 +1213,31 @@ NOVELTY_BONUS_SCALAR = 3 expect(fallbackCredibility.gates.baseTokenGatePassed).toBe(false); }); + it("falls back to neutral credibility when any evidence count is non-finite (not just mergedPullRequests)", () => { + const score = (payload: Record) => + buildScorePreview({ + repo, + snapshot, + contributorEvidence: { login: "riskdev", generatedAt: "2026-05-23T00:00:00.000Z", payload }, + input: { repoFullName: repo.fullName, sourceTokenScore: 100, totalTokenScore: 200, sourceLines: 10, openPrCount: 0 }, + }); + + // A malformed `stale` or `unlinked` would NaN-poison the credibility multiplier and the whole + // estimated score; each must degrade to the same neutral 0.8 the `merged` guard already produced. + for (const malformed of [ + { mergedPullRequests: 5, stalePullRequests: "n/a", unlinkedPullRequests: 0 }, + { mergedPullRequests: 5, stalePullRequests: 0, unlinkedPullRequests: "bad" }, + ] satisfies Record[]) { + const preview = score(malformed); + expect(preview.gates.credibilityObserved).toBe(0.8); + expect(Number.isFinite(preview.scoreEstimate.estimatedMergedScore)).toBe(true); + } + + // Well-formed counts still flow through the arithmetic rather than the guard. + const wellFormed = score({ mergedPullRequests: 5, stalePullRequests: 2, unlinkedPullRequests: 1 }); + expect(wellFormed.gates.credibilityObserved).toBeCloseTo(0.87, 5); + }); + it("refreshes scoring snapshots from upstream fixtures and falls back cleanly", async () => { const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "token" }); vi.stubGlobal("fetch", async (input: RequestInfo | URL) => {