diff --git a/package-lock.json b/package-lock.json index 5ee2c1584e..bff2cecce7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9435,9 +9435,19 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -12382,9 +12392,9 @@ } }, "node_modules/tar": { - "version": "7.5.15", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz", - "integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==", + "version": "7.5.16", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.16.tgz", + "integrity": "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -13303,9 +13313,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "dev": true, "license": "MIT", "engines": { @@ -13487,7 +13497,7 @@ }, "packages/gittensory-mcp": { "name": "@jsonbored/gittensory-mcp", - "version": "0.5.0", + "version": "0.6.0", "license": "AGPL-3.0-only", "dependencies": { "@modelcontextprotocol/sdk": "1.29.0", diff --git a/package.json b/package.json index cd3a1949ce..ba49bb780b 100644 --- a/package.json +++ b/package.json @@ -96,7 +96,8 @@ }, "vite": { "esbuild": "^0.28.1" - } + }, + "ws": "^8.21.0" }, "main": "index.js", "directories": { diff --git a/packages/gittensory-mcp/bin/gittensory-mcp.js b/packages/gittensory-mcp/bin/gittensory-mcp.js index e664f4f84b..41a4f4f58c 100755 --- a/packages/gittensory-mcp/bin/gittensory-mcp.js +++ b/packages/gittensory-mcp/bin/gittensory-mcp.js @@ -410,6 +410,54 @@ server.registerTool( async (input) => toolResult("Gittensory private local PR scoring preview.", await previewLocalScore(await withClientWorkspaceRoots(input))), ); +server.registerTool( + "gittensory_explain_score_breakdown", + { + description: "Explain a private score preview multiplier-by-multiplier with plain-English levers and the highest-impact improvement.", + inputSchema: localScoreShape, + }, + async (input) => { + const workspaceInput = await withClientWorkspaceRoots(input); + const contributorLogin = workspaceInput.contributorLogin ?? activeProfile.session?.login; + if (!contributorLogin) throw new Error("contributorLogin is required for score breakdown."); + const workspace = resolveWorkspaceCwd(workspaceInput); + const diff = collectLocalDiff(workspace.cwd, workspaceInput.baseRef, workspaceInput.workspaceRoots); + const branchPayload = buildBranchAnalysisPayload({ + ...workspaceInput, + login: contributorLogin, + cwd: workspace.cwd, + repoFullName: workspaceInput.repoFullName, + baseRef: workspaceInput.baseRef, + }); + const upstreamPreview = branchPayload.localScorerStatus; + const estimatedSourceLines = workspaceInput.sourceLines ?? Math.max(1, diff.changedLineCount - diff.testFiles.length); + const body = { + repoFullName: workspaceInput.repoFullName, + targetType: "local_diff", + targetKey: workspaceInput.targetKey ?? localDiffTargetKey(branchPayload, workspaceInput.baseRef), + contributorLogin, + labels: workspaceInput.labels, + linkedIssueMode: workspaceInput.linkedIssueMode, + sourceTokenScore: workspaceInput.sourceTokenScore ?? estimatedSourceLines, + sourceLines: estimatedSourceLines, + totalTokenScore: workspaceInput.totalTokenScore ?? diff.changedLineCount, + testTokenScore: diff.testFiles.length, + openPrCount: workspaceInput.openPrCount, + credibility: workspaceInput.credibility, + changesRequestedCount: workspaceInput.changesRequestedCount, + pendingMergedPrCount: workspaceInput.pendingMergedPrCount, + pendingClosedPrCount: workspaceInput.pendingClosedPrCount, + approvedPrCount: workspaceInput.approvedPrCount, + expectedOpenPrCountAfterMerge: workspaceInput.expectedOpenPrCountAfterMerge, + projectedCredibility: workspaceInput.projectedCredibility, + scenarioNotes: workspaceInput.scenarioNotes, + branchEligibility: workspaceInput.branchEligibility, + metadataOnly: !upstreamPreview.ok, + }; + return toolResult("Gittensory private score breakdown.", await apiPost("/v1/scoring/explain-breakdown", body)); + }, +); + server.registerTool( "gittensory_get_decision_pack", { diff --git a/src/api/routes.ts b/src/api/routes.ts index d89041e58b..4a4f920bdb 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -131,6 +131,7 @@ import { preflightBranchWithAgent, startAgentRun, } from "../services/agent-orchestrator"; +import { explainScoreBreakdown } from "../services/score-breakdown"; import { buildMcpClientTelemetry } from "../services/client-telemetry"; import { buildAndPersistContributorDecisionPack, @@ -1458,6 +1459,22 @@ export function createApp() { return c.json(record); }); + app.post("/v1/scoring/explain-breakdown", async (c) => { + const body = await c.req.json().catch(() => null); + const parsed = scorePreviewSchema.safeParse(body); + if (!parsed.success) return c.json({ error: "invalid_scoring_preview_request", issues: parsed.error.issues }, 400); + if (!parsed.data.contributorLogin) return c.json({ error: "contributor_login_required" }, 400); + const unauthorized = await requireContributorAccess(c, parsed.data.contributorLogin); + if (unauthorized) return unauthorized; + const [repo, snapshot, evidence] = await Promise.all([ + getRepository(c.env, parsed.data.repoFullName), + getOrCreateScoringModelSnapshot(c.env), + getContributorEvidence(c.env, parsed.data.contributorLogin), + ]); + const preview = buildScorePreview({ input: parsed.data, repo, snapshot, contributorEvidence: evidence }); + return c.json(explainScoreBreakdown(preview)); + }); + app.get("/v1/sync/status", async (c) => { const [snapshot, scoringSnapshot, repositories, segments, totals, detailStates, installations, rateLimits, signalSnapshots, bounties, upstreamDrift] = await Promise.all([ getLatestRegistrySnapshot(c.env), diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 451c08a68a..ec0c43960e 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -52,6 +52,7 @@ import { } from "../services/agent-orchestrator"; import { loadContributorDecisionPackForServing, repoDecisionFromPack } from "../services/decision-pack"; import { buildPublicPrBodyDraft } from "../services/pr-body-draft"; +import { explainScoreBreakdown } from "../services/score-breakdown"; import { loadOrComputeIssueQualityResponse } from "../services/issue-quality"; import { loadOrComputeBurdenForecastResponse } from "../services/burden-forecast"; import { buildMcpClientTelemetry } from "../services/client-telemetry"; @@ -532,6 +533,15 @@ const checkBeforeStartOutputSchema = { report: z.unknown().optional(), }; +const scoreBreakdownOutputSchema = { + repoFullName: z.string().optional(), + scoreabilityStatus: z.string().optional(), + effectiveEstimatedScore: z.number().optional(), + components: z.unknown().optional(), + gateHighlights: z.unknown().optional(), + highestLeverageLever: z.unknown().optional(), +}; + const lintPrTextOutputSchema = { verdict: z.string().optional(), score: z.number().optional(), @@ -977,6 +987,17 @@ export class GittensoryMcp { async (input) => this.toolResult(await this.previewScore(input)), ); + server.registerTool( + "gittensory_explain_score_breakdown", + { + description: + "Explain a private score preview multiplier-by-multiplier with plain-English levers and the single highest-impact improvement. Login and repo scoped; no new computation beyond the preview projection.", + inputSchema: scorePreviewShape, + outputSchema: scoreBreakdownOutputSchema, + }, + async (input) => this.toolResult(await this.explainScoreBreakdown(input)), + ); + server.registerTool( "gittensory_explain_review_risk", { @@ -1689,6 +1710,23 @@ export class GittensoryMcp { }; } + private async explainScoreBreakdown(input: z.infer>): Promise { + if (!input.contributorLogin) throw new Error("contributorLogin is required for score breakdown."); + this.requireContributorAccess(input.contributorLogin); + await this.requireRepoAccess(input.repoFullName); + const [repo, snapshot, evidence] = await Promise.all([ + getRepository(this.env, input.repoFullName), + getOrCreateScoringModelSnapshot(this.env), + getContributorEvidence(this.env, input.contributorLogin), + ]); + const preview = buildScorePreview({ input, repo, snapshot, contributorEvidence: evidence }); + const breakdown = explainScoreBreakdown(preview); + return { + summary: `Private Gittensory score breakdown for ${input.contributorLogin} in ${input.repoFullName}. Highest leverage: ${breakdown.highestLeverageLever.component}.`, + data: breakdown as unknown as Record, + }; + } + private async explainReviewRisk(input: z.infer>): Promise { if (input.contributorLogin) this.requireContributorAccess(input.contributorLogin); await this.requireRepoAccess(input.repoFullName); diff --git a/src/services/score-breakdown.ts b/src/services/score-breakdown.ts new file mode 100644 index 0000000000..42df9c3123 --- /dev/null +++ b/src/services/score-breakdown.ts @@ -0,0 +1,229 @@ +import { sanitizePublicComment } from "../github/commands"; +import type { ScoreGateDelta, ScorePreviewResult } from "../scoring/preview"; + +export type ScoreMultiplierBand = "full" | "reduced" | "neutral" | "blocked"; + +export type ScoreMultiplierBreakdown = { + component: string; + band: ScoreMultiplierBand; + summary: string; + lever: string; + leverageScore: number; +}; + +export type ScoreBreakdownExplanation = { + repoFullName: string; + scoreabilityStatus: ScorePreviewResult["scoreabilityStatus"]; + effectiveEstimatedScore: number; + components: ScoreMultiplierBreakdown[]; + gateHighlights: Array<{ gate: ScoreGateDelta["gate"]; explanation: string }>; + highestLeverageLever: { + component: string; + lever: string; + reason: string; + }; +}; + +function bandForMultiplier(value: number, blockedAtZero = true): ScoreMultiplierBand { + if (blockedAtZero && value <= 0) return "blocked"; + if (value >= 0.99) return "full"; + if (value <= 0.01) return "blocked"; + return "reduced"; +} + +function densityBreakdown(preview: ScorePreviewResult): ScoreMultiplierBreakdown { + const { densityMultiplier, contributionBonus } = preview.scoreEstimate; + const baseGatePassed = preview.gates.baseTokenGatePassed; + const band = baseGatePassed ? bandForMultiplier(densityMultiplier, false) : "blocked"; + const summary = baseGatePassed + ? densityMultiplier >= 0.99 + ? "Code density is in a healthy range for the current change size." + : "Code density is below the typical full-strength range for this change size." + : "The change does not yet meet the minimum meaningful source-change threshold."; + const lever = baseGatePassed + ? densityMultiplier >= 0.99 + ? "Keep the diff focused on substantive source changes with clear scope." + : "Increase meaningful source changes or clarify scope so density is easier to review." + : "Add more substantive source changes or tighten the diff before relying on this preview."; + const leverageScore = baseGatePassed ? Math.round((1 - Math.min(densityMultiplier, 1)) * 50) : 75; + if (contributionBonus > 0 && leverageScore < 40) { + return { + component: "densityMultiplier", + band, + summary: `${summary} Contribution bonus is already contributing.`, + lever, + leverageScore, + }; + } + return { component: "densityMultiplier", band, summary, lever, leverageScore }; +} + +function openPrBreakdown(preview: ScorePreviewResult): ScoreMultiplierBreakdown { + const { openPrMultiplier } = preview.scoreEstimate; + const { openPrCount, openPrThreshold } = preview.gates; + const band = bandForMultiplier(openPrMultiplier); + return { + component: "openPrMultiplier", + band, + summary: + openPrMultiplier >= 1 + ? `Open PR count (${openPrCount}) is within the current allowance (${openPrThreshold}).` + : `Open PR count (${openPrCount}) exceeds the current allowance (${openPrThreshold}), so concurrent work is blocked.`, + lever: + openPrMultiplier >= 1 + ? "Keep concurrent open PRs within the allowance before starting more work." + : "Land, merge, or close existing open PRs before opening another concurrent contribution.", + leverageScore: openPrMultiplier >= 1 ? 5 : 100, + }; +} + +function credibilityBreakdown(preview: ScorePreviewResult): ScoreMultiplierBreakdown { + const { credibilityMultiplier } = preview.scoreEstimate; + const { credibilityObserved, credibilityFloor } = preview.gates; + const band = bandForMultiplier(credibilityMultiplier); + return { + component: "credibilityMultiplier", + band, + summary: + credibilityMultiplier >= 1 + ? "Contributor credibility evidence meets the current floor." + : `Contributor credibility (${roundBand(credibilityObserved)}) is below the floor (${roundBand(credibilityFloor)}), so the preview is reduced.`, + lever: + credibilityMultiplier >= 1 + ? "Continue building clean merged history and consistent review outcomes." + : "Build more merged, review-clean history in registered repos before relying on full-strength previews.", + leverageScore: credibilityMultiplier >= 1 ? 10 : 85, + }; +} + +function issueMultiplierBreakdown(preview: ScorePreviewResult): ScoreMultiplierBreakdown { + const { issueMultiplier } = preview.scoreEstimate; + const linked = preview.linkedIssueMultiplier; + const band = linked.eligible && issueMultiplier > 1 ? "full" : issueMultiplier >= 1 ? "neutral" : "reduced"; + const summary = + linked.mode === "none" + ? "No linked-issue multiplier was requested for this preview." + : linked.eligible + ? "Linked issue context is eligible for the configured issue multiplier." + : `Linked issue context is present but not fully eligible (${linked.status}).`; + const lever = + linked.mode === "none" + ? "Link a validated open issue with solved-by-PR evidence if this contribution closes scoped work." + : linked.eligible + ? "Keep the linked issue open, valid, and clearly solved by this PR." + : linked.status === "invalid" + ? "Fix linked issue state: confirm the issue is open and not already solved elsewhere." + : "Validate linked issue context with solved-by-PR evidence or refresh mirror metadata."; + const leverageScore = linked.eligible ? 15 : linked.mode === "none" ? 20 : 70; + return { component: "issueMultiplier", band, summary, lever, leverageScore }; +} + +function reviewPenaltyBreakdown(preview: ScorePreviewResult): ScoreMultiplierBreakdown { + const { reviewPenaltyMultiplier } = preview.scoreEstimate; + const band = bandForMultiplier(reviewPenaltyMultiplier, false); + return { + component: "reviewPenaltyMultiplier", + band, + summary: + reviewPenaltyMultiplier >= 0.99 + ? "Review churn penalty is not materially reducing this preview." + : "Prior review churn is reducing the preview through the review penalty multiplier.", + lever: + reviewPenaltyMultiplier >= 0.99 + ? "Keep tests, evidence, and PR scope tight to avoid future review churn." + : "Reduce review churn with clearer tests, smaller diffs, and explicit validation evidence.", + leverageScore: reviewPenaltyMultiplier >= 0.99 ? 8 : 60, + }; +} + +function labelMultiplierBreakdown(preview: ScorePreviewResult): ScoreMultiplierBreakdown { + const { labelMultiplier } = preview.scoreEstimate; + const band = labelMultiplier > 1 ? "full" : "neutral"; + return { + component: "labelMultiplier", + band, + summary: + labelMultiplier > 1 + ? "A configured trusted label multiplier is applied." + : "No trusted label multiplier is applied beyond the default.", + lever: + labelMultiplier > 1 + ? "Ensure the label match is legitimate and documented for maintainers." + : "Check whether the change legitimately matches a configured trusted label before submission.", + leverageScore: labelMultiplier > 1 ? 12 : 25, + }; +} + +function contributionBonusBreakdown(preview: ScorePreviewResult): ScoreMultiplierBreakdown { + const { contributionBonus } = preview.scoreEstimate; + const band = contributionBonus > 0 ? "full" : "neutral"; + return { + component: "contributionBonus", + band, + summary: + contributionBonus > 0 + ? "Total change size is large enough to add a contribution bonus on top of the base score." + : "Total change size has not yet reached the contribution bonus ramp.", + lever: + contributionBonus > 0 + ? "Keep meaningful tests and docs aligned with the source change." + : "Add substantive tests or supporting changes if they genuinely improve maintainability.", + leverageScore: contributionBonus > 0 ? 6 : 30, + }; +} + +function roundBand(value: number): string { + return value.toFixed(2).replace(/\.?0+$/, ""); +} + +function gateHighlightsFor(preview: ScorePreviewResult): ScoreBreakdownExplanation["gateHighlights"] { + return preview.gateDeltas.map((delta) => ({ + gate: delta.gate, + explanation: sanitizePublicComment(delta.explanation), + })); +} + +function pickHighestLeverage(components: ScoreMultiplierBreakdown[]): ScoreBreakdownExplanation["highestLeverageLever"] { + const ranked = [...components].sort((left, right) => right.leverageScore - left.leverageScore || left.component.localeCompare(right.component)); + const top = ranked[0]!; + const reason = + top.band === "blocked" + ? `${top.component} is fully blocking or zeroing part of the preview right now.` + : top.band === "reduced" + ? `${top.component} is the largest remaining reducer in the multiplier stack.` + : `${top.component} is the best next optimization lever among non-blocking multipliers.`; + return { + component: top.component, + lever: top.lever, + reason: sanitizePublicComment(reason), + }; +} + +/** + * Pure projection over a {@link ScorePreviewResult} that explains each score multiplier + * in plain language and identifies the single highest-leverage improvement lever. + */ +export function explainScoreBreakdown(preview: ScorePreviewResult): ScoreBreakdownExplanation { + const components = [ + densityBreakdown(preview), + contributionBonusBreakdown(preview), + labelMultiplierBreakdown(preview), + issueMultiplierBreakdown(preview), + credibilityBreakdown(preview), + reviewPenaltyBreakdown(preview), + openPrBreakdown(preview), + ].map((entry) => ({ + ...entry, + summary: sanitizePublicComment(entry.summary), + lever: sanitizePublicComment(entry.lever), + })); + + return { + repoFullName: preview.repoFullName, + scoreabilityStatus: preview.scoreabilityStatus, + effectiveEstimatedScore: preview.effectiveEstimatedScore, + components, + gateHighlights: gateHighlightsFor(preview), + highestLeverageLever: pickHighestLeverage(components), + }; +} diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index faad53c53e..0709215898 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -4470,6 +4470,7 @@ describe("api routes", () => { expect(toolNames).toContain("gittensory_preflight_pr"); expect(toolNames).toContain("gittensory_preflight_local_diff"); expect(toolNames).toContain("gittensory_preview_local_pr_score"); + expect(toolNames).toContain("gittensory_explain_score_breakdown"); expect(toolNames).toContain("gittensory_get_registry_changes"); expect(toolNames).toContain("gittensory_get_upstream_drift"); expect(toolNames).toContain("gittensory_explain_review_risk"); diff --git a/test/unit/mcp-output-schemas.test.ts b/test/unit/mcp-output-schemas.test.ts index 40789d25f6..cb2a69f685 100644 --- a/test/unit/mcp-output-schemas.test.ts +++ b/test/unit/mcp-output-schemas.test.ts @@ -24,6 +24,7 @@ const TOOLS_WITH_OUTPUT_SCHEMA = [ "gittensory_get_registry_changes", "gittensory_get_upstream_drift", "gittensory_local_status", + "gittensory_explain_score_breakdown", ]; async function connectTestClient(env: Env = createTestEnv()) { @@ -184,6 +185,29 @@ describe("MCP tool calls return schema-valid structured content", () => { expect(JSON.stringify(data)).not.toMatch(/hotkey|coldkey|wallet|payout|reward/i); }); + it("gittensory_explain_score_breakdown returns validated structured content", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "demo", full_name: "octo/demo", private: false, owner: { login: "octo" }, default_branch: "main" }); + const { client } = await connectTestClient(env); + const result = await client.callTool({ + name: "gittensory_explain_score_breakdown", + arguments: { + repoFullName: "octo/demo", + contributorLogin: "octo", + sourceTokenScore: 40, + totalTokenScore: 60, + sourceLines: 80, + openPrCount: 0, + credibility: 1, + }, + }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as Record; + expect(data.repoFullName).toBe("octo/demo"); + expect(Array.isArray(data.components)).toBe(true); + expect(data.highestLeverageLever).toBeTruthy(); + }); + it("gittensory_lint_pr_text returns a deterministic verdict and fixes", async () => { const { client } = await connectTestClient(); const weak = await client.callTool({ name: "gittensory_lint_pr_text", arguments: { commitMessages: ["wip"], prBody: "" } }); diff --git a/test/unit/score-breakdown.test.ts b/test/unit/score-breakdown.test.ts new file mode 100644 index 0000000000..0b483dab86 --- /dev/null +++ b/test/unit/score-breakdown.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, it } from "vitest"; +import { buildScorePreview } from "../../src/scoring/preview"; +import { explainScoreBreakdown } from "../../src/services/score-breakdown"; +import type { RepositoryRecord, ScoringModelSnapshotRecord } from "../../src/types"; + +const FORBIDDEN = /\b(wallet|hotkey|coldkey|mnemonic|farming|payout|raw[-_\s]?trust)\b/i; + +const snapshot: ScoringModelSnapshotRecord = { + id: "score-model-fixture", + sourceKind: "test", + sourceUrl: "fixture://constants.py", + fetchedAt: "2026-05-23T00:00:00.000Z", + activeModel: "current_density_model", + constants: { + OSS_EMISSION_SHARE: 0.9, + MERGED_PR_BASE_SCORE: 25, + MIN_TOKEN_SCORE_FOR_BASE_SCORE: 5, + MAX_CODE_DENSITY_MULTIPLIER: 1.15, + MAX_CONTRIBUTION_BONUS: 25, + CONTRIBUTION_SCORE_FOR_FULL_BONUS: 1500, + STANDARD_ISSUE_MULTIPLIER: 1.33, + MAINTAINER_ISSUE_MULTIPLIER: 1.66, + MIN_CREDIBILITY: 0.8, + REVIEW_PENALTY_RATE: 0.15, + EXCESSIVE_PR_PENALTY_BASE_THRESHOLD: 2, + OPEN_PR_THRESHOLD_TOKEN_SCORE: 300, + MAX_OPEN_PR_THRESHOLD: 30, + OPEN_PR_COLLATERAL_PERCENT: 0.2, + SRC_TOK_SATURATION_SCALE: 58, + TOTAL_TOK_SATURATION_SCALE: 58, + }, + payload: {}, + programmingLanguages: {}, + warnings: [], +}; + +const repo: RepositoryRecord = { + fullName: "octo/demo", + owner: "octo", + name: "demo", + isInstalled: false, + isRegistered: true, + isPrivate: false, + registryConfig: { + repo: "octo/demo", + emissionShare: 0.02, + issueDiscoveryShare: 0.25, + labelMultipliers: { bug: 1.2 }, + maintainerCut: 0, + raw: {}, + }, +}; + +describe("explainScoreBreakdown", () => { + it("explains each multiplier with a concrete improvement lever", () => { + const preview = buildScorePreview({ + repo, + snapshot, + input: { + repoFullName: repo.fullName, + contributorLogin: "miner", + sourceTokenScore: 40, + totalTokenScore: 60, + sourceLines: 80, + openPrCount: 4, + existingContributorTokenScore: 100, + credibility: 0.5, + changesRequestedCount: 2, + linkedIssueMode: "standard", + linkedIssueContext: { status: "raw", source: "github_cache", issueNumbers: [12] }, + }, + }); + + const breakdown = explainScoreBreakdown(preview); + const componentNames = breakdown.components.map((entry) => entry.component); + expect(componentNames).toEqual( + expect.arrayContaining([ + "densityMultiplier", + "contributionBonus", + "labelMultiplier", + "issueMultiplier", + "credibilityMultiplier", + "reviewPenaltyMultiplier", + "openPrMultiplier", + ]), + ); + for (const component of breakdown.components) { + expect(component.summary.length).toBeGreaterThan(0); + expect(component.lever.length).toBeGreaterThan(0); + expect(["full", "reduced", "neutral", "blocked"]).toContain(component.band); + } + expect(breakdown.highestLeverageLever.component).toBeTruthy(); + expect(breakdown.highestLeverageLever.lever).toMatch(/merge|close|credibility|open PR|linked issue|density|review/i); + expect(JSON.stringify(breakdown)).not.toMatch(FORBIDDEN); + }); + + it("prioritizes open PR blocking as the highest leverage lever", () => { + const preview = buildScorePreview({ + repo, + snapshot, + input: { + repoFullName: repo.fullName, + sourceTokenScore: 80, + totalTokenScore: 100, + sourceLines: 50, + openPrCount: 8, + existingContributorTokenScore: 50, + credibility: 1, + }, + }); + + const breakdown = explainScoreBreakdown(preview); + expect(breakdown.components.find((entry) => entry.component === "openPrMultiplier")).toMatchObject({ band: "blocked" }); + expect(breakdown.highestLeverageLever.component).toBe("openPrMultiplier"); + expect(breakdown.highestLeverageLever.lever).toMatch(/Land, merge, or close/i); + }); + + it("includes gate highlights without leaking forbidden language", () => { + const preview = buildScorePreview({ + repo, + snapshot, + input: { + repoFullName: repo.fullName, + sourceTokenScore: 60, + totalTokenScore: 80, + sourceLines: 40, + openPrCount: 3, + existingContributorTokenScore: 900, + credibility: 0.6, + linkedIssueMode: "standard", + linkedIssueContext: { status: "validated", source: "official_mirror", issueNumbers: [3], solvedByPullRequests: [44] }, + }, + }); + + const breakdown = explainScoreBreakdown(preview); + expect(breakdown.gateHighlights.length).toBeGreaterThan(0); + expect(breakdown.gateHighlights[0]?.explanation).toMatch(/private context|estimated score/i); + expect(JSON.stringify(breakdown.gateHighlights)).not.toMatch(FORBIDDEN); + }); + + it("covers healthy multiplier branches and contribution bonus density messaging", () => { + const preview = buildScorePreview({ + repo, + snapshot, + input: { + repoFullName: repo.fullName, + sourceTokenScore: 120, + totalTokenScore: 1600, + sourceLines: 120, + openPrCount: 0, + existingContributorTokenScore: 1200, + credibility: 1, + changesRequestedCount: 0, + labels: ["bug"], + linkedIssueMode: "none", + }, + }); + + const breakdown = explainScoreBreakdown(preview); + expect(breakdown.components.find((entry) => entry.component === "densityMultiplier")?.summary).toMatch(/Contribution bonus is already contributing/i); + expect(breakdown.components.find((entry) => entry.component === "labelMultiplier")).toMatchObject({ band: "full" }); + expect(breakdown.components.find((entry) => entry.component === "issueMultiplier")).toMatchObject({ band: "neutral" }); + expect(breakdown.components.find((entry) => entry.component === "openPrMultiplier")).toMatchObject({ band: "full" }); + expect(breakdown.components.find((entry) => entry.component === "credibilityMultiplier")).toMatchObject({ band: "full" }); + expect(breakdown.components.find((entry) => entry.component === "reviewPenaltyMultiplier")).toMatchObject({ band: "full" }); + }); + + it("explains failed base-token and invalid linked-issue branches", () => { + const preview = buildScorePreview({ + repo, + snapshot, + input: { + repoFullName: repo.fullName, + sourceTokenScore: 0, + totalTokenScore: 0, + sourceLines: 10, + openPrCount: 0, + credibility: 1, + linkedIssueMode: "standard", + linkedIssueContext: { status: "invalid", source: "github_cache", issueNumbers: [9], reason: "Issue #9 is closed." }, + }, + }); + + const breakdown = explainScoreBreakdown(preview); + expect(breakdown.components.find((entry) => entry.component === "densityMultiplier")).toMatchObject({ band: "blocked" }); + expect(breakdown.components.find((entry) => entry.component === "issueMultiplier")?.lever).toMatch(/Fix linked issue state/i); + expect(breakdown.highestLeverageLever.component).toBe("densityMultiplier"); + }); + + it("selects a reduced multiplier as highest leverage when nothing is fully blocked", () => { + const preview = buildScorePreview({ + repo, + snapshot, + input: { + repoFullName: repo.fullName, + sourceTokenScore: 80, + totalTokenScore: 90, + sourceLines: 50, + openPrCount: 1, + existingContributorTokenScore: 1200, + credibility: 0.7, + changesRequestedCount: 1, + linkedIssueMode: "standard", + linkedIssueContext: { status: "plausible", source: "github_cache", issueNumbers: [4] }, + }, + }); + + const breakdown = explainScoreBreakdown(preview); + expect(breakdown.highestLeverageLever.reason).toMatch(/reducer|optimization lever/i); + expect(breakdown.highestLeverageLever.component).toMatch(/credibilityMultiplier|issueMultiplier|reviewPenaltyMultiplier/); + }); +});