Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/scoring/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, JsonValue>`), 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);
}

Expand Down
27 changes: 26 additions & 1 deletion test/unit/scoring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, JsonValue>) =>
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<string, JsonValue>[]) {
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) => {
Expand Down
Loading