diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index fe665240b..99f63791d 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -4338,6 +4338,12 @@ }, "issueCredibility": { "type": "number" + }, + "nonCodeLineCap": { + "type": "number" + }, + "nonCodeLinesObserved": { + "type": "number" } }, "required": [ @@ -4352,7 +4358,8 @@ "openIssueCount", "mergedPrFloor", "validSolvedIssuesFloor", - "issueCredibilityFloor" + "issueCredibilityFloor", + "nonCodeLineCap" ] }, "effectiveEstimatedScore": { @@ -4640,6 +4647,12 @@ }, "issueCredibility": { "type": "number" + }, + "nonCodeLineCap": { + "type": "number" + }, + "nonCodeLinesObserved": { + "type": "number" } }, "required": [ @@ -4654,7 +4667,8 @@ "openIssueCount", "mergedPrFloor", "validSolvedIssuesFloor", - "issueCredibilityFloor" + "issueCredibilityFloor", + "nonCodeLineCap" ] }, "effectiveEstimatedScore": { @@ -4942,6 +4956,12 @@ }, "issueCredibility": { "type": "number" + }, + "nonCodeLineCap": { + "type": "number" + }, + "nonCodeLinesObserved": { + "type": "number" } }, "required": [ @@ -4956,7 +4976,8 @@ "openIssueCount", "mergedPrFloor", "validSolvedIssuesFloor", - "issueCredibilityFloor" + "issueCredibilityFloor", + "nonCodeLineCap" ] }, "effectiveEstimatedScore": { @@ -5244,6 +5265,12 @@ }, "issueCredibility": { "type": "number" + }, + "nonCodeLineCap": { + "type": "number" + }, + "nonCodeLinesObserved": { + "type": "number" } }, "required": [ @@ -5258,7 +5285,8 @@ "openIssueCount", "mergedPrFloor", "validSolvedIssuesFloor", - "issueCredibilityFloor" + "issueCredibilityFloor", + "nonCodeLineCap" ] }, "effectiveEstimatedScore": { @@ -5546,6 +5574,12 @@ }, "issueCredibility": { "type": "number" + }, + "nonCodeLineCap": { + "type": "number" + }, + "nonCodeLinesObserved": { + "type": "number" } }, "required": [ @@ -5560,7 +5594,8 @@ "openIssueCount", "mergedPrFloor", "validSolvedIssuesFloor", - "issueCredibilityFloor" + "issueCredibilityFloor", + "nonCodeLineCap" ] }, "effectiveEstimatedScore": { @@ -6504,6 +6539,12 @@ }, "issueCredibility": { "type": "number" + }, + "nonCodeLineCap": { + "type": "number" + }, + "nonCodeLinesObserved": { + "type": "number" } }, "required": [ @@ -6518,7 +6559,8 @@ "openIssueCount", "mergedPrFloor", "validSolvedIssuesFloor", - "issueCredibilityFloor" + "issueCredibilityFloor", + "nonCodeLineCap" ] }, "branchEligibility": { @@ -6805,6 +6847,12 @@ }, "issueCredibility": { "type": "number" + }, + "nonCodeLineCap": { + "type": "number" + }, + "nonCodeLinesObserved": { + "type": "number" } }, "required": [ @@ -6819,7 +6867,8 @@ "openIssueCount", "mergedPrFloor", "validSolvedIssuesFloor", - "issueCredibilityFloor" + "issueCredibilityFloor", + "nonCodeLineCap" ] }, "effectiveEstimatedScore": { diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 356566f9a..f6766d071 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -1290,6 +1290,8 @@ const ScoreGatesSchema = z.object({ validSolvedIssues: z.number().optional(), issueCredibilityFloor: z.number(), issueCredibility: z.number().optional(), + nonCodeLineCap: z.number(), + nonCodeLinesObserved: z.number().optional(), }); const BranchEligibilitySchema = z.object({ diff --git a/src/scoring/preview.ts b/src/scoring/preview.ts index 03eafc934..ef1ee2bec 100644 --- a/src/scoring/preview.ts +++ b/src/scoring/preview.ts @@ -209,6 +209,11 @@ export type ScorePreviewResult = { issueCredibilityFloor: number; /** Observed issue-discovery credibility when supplied; absent when unknown. */ issueCredibility?: number | undefined; + /** Upstream non-code line scoring cap (MAX_LINES_SCORED_FOR_NON_CODE_EXT); non-code token score beyond this + * many changed non-code lines is not scored. */ + nonCodeLineCap: number; + /** Observed raw non-code line count before the cap; absent when no non-code line count was supplied. */ + nonCodeLinesObserved?: number | undefined; }; branchEligibility: BranchEligibilityResult; effectiveEstimatedScore: number; @@ -474,6 +479,8 @@ function computeScoreCore( ...(validSolvedIssuesObserved !== undefined ? { validSolvedIssues: validSolvedIssuesObserved } : {}), issueCredibilityFloor, ...(issueCredibilityObserved !== undefined ? { issueCredibility: issueCredibilityObserved } : {}), + nonCodeLineCap: constant(constants, "MAX_LINES_SCORED_FOR_NON_CODE_EXT"), + ...(input.nonCodeLines !== undefined ? { nonCodeLinesObserved: nonNegative(input.nonCodeLines) } : {}), }, }; } diff --git a/src/services/score-breakdown.ts b/src/services/score-breakdown.ts index 69e2a7f95..1d20e5873 100644 --- a/src/services/score-breakdown.ts +++ b/src/services/score-breakdown.ts @@ -275,6 +275,36 @@ function contributionBonusBreakdown(preview: ScorePreviewResult): ScoreMultiplie }; } +// Sibling of the history-floor breakdowns for the upstream non-code line cap (MAX_LINES_SCORED_FOR_NON_CODE_EXT): +// non-code token score beyond the cap's worth of changed non-code lines is not scored, so a docs/config-heavy PR +// can silently lose non-code contribution. Surfaced here so a miner sees the cap the same way the gate breakdowns +// already surface the open-PR / open-issue / merged-history floors. The cap is a token-score reducer (not a stacked +// multiplier), so it reads neutral unless the observed non-code line count actually exceeds the cap. +function nonCodeCapBreakdown(preview: ScorePreviewResult): ScoreMultiplierBreakdown { + const { nonCodeLineCap, nonCodeLinesObserved } = preview.gates; + if (nonCodeLinesObserved === undefined) { + return { + component: "nonCodeLineCap", + band: "neutral", + summary: `No scored non-code line count is observed for this preview (upstream scores at most ${nonCodeLineCap} non-code lines).`, + lever: "No action needed; the non-code line cap only affects previews that add scored non-code lines.", + leverageScore: 0, + }; + } + const capped = nonCodeLinesObserved > nonCodeLineCap; + return { + component: "nonCodeLineCap", + band: capped ? "reduced" : "neutral", + summary: capped + ? `Non-code lines (${nonCodeLinesObserved}) exceed the upstream scored cap (${nonCodeLineCap}); non-code token contribution beyond the cap is not scored.` + : `Non-code lines (${nonCodeLinesObserved}) are within the upstream scored cap (${nonCodeLineCap}).`, + lever: capped + ? "Non-code changes beyond the cap add no score; move substantive logic into source files or split the non-code bulk out of this PR." + : "Non-code contribution is within the scored cap; no action needed on this lever.", + leverageScore: capped ? 30 : 5, + }; +} + function roundBand(value: number): string { return value.toFixed(2).replace(/\.?0+$/, ""); } @@ -319,6 +349,7 @@ export function explainScoreBreakdown(preview: ScorePreviewResult): ScoreBreakdo openIssueBreakdown(preview), mergedHistoryBreakdown(preview), timeDecayBreakdown(preview), + nonCodeCapBreakdown(preview), ].map((entry) => ({ ...entry, summary: sanitizePublicComment(entry.summary), diff --git a/test/unit/score-breakdown.test.ts b/test/unit/score-breakdown.test.ts index 4a6038612..3b13c7ab5 100644 --- a/test/unit/score-breakdown.test.ts +++ b/test/unit/score-breakdown.test.ts @@ -86,6 +86,7 @@ describe("explainScoreBreakdown", () => { "openIssueMultiplier", "mergedHistoryMultiplier", "timeDecayMultiplier", + "nonCodeLineCap", ]), ); for (const component of breakdown.components) { @@ -122,6 +123,33 @@ describe("explainScoreBreakdown", () => { expect(JSON.stringify(blocked)).not.toMatch(FORBIDDEN); }); + it("explains the non-code line cap as neutral (unobserved / within cap) and reduced (over cap)", () => { + const base = { + repoFullName: repo.fullName, + contributorLogin: "miner", + sourceTokenScore: 40, + totalTokenScore: 60, + sourceLines: 80, + openPrCount: 1, + credibility: 0.9, + linkedIssueMode: "none" as const, + }; + // No non-code line count supplied -> the cap is not counted for this preview -> neutral, zero leverage. + const unobserved = explainScoreBreakdown(buildScorePreview({ repo, snapshot, input: base })); + expect(unobserved.components.find((entry) => entry.component === "nonCodeLineCap")).toMatchObject({ band: "neutral", leverageScore: 0 }); + + // Observed within the upstream cap (MAX_LINES_SCORED_FOR_NON_CODE_EXT = 300) -> neutral. + const within = explainScoreBreakdown(buildScorePreview({ repo, snapshot, input: { ...base, nonCodeLines: 10 } })); + expect(within.components.find((entry) => entry.component === "nonCodeLineCap")).toMatchObject({ band: "neutral", leverageScore: 5 }); + + // Observed over the cap -> reduced, with an actionable move-to-source lever. + const over = explainScoreBreakdown(buildScorePreview({ repo, snapshot, input: { ...base, nonCodeLines: 5000 } })); + const cap = over.components.find((entry) => entry.component === "nonCodeLineCap")!; + expect(cap).toMatchObject({ band: "reduced", leverageScore: 30 }); + expect(cap.summary).toMatch(/exceed/i); + expect(JSON.stringify(over)).not.toMatch(FORBIDDEN); + }); + it("explains an over-threshold open-issue count as a blocked open-issue spam gate", () => { const preview = buildScorePreview({ repo,