From 45513afdb7cf213721fffe130d11068b18dca0b5 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Tue, 26 May 2026 01:54:35 -0700 Subject: [PATCH] feat(scoring): add situational score projections --- package-lock.json | 2 +- packages/gittensory-mcp/README.md | 11 + packages/gittensory-mcp/bin/gittensory-mcp.js | 74 +++- packages/gittensory-mcp/lib/local-branch.js | 32 +- packages/gittensory-mcp/package.json | 2 +- site/guide/miners.md | 15 + src/api/routes.ts | 16 + src/mcp/server.ts | 22 +- src/openapi/schemas.ts | 99 ++++- src/scoring/preview.ts | 380 +++++++++++++++--- src/signals/engine.ts | 10 +- src/signals/local-branch.ts | 187 ++++++++- test/unit/local-branch.test.ts | 90 +++++ test/unit/openapi.test.ts | 3 + test/unit/scoring.test.ts | 51 +++ 15 files changed, 911 insertions(+), 83 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6899eeb26d..a64c3cc133 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7566,7 +7566,7 @@ }, "packages/gittensory-mcp": { "name": "@jsonbored/gittensory-mcp", - "version": "0.1.2", + "version": "0.1.3", "license": "AGPL-3.0-only", "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", diff --git a/packages/gittensory-mcp/README.md b/packages/gittensory-mcp/README.md index eafea275de..4078f9b48a 100644 --- a/packages/gittensory-mcp/README.md +++ b/packages/gittensory-mcp/README.md @@ -40,6 +40,17 @@ gittensory-mcp preflight --login jsonbored --json gittensory-mcp --stdio ``` +For near-term what-if scoreability, pass the situational assumptions explicitly: + +```sh +gittensory-mcp analyze-branch --login jsonbored \ + --pending-merged-prs 3 \ + --expected-open-prs 0 \ + --projected-credibility 0.8 \ + --scenario-note "approved PRs expected to merge" \ + --json +``` + ## Auth `login` uses GitHub Device Flow by default. For non-interactive bootstrap: diff --git a/packages/gittensory-mcp/bin/gittensory-mcp.js b/packages/gittensory-mcp/bin/gittensory-mcp.js index 0a79d47ba5..97c0833ff0 100755 --- a/packages/gittensory-mcp/bin/gittensory-mcp.js +++ b/packages/gittensory-mcp/bin/gittensory-mcp.js @@ -67,6 +67,12 @@ const localScoreShape = { openPrCount: z.number().int().min(0).optional(), credibility: z.number().min(0).max(1).optional(), changesRequestedCount: z.number().int().min(0).optional(), + pendingMergedPrCount: z.number().int().min(0).optional(), + pendingClosedPrCount: z.number().int().min(0).optional(), + approvedPrCount: z.number().int().min(0).optional(), + expectedOpenPrCountAfterMerge: z.number().int().min(0).optional(), + projectedCredibility: z.number().min(0).max(1).optional(), + scenarioNotes: z.array(z.string()).optional(), scorePreviewCommand: z.string().optional(), }; @@ -85,6 +91,12 @@ const currentBranchShape = { body: z.string().optional(), labels: z.array(z.string()).optional(), linkedIssues: z.array(z.number().int().positive()).optional(), + pendingMergedPrCount: z.number().int().min(0).optional(), + pendingClosedPrCount: z.number().int().min(0).optional(), + approvedPrCount: z.number().int().min(0).optional(), + expectedOpenPrCountAfterMerge: z.number().int().min(0).optional(), + projectedCredibility: z.number().min(0).max(1).optional(), + scenarioNotes: z.array(z.string()).optional(), validation: z .array( z.object({ @@ -109,7 +121,7 @@ if (cliArgs[0] && cliArgs[0] !== "--stdio") { const server = new McpServer({ name: "gittensory-local", - version: "0.1.0", + version: "0.1.3", }); server.registerTool( @@ -208,7 +220,7 @@ server.registerTool( async ({ variants }) => { const previews = []; for (const variant of variants) previews.push(await previewLocalScore({ ...variant, targetKey: variant.targetKey ?? `variant:${previews.length + 1}` })); - previews.sort((left, right) => Number(right?.remotePreview?.result?.scoreEstimate?.estimatedMergedScore ?? 0) - Number(left?.remotePreview?.result?.scoreEstimate?.estimatedMergedScore ?? 0)); + previews.sort((left, right) => Number(right?.remotePreview?.result?.effectiveEstimatedScore ?? right?.remotePreview?.result?.scoreEstimate?.estimatedMergedScore ?? 0) - Number(left?.remotePreview?.result?.effectiveEstimatedScore ?? left?.remotePreview?.result?.scoreEstimate?.estimatedMergedScore ?? 0)); return toolResult("Gittensory PR variant comparison.", { variants: previews }); }, ); @@ -262,7 +274,13 @@ server.registerTool( }, async (input) => { const result = await analyzeCurrentBranch(input); - return toolResult("Gittensory current-branch private score preview.", { local: result.local, scorePreview: result.analysis.scorePreview, scoreBlockers: result.analysis.scoreBlockers }); + return toolResult("Gittensory current-branch private score preview.", { + local: result.local, + scorePreview: result.analysis.scorePreview, + scenarioScorePreview: result.analysis.scenarioScorePreview, + scoreBlockers: result.analysis.scoreBlockers, + recommendedRerunCondition: result.analysis.recommendedRerunCondition, + }); }, ); @@ -274,7 +292,7 @@ server.registerTool( }, async (input) => { const result = await analyzeCurrentBranch(input); - return toolResult("Gittensory local next-action ranking.", { local: result.local, nextActions: result.analysis.nextActions, rewardRisk: result.analysis.rewardRisk }); + return toolResult("Gittensory local next-action ranking.", { local: result.local, nextActions: result.analysis.nextActions, rewardRisk: result.analysis.rewardRisk, recommendedRerunCondition: result.analysis.recommendedRerunCondition }); }, ); @@ -286,7 +304,15 @@ server.registerTool( }, async (input) => { const result = await analyzeCurrentBranch(input); - return toolResult("Gittensory local blocker explanation.", { local: result.local, scoreBlockers: result.analysis.scoreBlockers, localFindings: result.analysis.localFindings }); + return toolResult("Gittensory local blocker explanation.", { + local: result.local, + scoreBlockers: result.analysis.scoreBlockers, + branchQualityBlockers: result.analysis.branchQualityBlockers, + accountStateBlockers: result.analysis.accountStateBlockers, + baseFreshness: result.analysis.baseFreshness, + localFindings: result.analysis.localFindings, + recommendedRerunCondition: result.analysis.recommendedRerunCondition, + }); }, ); @@ -314,7 +340,7 @@ server.registerTool( analyses.sort( (left, right) => Number(right.analysis.nextActions?.[0]?.priorityScore ?? 0) - Number(left.analysis.nextActions?.[0]?.priorityScore ?? 0) || - Number(right.analysis.scorePreview?.scoreEstimate?.estimatedMergedScore ?? 0) - Number(left.analysis.scorePreview?.scoreEstimate?.estimatedMergedScore ?? 0), + Number(right.analysis.scorePreview?.effectiveEstimatedScore ?? right.analysis.scorePreview?.scoreEstimate?.estimatedMergedScore ?? 0) - Number(left.analysis.scorePreview?.effectiveEstimatedScore ?? left.analysis.scorePreview?.scoreEstimate?.estimatedMergedScore ?? 0), ); return toolResult("Gittensory local variant comparison.", { variants: analyses.map((entry) => ({ @@ -352,6 +378,12 @@ async function runCli(args) { body: options.body, labels: options.label, linkedIssues: options.issue?.map((value) => Number(value)).filter((value) => Number.isInteger(value) && value > 0), + pendingMergedPrCount: optionalInteger(options.pendingMergedPrs), + pendingClosedPrCount: optionalInteger(options.pendingClosedPrs), + approvedPrCount: optionalInteger(options.approvedPrs), + expectedOpenPrCountAfterMerge: optionalInteger(options.expectedOpenPrs), + projectedCredibility: optionalNumber(options.projectedCredibility), + scenarioNotes: options.scenarioNote, validation: validationFromOptions(options), scorePreviewCommand: options.scorePreviewCommand, }); @@ -383,8 +415,8 @@ function printHelp() { gittensory-mcp status [--json] gittensory-mcp doctor [--cwd path] [--json] gittensory-mcp init-client --print codex|claude|cursor [--json] - gittensory-mcp analyze-branch --login [--repo owner/repo] [--base origin/main] [--validation "passed|npm test|summary"] [--json] - gittensory-mcp preflight --login [--repo owner/repo] [--base origin/main] [--validation "passed|npm test|summary"] [--json] + gittensory-mcp analyze-branch --login [--repo owner/repo] [--base origin/main] [--pending-merged-prs 3] [--expected-open-prs 0] [--projected-credibility 0.8] [--scenario-note "..."] [--validation "passed|npm test|summary"] [--json] + gittensory-mcp preflight --login [--repo owner/repo] [--base origin/main] [--pending-merged-prs 3] [--expected-open-prs 0] [--projected-credibility 0.8] [--validation "passed|npm test|summary"] [--json] Environment: GITTENSORY_API_URL @@ -399,7 +431,7 @@ Environment: function parseOptions(args) { const options = {}; - const repeatable = new Set(["label", "issue", "validation", "validationCommand", "validationStatus", "validationSummary"]); + const repeatable = new Set(["label", "issue", "validation", "validationCommand", "validationStatus", "validationSummary", "scenarioNote"]); for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg === "--json") { @@ -609,6 +641,18 @@ function validationFromOptions(options) { return [...direct, ...expanded].filter((entry) => typeof entry.command === "string" && entry.command.length > 0); } +function optionalInteger(value) { + if (value === undefined || value === true) return undefined; + const parsed = Number(value); + return Number.isInteger(parsed) && parsed >= 0 ? parsed : undefined; +} + +function optionalNumber(value) { + if (value === undefined || value === true) return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + function isValidationStatus(value) { return value === "passed" || value === "failed" || value === "not_run"; } @@ -701,7 +745,13 @@ async function analyzeCurrentBranch(input) { baseRef: body.baseRef, headRef: body.headRef, branchName: body.branchName, + baseSha: body.baseSha, + headSha: body.headSha, + mergeBaseSha: body.mergeBaseSha, + remoteTrackingSha: body.remoteTrackingSha, changedFileCount: body.changedFiles?.length ?? 0, + testFileCount: body.changedFiles?.filter((file) => /(^|\/)(test|tests|spec|__tests__)\/|(^|\/)src\/test\/|(^|\/)[^/]+_test\.(go|py|rb)$|(^|\/)[^/]+_spec\.rb$|\.(test|spec)\.(ts|tsx|js|jsx|py|rb|rs)$/i.test(file.path)).length ?? 0, + passedValidationCount: body.validation?.filter((entry) => entry.status === "passed").length ?? 0, localScorerStatus, setupGuidance: setupGuidanceForLocalScorer(localScorerStatus), }, @@ -729,6 +779,12 @@ async function previewLocalScore(input) { openPrCount: input.openPrCount, credibility: input.credibility, changesRequestedCount: input.changesRequestedCount, + pendingMergedPrCount: input.pendingMergedPrCount, + pendingClosedPrCount: input.pendingClosedPrCount, + approvedPrCount: input.approvedPrCount, + expectedOpenPrCountAfterMerge: input.expectedOpenPrCountAfterMerge, + projectedCredibility: input.projectedCredibility, + scenarioNotes: input.scenarioNotes, metadataOnly: !upstreamPreview.ok, }; return { diff --git a/packages/gittensory-mcp/lib/local-branch.js b/packages/gittensory-mcp/lib/local-branch.js index f7c923b20c..8b00a92140 100644 --- a/packages/gittensory-mcp/lib/local-branch.js +++ b/packages/gittensory-mcp/lib/local-branch.js @@ -35,6 +35,10 @@ export function collectLocalBranchMetadata(input) { if (!repoFullName) throw new Error("Could not infer repoFullName from git remote; pass --repo owner/repo."); const branchName = input.branchName ?? gitLines(cwd, ["branch", "--show-current"])[0] ?? "local-branch"; const headRef = input.headRef ?? gitLines(cwd, ["rev-parse", "--abbrev-ref", "HEAD"])[0] ?? branchName; + const baseSha = gitLines(cwd, ["rev-parse", "--verify", baseRef])[0]; + const headSha = gitLines(cwd, ["rev-parse", "--verify", "HEAD"])[0]; + const mergeBaseSha = gitLines(cwd, ["merge-base", baseRef, "HEAD"])[0]; + const remoteTrackingSha = collectRemoteTrackingSha(cwd, baseRef); const changedFiles = collectChangedFiles(cwd, baseRef); const commitMessages = input.commitMessages ?? collectCommitMessages(cwd, baseRef); const title = input.title ?? titleFromBranch(branchName) ?? firstCommitTitle(commitMessages); @@ -47,6 +51,10 @@ export function collectLocalBranchMetadata(input) { baseRef, headRef, branchName, + baseSha, + headSha, + mergeBaseSha, + remoteTrackingSha, commitMessages, changedFiles, validation: input.validation, @@ -54,6 +62,12 @@ export function collectLocalBranchMetadata(input) { labels: input.labels, title, body: input.body, + pendingMergedPrCount: input.pendingMergedPrCount, + pendingClosedPrCount: input.pendingClosedPrCount, + approvedPrCount: input.approvedPrCount, + expectedOpenPrCountAfterMerge: input.expectedOpenPrCountAfterMerge, + projectedCredibility: input.projectedCredibility, + scenarioNotes: input.scenarioNotes, }; return stripUndefined(payload); } @@ -101,7 +115,7 @@ export function setupGuidanceForLocalScorer(status) { export function gitLines(cwd, args) { try { - return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }) + return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }) .split("\n") .map((line) => line.trim()) .filter(Boolean); @@ -169,6 +183,14 @@ function defaultBaseRef(cwd) { return "HEAD"; } +function collectRemoteTrackingSha(cwd, baseRef) { + const match = String(baseRef ?? "").replace(/^refs\/remotes\//, "").match(/^origin\/(.+)$/); + const branch = match?.[1]; + if (!branch) return undefined; + const remoteRow = gitLines(cwd, ["ls-remote", "--heads", "origin", branch])[0]; + return remoteRow?.split(/\s+/)[0]; +} + function normalizeScorerOutput(payload) { return stripUndefined({ mode: "external_command", @@ -226,7 +248,13 @@ function firstCommitTitle(messages) { } function isTestFile(file) { - return /(^|\/)(test|tests|spec|__tests__)\//i.test(file) || /\.(test|spec)\.(ts|tsx|js|jsx|py|rb|rs)$/i.test(file); + return ( + /(^|\/)(test|tests|spec|__tests__)\//i.test(file) || + /(^|\/)src\/test\//i.test(file) || + /(^|\/)[^/]+_test\.(go|py|rb)$/i.test(file) || + /(^|\/)[^/]+_spec\.rb$/i.test(file) || + /\.(test|spec)\.(ts|tsx|js|jsx|py|rb|rs)$/i.test(file) + ); } function isCodeFile(file) { diff --git a/packages/gittensory-mcp/package.json b/packages/gittensory-mcp/package.json index 81cfce3cf1..3616180b31 100644 --- a/packages/gittensory-mcp/package.json +++ b/packages/gittensory-mcp/package.json @@ -1,6 +1,6 @@ { "name": "@jsonbored/gittensory-mcp", - "version": "0.1.2", + "version": "0.1.3", "license": "AGPL-3.0-only", "type": "module", "description": "Local stdio MCP wrapper for Gittensory contributor intelligence.", diff --git a/site/guide/miners.md b/site/guide/miners.md index 8d49b33038..c3e9c575a6 100644 --- a/site/guide/miners.md +++ b/site/guide/miners.md @@ -26,11 +26,26 @@ The response includes: - role context - preflight findings - private score blockers +- current vs projected scoreability scenarios - reward/risk reasoning +- base freshness warnings when the local diff may be inflated - maintainer-fit notes - public-safe PR packet - ranked next actions +When the current score is blocked by temporary account/queue state, pass the assumptions explicitly: + +```sh +gittensory-mcp analyze-branch --login YOUR_GITHUB_LOGIN \ + --pending-merged-prs 3 \ + --expected-open-prs 0 \ + --projected-credibility 0.8 \ + --scenario-note "approved PRs expected to merge" \ + --json +``` + +Gittensory labels that as a user-supplied scenario. It shows the current effective score, the underlying potential score, and what changes if the open-PR and credibility gates clear. + ## Preflight ```sh diff --git a/src/api/routes.ts b/src/api/routes.ts index 21e5e99bb9..64ef141931 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -155,6 +155,10 @@ const localBranchAnalysisSchema = z baseRef: z.string().min(1).optional(), headRef: z.string().min(1).optional(), branchName: z.string().min(1).optional(), + baseSha: z.string().min(1).optional(), + headSha: z.string().min(1).optional(), + mergeBaseSha: z.string().min(1).optional(), + remoteTrackingSha: z.string().min(1).optional(), commitMessages: z.array(z.string()).max(30).optional(), changedFiles: z.array(localBranchChangedFileSchema).max(500).optional(), validation: z.array(localBranchValidationSchema).max(50).optional(), @@ -163,6 +167,12 @@ const localBranchAnalysisSchema = z title: z.string().min(1).optional(), body: z.string().optional(), localScorer: localBranchScorerSchema.optional(), + pendingMergedPrCount: z.number().int().min(0).optional(), + pendingClosedPrCount: z.number().int().min(0).optional(), + approvedPrCount: z.number().int().min(0).optional(), + expectedOpenPrCountAfterMerge: z.number().int().min(0).optional(), + projectedCredibility: z.number().min(0).max(1).optional(), + scenarioNotes: z.array(z.string()).max(20).optional(), }) .strict(); @@ -184,6 +194,12 @@ const scorePreviewSchema = z.object({ changesRequestedCount: z.number().int().min(0).optional(), fixedBaseScore: z.number().min(0).optional(), metadataOnly: z.boolean().default(false), + pendingMergedPrCount: z.number().int().min(0).optional(), + pendingClosedPrCount: z.number().int().min(0).optional(), + approvedPrCount: z.number().int().min(0).optional(), + expectedOpenPrCountAfterMerge: z.number().int().min(0).optional(), + projectedCredibility: z.number().min(0).max(1).optional(), + scenarioNotes: z.array(z.string()).max(20).optional(), }); const repositorySettingsSchema = z.object({ diff --git a/src/mcp/server.ts b/src/mcp/server.ts index fb493d1ea1..cfd7b8f758 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -97,6 +97,10 @@ const localBranchAnalysisShape = { baseRef: z.string().min(1).optional(), headRef: z.string().min(1).optional(), branchName: z.string().min(1).optional(), + baseSha: z.string().min(1).optional(), + headSha: z.string().min(1).optional(), + mergeBaseSha: z.string().min(1).optional(), + remoteTrackingSha: z.string().min(1).optional(), commitMessages: z.array(z.string()).max(30).optional(), changedFiles: z .array( @@ -129,6 +133,12 @@ const localBranchAnalysisShape = { labels: z.array(z.string()).optional(), title: z.string().min(1).optional(), body: z.string().optional(), + pendingMergedPrCount: z.number().int().min(0).optional(), + pendingClosedPrCount: z.number().int().min(0).optional(), + approvedPrCount: z.number().int().min(0).optional(), + expectedOpenPrCountAfterMerge: z.number().int().min(0).optional(), + projectedCredibility: z.number().min(0).max(1).optional(), + scenarioNotes: z.array(z.string()).max(20).optional(), localScorer: z .object({ mode: z.enum(["metadata_only", "external_command", "gittensor_root"]), @@ -165,6 +175,12 @@ const scorePreviewShape = { credibility: z.number().min(0).max(1).optional(), changesRequestedCount: z.number().int().min(0).optional(), metadataOnly: z.boolean().default(true), + pendingMergedPrCount: z.number().int().min(0).optional(), + pendingClosedPrCount: z.number().int().min(0).optional(), + approvedPrCount: z.number().int().min(0).optional(), + expectedOpenPrCountAfterMerge: z.number().int().min(0).optional(), + projectedCredibility: z.number().min(0).max(1).optional(), + scenarioNotes: z.array(z.string()).max(20).optional(), }; const variantsShape = { @@ -571,6 +587,10 @@ export class GittensoryMcp { repoFullName: analysis.repoFullName, generatedAt: analysis.generatedAt, [slice]: analysis[slice], + scenarioScorePreview: slice === "scorePreview" || slice === "scoreBlockers" ? analysis.scenarioScorePreview : undefined, + branchQualityBlockers: slice === "scoreBlockers" ? analysis.branchQualityBlockers : undefined, + accountStateBlockers: slice === "scoreBlockers" ? analysis.accountStateBlockers : undefined, + recommendedRerunCondition: slice === "scoreBlockers" || slice === "nextActions" ? analysis.recommendedRerunCondition : undefined, dataQuality: analysis.dataQuality, } as Record, }; @@ -582,7 +602,7 @@ export class GittensoryMcp { analyses.sort( (left, right) => (right.nextActions[0]?.priorityScore ?? 0) - (left.nextActions[0]?.priorityScore ?? 0) || - right.scorePreview.scoreEstimate.estimatedMergedScore - left.scorePreview.scoreEstimate.estimatedMergedScore || + right.scorePreview.effectiveEstimatedScore - left.scorePreview.effectiveEstimatedScore || left.repoFullName.localeCompare(right.repoFullName), ); return { diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 5cfca796e9..c069cdd8dc 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -698,6 +698,78 @@ export const ScoringModelSnapshotSchema = z }) .openapi("ScoringModelSnapshot"); +const ScoreEstimateSchema = z.object({ + baseScore: z.number(), + densityMultiplier: z.number(), + contributionBonus: z.number(), + labelMultiplier: z.number(), + issueMultiplier: z.number(), + credibilityMultiplier: z.number(), + reviewPenaltyMultiplier: z.number(), + openPrMultiplier: z.number(), + estimatedMergedScore: z.number(), + pendingSaturationScore: z.number(), +}); + +const ScoreGatesSchema = z.object({ + baseTokenGatePassed: z.boolean(), + openPrThreshold: z.number(), + openPrCount: z.number(), + collateralFraction: z.number(), + credibilityFloor: z.number(), + credibilityObserved: z.number(), +}); + +const ScoreGateBlockerSchema = z.object({ + code: z.enum(["repo_not_registered", "inactive_allocation", "base_token_gate", "open_pr_threshold", "credibility_floor", "review_penalty", "metadata_only"]), + severity: z.enum(["blocker", "reducer", "context"]), + detail: z.string(), +}); + +const ScoreGateDeltaSchema = z.object({ + gate: z.enum(["open_pr_threshold", "credibility_floor", "linked_issue_multiplier"]), + current: z.string(), + projected: z.string(), + explanation: z.string(), +}); + +const ScoreScenarioPreviewSchema = z.object({ + name: z.enum(["current", "cleanGates", "afterPendingMerges", "linkedIssueFixed", "bestReasonableCase"]), + source: z.enum(["current_data", "user_supplied", "gittensory_projection"]), + assumptions: z.array(z.string()), + scoreEstimate: ScoreEstimateSchema, + gates: ScoreGatesSchema, + effectiveEstimatedScore: z.number(), + underlyingPotentialScore: z.number(), + blockedBy: z.array(ScoreGateBlockerSchema), + deltaExplanation: z.string(), +}); + +export const ScorePreviewResultSchema = z + .object({ + repoFullName: z.string(), + generatedAt: z.string(), + scoringModelSnapshotId: z.string(), + activeModel: z.enum(["current_density_model", "pending_saturation_model", "unknown"]), + privateOnly: z.literal(true), + laneMath: z.record(z.number()), + scoreEstimate: ScoreEstimateSchema, + gates: ScoreGatesSchema, + effectiveEstimatedScore: z.number(), + underlyingPotentialScore: z.number(), + blockedBy: z.array(ScoreGateBlockerSchema), + gateDeltas: z.array(ScoreGateDeltaSchema), + scenarioPreviews: z.array(ScoreScenarioPreviewSchema), + scoreabilityStatus: z.enum(["blocked", "conditionally_scoreable", "scoreable", "hold"]), + warnings: z.array(z.string()), + assumptions: z.array(z.string()), + recommendation: z.object({ + level: z.enum(["strong_fit", "reasonable_fit", "needs_work", "hold"]), + actions: z.array(z.string()), + }), + }) + .openapi("ScorePreviewResult"); + export const ScorePreviewSchema = z .object({ id: z.string(), @@ -707,7 +779,7 @@ export const ScorePreviewSchema = z targetKey: z.string(), contributorLogin: z.string().nullable().optional(), input: z.record(z.unknown()), - result: z.record(z.unknown()), + result: ScorePreviewResultSchema, generatedAt: z.string(), }) .openapi("ScorePreview"); @@ -1020,12 +1092,35 @@ export const LocalBranchAnalysisSchema = z baseRef: z.string().optional(), headRef: z.string().optional(), branchName: z.string().optional(), + baseFreshness: z.object({ + status: z.enum(["fresh", "stale", "possibly_stale", "unknown"]), + baseRef: z.string().optional(), + baseSha: z.string().optional(), + headSha: z.string().optional(), + mergeBaseSha: z.string().optional(), + remoteTrackingSha: z.string().optional(), + changedFileCount: z.number(), + testFileCount: z.number(), + passedValidationCount: z.number(), + warnings: z.array(z.string()), + recommendation: z.string().optional(), + }), lane: LaneAdviceSchema, roleContext: RoleContextSchema, preflight: LocalDiffPreflightResultSchema, - scorePreview: ScorePreviewSchema, + scorePreview: ScorePreviewResultSchema, + scenarioScorePreview: z.object({ + current: ScoreScenarioPreviewSchema, + bestReasonableCase: ScoreScenarioPreviewSchema, + afterPendingMerges: ScoreScenarioPreviewSchema.optional(), + gateDeltas: z.array(ScoreGateDeltaSchema), + blockedBy: z.array(ScoreGateBlockerSchema), + }), rewardRisk: RepoRewardRiskSchema, scoreBlockers: z.array(z.string()), + branchQualityBlockers: z.array(z.string()), + accountStateBlockers: z.array(z.string()), + recommendedRerunCondition: z.string(), localFindings: z.array(FindingSchema), maintainerFit: z.object({ recommendation: z.enum(["pursue", "cleanup_first", "maintainer_lane", "avoid_for_now", "unknown"]), diff --git a/src/scoring/preview.ts b/src/scoring/preview.ts index 3dde43be11..12feb8b45b 100644 --- a/src/scoring/preview.ts +++ b/src/scoring/preview.ts @@ -19,6 +19,44 @@ export type ScorePreviewInput = { changesRequestedCount?: number | undefined; fixedBaseScore?: number | undefined; metadataOnly?: boolean | undefined; + pendingMergedPrCount?: number | undefined; + pendingClosedPrCount?: number | undefined; + approvedPrCount?: number | undefined; + expectedOpenPrCountAfterMerge?: number | undefined; + projectedCredibility?: number | undefined; + scenarioNotes?: string[] | undefined; +}; + +export type ScoreGateBlocker = { + code: + | "repo_not_registered" + | "inactive_allocation" + | "base_token_gate" + | "open_pr_threshold" + | "credibility_floor" + | "review_penalty" + | "metadata_only"; + severity: "blocker" | "reducer" | "context"; + detail: string; +}; + +export type ScoreGateDelta = { + gate: "open_pr_threshold" | "credibility_floor" | "linked_issue_multiplier"; + current: string; + projected: string; + explanation: string; +}; + +export type ScoreScenarioPreview = { + name: "current" | "cleanGates" | "afterPendingMerges" | "linkedIssueFixed" | "bestReasonableCase"; + source: "current_data" | "user_supplied" | "gittensory_projection"; + assumptions: string[]; + scoreEstimate: ScorePreviewResult["scoreEstimate"]; + gates: ScorePreviewResult["gates"]; + effectiveEstimatedScore: number; + underlyingPotentialScore: number; + blockedBy: ScoreGateBlocker[]; + deltaExplanation: string; }; export type ScorePreviewResult = { @@ -55,6 +93,12 @@ export type ScorePreviewResult = { credibilityFloor: number; credibilityObserved: number; }; + effectiveEstimatedScore: number; + underlyingPotentialScore: number; + blockedBy: ScoreGateBlocker[]; + gateDeltas: ScoreGateDelta[]; + scenarioPreviews: ScoreScenarioPreview[]; + scoreabilityStatus: "blocked" | "conditionally_scoreable" | "scoreable" | "hold"; warnings: string[]; assumptions: string[]; recommendation: { @@ -69,19 +113,93 @@ export function buildScorePreview(args: { snapshot: ScoringModelSnapshotRecord; contributorEvidence?: ContributorEvidenceRecord | null | undefined; }): ScorePreviewResult { - const constants = { ...args.snapshot.constants }; - const config = args.repo?.registryConfig; + const current = computeScoreCore(args.input, args.repo, args.snapshot, args.contributorEvidence); + const scenarioPreviews = buildScenarioPreviews(args.input, args.repo, args.snapshot, args.contributorEvidence, current); + const blockedBy = blockedByFor(args.input, args.repo, current); + const gateDeltas = buildGateDeltas(current, scenarioPreviews); + const effectiveEstimatedScore = current.scoreEstimate.estimatedMergedScore; + const underlyingPotentialScore = current.scoreEstimate.pendingSaturationScore; + const scoreabilityStatus = statusFor(args.repo, blockedBy, effectiveEstimatedScore, scenarioPreviews); + const warnings = warningsFor(args.input, args.repo, current); + const actions = [ + ...(!current.gates.baseTokenGatePassed ? ["Increase meaningful source change size or scope clarity before relying on this preview."] : []), + ...(current.scoreEstimate.openPrMultiplier === 0 ? ["Land or close existing open PRs before opening more concurrent work."] : []), + ...(current.scoreEstimate.credibilityMultiplier < 1 ? ["Build or wait for contributor credibility evidence before relying on this preview."] : []), + ...(current.scoreEstimate.reviewPenaltyMultiplier < 1 ? ["Reduce review churn with tighter tests and clearer evidence."] : []), + ...(current.scoreEstimate.labelMultiplier <= 1 && Object.keys(args.repo?.registryConfig?.labelMultipliers ?? {}).length > 0 + ? ["Check whether the change legitimately matches one of the repo's configured trusted labels."] + : []), + ]; + + return { + repoFullName: args.input.repoFullName, + generatedAt: nowIso(), + scoringModelSnapshotId: args.snapshot.id, + activeModel: args.snapshot.activeModel, + privateOnly: true, + laneMath: current.laneMath, + scoreEstimate: current.scoreEstimate, + gates: current.gates, + effectiveEstimatedScore, + underlyingPotentialScore, + blockedBy, + gateDeltas, + scenarioPreviews, + scoreabilityStatus, + warnings, + assumptions: [ + "Advisory preview only; tied to the recorded scoring model snapshot and cached Gittensory data.", + "No future outcome or exact payout is guaranteed.", + "Private API/MCP output only; public comments intentionally omit these details.", + ...(args.input.scenarioNotes ?? []).map((note) => `User scenario note: ${note}`), + ], + recommendation: { + level: scoreabilityStatus === "hold" || warnings.some((warning) => /not registered|no active|exceeds/i.test(warning)) + ? "hold" + : effectiveEstimatedScore >= 30 && warnings.length === 0 + ? "strong_fit" + : effectiveEstimatedScore >= 15 + ? "reasonable_fit" + : "needs_work", + actions: actions.length > 0 ? actions : ["Keep the PR focused, linked, tested, and easy for maintainers to review."], + }, + }; +} + +export function makeScorePreviewRecord(input: ScorePreviewInput, snapshot: ScoringModelSnapshotRecord, result: ScorePreviewResult): ScorePreviewRecord { + return { + id: crypto.randomUUID(), + scoringModelSnapshotId: snapshot.id, + repoFullName: input.repoFullName, + targetType: input.targetType ?? "planned_pr", + targetKey: input.targetKey ?? `${input.repoFullName}:${input.targetType ?? "planned_pr"}:${Date.now()}`, + contributorLogin: input.contributorLogin, + input: input as unknown as Record, + result: result as unknown as Record, + generatedAt: result.generatedAt, + }; +} + +type ScoreCore = Pick; + +function computeScoreCore( + input: ScorePreviewInput, + repo: RepositoryRecord | null, + snapshot: ScoringModelSnapshotRecord, + contributorEvidence?: ContributorEvidenceRecord | null | undefined, +): ScoreCore { + const constants = { ...snapshot.constants }; + const config = repo?.registryConfig; const emissionShare = clamp(config?.emissionShare ?? 0, 0, 1); const issueDiscoveryShare = clamp(config?.issueDiscoveryShare ?? 0, 0, 1); const ossEmissionShare = constant(constants, "OSS_EMISSION_SHARE", 0.9); const repoSlice = emissionShare * ossEmissionShare; const directPrSlice = repoSlice * (1 - issueDiscoveryShare); const issueDiscoverySlice = repoSlice * issueDiscoveryShare; - - const sourceTokenScore = nonNegative(args.input.sourceTokenScore); - const totalTokenScore = nonNegative(args.input.totalTokenScore ?? sourceTokenScore + nonNegative(args.input.testTokenScore) + nonNegative(args.input.nonCodeTokenScore)); - const sourceLines = Math.max(1, nonNegative(args.input.sourceLines ?? sourceTokenScore)); - const fixedBaseScore = args.input.fixedBaseScore ?? config?.fixedBaseScore ?? undefined; + const sourceTokenScore = nonNegative(input.sourceTokenScore); + const totalTokenScore = nonNegative(input.totalTokenScore ?? sourceTokenScore + nonNegative(input.testTokenScore) + nonNegative(input.nonCodeTokenScore)); + const sourceLines = Math.max(1, nonNegative(input.sourceLines ?? sourceTokenScore)); + const fixedBaseScore = input.fixedBaseScore ?? config?.fixedBaseScore ?? undefined; const rawDensity = sourceTokenScore / sourceLines; const densityMultiplier = clamp(rawDensity || 0, 0, constant(constants, "MAX_CODE_DENSITY_MULTIPLIER", 1.15)); const baseTokenGatePassed = sourceTokenScore >= constant(constants, "MIN_TOKEN_SCORE_FOR_BASE_SCORE", 5); @@ -92,18 +210,18 @@ export function buildScorePreview(args: { fixedBaseScore !== undefined ? fixedBaseScore : (baseTokenGatePassed ? constant(constants, "MERGED_PR_BASE_SCORE", 25) * densityMultiplier : 0) + contributionBonus; - const labelMultiplier = selectLabelMultiplier(args.input.labels ?? [], config?.labelMultipliers ?? {}, config?.defaultLabelMultiplier ?? 1); - const issueMultiplier = selectIssueMultiplier(args.input.linkedIssueMode ?? "none", constants); - const credibilityObserved = clamp(args.input.credibility ?? inferCredibility(args.contributorEvidence), 0, 1); + const labelMultiplier = selectLabelMultiplier(input.labels ?? [], config?.labelMultipliers ?? {}, config?.defaultLabelMultiplier ?? 1); + const issueMultiplier = selectIssueMultiplier(input.linkedIssueMode ?? "none", constants); + const credibilityObserved = clamp(input.credibility ?? inferCredibility(contributorEvidence), 0, 1); const credibilityFloor = constant(constants, "MIN_CREDIBILITY", 0.8); const credibilityMultiplier = credibilityObserved >= credibilityFloor ? 1 : credibilityObserved / credibilityFloor; - const changesRequestedCount = nonNegative(args.input.changesRequestedCount); + const changesRequestedCount = nonNegative(input.changesRequestedCount); const reviewPenaltyMultiplier = clamp(1 - changesRequestedCount * constant(constants, "REVIEW_PENALTY_RATE", 0.15), 0, 1); - const openPrCount = nonNegative(args.input.openPrCount); + const openPrCount = nonNegative(input.openPrCount); const openPrThreshold = Math.min( constant(constants, "MAX_OPEN_PR_THRESHOLD", 30), constant(constants, "EXCESSIVE_PR_PENALTY_BASE_THRESHOLD", 2) + - Math.floor((nonNegative(args.input.existingContributorTokenScore) + totalTokenScore) / constant(constants, "OPEN_PR_THRESHOLD_TOKEN_SCORE", 300)), + Math.floor((nonNegative(input.existingContributorTokenScore) + totalTokenScore) / constant(constants, "OPEN_PR_THRESHOLD_TOKEN_SCORE", 300)), ); const openPrMultiplier = openPrCount <= openPrThreshold ? 1 : 0; const estimatedMergedScore = roundScore(baseScore * labelMultiplier * issueMultiplier * credibilityMultiplier * reviewPenaltyMultiplier * openPrMultiplier); @@ -111,29 +229,7 @@ export function buildScorePreview(args: { constant(constants, "MERGED_PR_BASE_SCORE", 25) * (1 - Math.exp(-sourceTokenScore / constant(constants, "SRC_TOK_SATURATION_SCALE", 58))) + clamp(totalTokenScore / constant(constants, "CONTRIBUTION_SCORE_FOR_FULL_BONUS", 1500), 0, 1) * 5, ); - - const warnings = [ - ...(!args.repo?.isRegistered ? ["Repository is not registered in the local Gittensory cache."] : []), - ...(emissionShare <= 0 ? ["Repository has no active allocation in the current registry snapshot."] : []), - ...(args.input.metadataOnly ? ["Preview used metadata-only inputs, so token and density estimates are rough."] : []), - ...(!baseTokenGatePassed ? ["Source token score does not pass the current base-score token gate."] : []), - ...(openPrMultiplier === 0 ? ["Open PR count exceeds the current threshold assumption."] : []), - ...(credibilityMultiplier < 1 ? ["Credibility assumption is below the current floor."] : []), - ...(reviewPenaltyMultiplier < 1 ? ["Change-request history reduces the estimate."] : []), - ]; - const actions = [ - ...(!baseTokenGatePassed ? ["Increase meaningful source change size or scope clarity before relying on this preview."] : []), - ...(openPrMultiplier === 0 ? ["Land or close existing open PRs before opening more concurrent work."] : []), - ...(reviewPenaltyMultiplier < 1 ? ["Reduce review churn with tighter tests and clearer evidence."] : []), - ...(labelMultiplier <= 1 && Object.keys(config?.labelMultipliers ?? {}).length > 0 ? ["Check whether the change legitimately matches one of the repo's configured trusted labels."] : []), - ]; - return { - repoFullName: args.input.repoFullName, - generatedAt: nowIso(), - scoringModelSnapshotId: args.snapshot.id, - activeModel: args.snapshot.activeModel, - privateOnly: true, laneMath: { repoEmissionShare: emissionShare, ossEmissionShare, @@ -162,39 +258,201 @@ export function buildScorePreview(args: { credibilityFloor, credibilityObserved, }, - warnings, - assumptions: [ - "Advisory preview only; tied to the recorded scoring model snapshot and cached Gittensory data.", - "No future outcome or exact payout is guaranteed.", - "Private API/MCP output only; public comments intentionally omit these details.", - ], - recommendation: { - level: warnings.some((warning) => /not registered|no active|exceeds/i.test(warning)) - ? "hold" - : estimatedMergedScore >= 30 && warnings.length === 0 - ? "strong_fit" - : estimatedMergedScore >= 15 - ? "reasonable_fit" - : "needs_work", - actions: actions.length > 0 ? actions : ["Keep the PR focused, linked, tested, and easy for maintainers to review."], - }, }; } -export function makeScorePreviewRecord(input: ScorePreviewInput, snapshot: ScoringModelSnapshotRecord, result: ScorePreviewResult): ScorePreviewRecord { +function buildScenarioPreviews( + input: ScorePreviewInput, + repo: RepositoryRecord | null, + snapshot: ScoringModelSnapshotRecord, + contributorEvidence: ContributorEvidenceRecord | null | undefined, + current: ScoreCore, +): ScoreScenarioPreview[] { + const pendingCount = nonNegative(input.pendingMergedPrCount) + nonNegative(input.pendingClosedPrCount) + nonNegative(input.approvedPrCount); + const expectedOpenPrCountAfterMerge = + input.expectedOpenPrCountAfterMerge !== undefined ? nonNegative(input.expectedOpenPrCountAfterMerge) : Math.max(0, current.gates.openPrCount - pendingCount); + const projectedCredibility = + input.projectedCredibility !== undefined + ? clamp(input.projectedCredibility, 0, 1) + : pendingCount > 0 + ? Math.max(current.gates.credibilityObserved, current.gates.credibilityFloor) + : current.gates.credibilityObserved; + const cleanGatesInput = { + ...input, + openPrCount: Math.min(current.gates.openPrCount, current.gates.openPrThreshold), + credibility: Math.max(current.gates.credibilityObserved, current.gates.credibilityFloor), + }; + const afterPendingInput = { + ...input, + openPrCount: expectedOpenPrCountAfterMerge, + credibility: projectedCredibility, + }; + const linkedIssueInput = { + ...input, + linkedIssueMode: input.linkedIssueMode === "none" || !input.linkedIssueMode ? ("standard" as const) : input.linkedIssueMode, + }; + const bestReasonableInput = { + ...linkedIssueInput, + openPrCount: Math.min(expectedOpenPrCountAfterMerge, current.gates.openPrThreshold), + credibility: Math.max(projectedCredibility, current.gates.credibilityFloor), + }; + return [ + scenario("current", "current_data", input, current, ["Current cached/account state and supplied local diff metadata."], repo), + scenario("cleanGates", "gittensory_projection", cleanGatesInput, computeScoreCore(cleanGatesInput, repo, snapshot, contributorEvidence), [ + "Open PR and credibility gates are projected as cleared; branch metadata is otherwise unchanged.", + ], repo), + scenario( + "afterPendingMerges", + pendingCount > 0 || input.expectedOpenPrCountAfterMerge !== undefined || input.projectedCredibility !== undefined ? "user_supplied" : "gittensory_projection", + afterPendingInput, + computeScoreCore(afterPendingInput, repo, snapshot, contributorEvidence), + [ + pendingCount > 0 + ? `${pendingCount} supplied pending approved/merged/closed PR(s) are treated as no longer open for this scenario.` + : "No pending merge/close count was supplied; this scenario preserves current open PR pressure.", + ...(input.projectedCredibility !== undefined + ? [`Projected credibility is user-supplied as ${roundScore(projectedCredibility)}.`] + : pendingCount > 0 + ? [`Projected credibility is raised to the current floor ${current.gates.credibilityFloor} because pending merges were supplied.`] + : []), + ...(input.scenarioNotes ?? []), + ], + repo, + ), + scenario("linkedIssueFixed", "gittensory_projection", linkedIssueInput, computeScoreCore(linkedIssueInput, repo, snapshot, contributorEvidence), [ + input.linkedIssueMode === "none" || !input.linkedIssueMode + ? "A standard linked-issue/no-issue rationale multiplier is projected as present." + : "Linked issue mode was already supplied; this scenario is unchanged.", + ], repo), + scenario("bestReasonableCase", "gittensory_projection", bestReasonableInput, computeScoreCore(bestReasonableInput, repo, snapshot, contributorEvidence), [ + "Combines plausible near-term gate cleanup: open PR pressure at threshold or below, credibility at floor or above, and linked-issue context where applicable.", + ...(input.scenarioNotes ?? []), + ], repo), + ]; +} + +function scenario( + name: ScoreScenarioPreview["name"], + source: ScoreScenarioPreview["source"], + input: ScorePreviewInput, + core: ScoreCore, + assumptions: string[], + repo: RepositoryRecord | null, +): ScoreScenarioPreview { + const blockedBy = blockedByFor(input, repo, core); return { - id: crypto.randomUUID(), - scoringModelSnapshotId: snapshot.id, - repoFullName: input.repoFullName, - targetType: input.targetType ?? "planned_pr", - targetKey: input.targetKey ?? `${input.repoFullName}:${input.targetType ?? "planned_pr"}:${Date.now()}`, - contributorLogin: input.contributorLogin, - input: input as unknown as Record, - result: result as unknown as Record, - generatedAt: result.generatedAt, + name, + source, + assumptions, + scoreEstimate: core.scoreEstimate, + gates: core.gates, + effectiveEstimatedScore: core.scoreEstimate.estimatedMergedScore, + underlyingPotentialScore: core.scoreEstimate.pendingSaturationScore, + blockedBy, + deltaExplanation: deltaExplanationFor(core, blockedBy), }; } +function blockedByFor(input: ScorePreviewInput, repo: RepositoryRecord | null, core: ScoreCore): ScoreGateBlocker[] { + return [ + ...(!repo?.isRegistered + ? [{ code: "repo_not_registered" as const, severity: "blocker" as const, detail: "Repository is not registered in the local Gittensory cache." }] + : []), + ...(core.laneMath.repoEmissionShare <= 0 + ? [{ code: "inactive_allocation" as const, severity: "blocker" as const, detail: "Repository has no active allocation in the current registry snapshot." }] + : []), + ...(input.metadataOnly + ? [{ code: "metadata_only" as const, severity: "context" as const, detail: "Preview used metadata-only inputs, so token and density estimates are rough." }] + : []), + ...(!core.gates.baseTokenGatePassed + ? [{ code: "base_token_gate" as const, severity: "blocker" as const, detail: "Source token score does not pass the current base-score token gate." }] + : []), + ...(core.scoreEstimate.openPrMultiplier === 0 + ? [ + { + code: "open_pr_threshold" as const, + severity: "blocker" as const, + detail: `Open PR count ${core.gates.openPrCount} exceeds threshold ${core.gates.openPrThreshold}.`, + }, + ] + : []), + ...(core.gates.credibilityObserved < core.gates.credibilityFloor + ? [ + { + code: "credibility_floor" as const, + severity: "reducer" as const, + detail: `Credibility ${roundScore(core.gates.credibilityObserved)} is below floor ${core.gates.credibilityFloor}.`, + }, + ] + : []), + ...(core.scoreEstimate.reviewPenaltyMultiplier < 1 + ? [{ code: "review_penalty" as const, severity: "reducer" as const, detail: "Change-request history reduces the estimate." }] + : []), + ]; +} + +function buildGateDeltas(current: ScoreCore, scenarios: ScoreScenarioPreview[]): ScoreGateDelta[] { + const currentScenario = scenarios[0]; + if (!currentScenario) return []; + const best = scenarios.find((scenarioPreview) => scenarioPreview.name === "bestReasonableCase") ?? currentScenario; + const linked = scenarios.find((scenarioPreview) => scenarioPreview.name === "linkedIssueFixed") ?? best; + return [ + ...(current.scoreEstimate.openPrMultiplier !== best.scoreEstimate.openPrMultiplier || current.gates.openPrCount !== best.gates.openPrCount + ? [ + { + gate: "open_pr_threshold" as const, + current: `${current.gates.openPrCount}/${current.gates.openPrThreshold} open PRs, multiplier ${current.scoreEstimate.openPrMultiplier}`, + projected: `${best.gates.openPrCount}/${best.gates.openPrThreshold} open PRs, multiplier ${best.scoreEstimate.openPrMultiplier}`, + explanation: `Open PR pressure changes estimated score ${current.scoreEstimate.estimatedMergedScore} -> ${best.scoreEstimate.estimatedMergedScore}.`, + }, + ] + : []), + ...(current.gates.credibilityObserved !== best.gates.credibilityObserved || current.scoreEstimate.credibilityMultiplier !== best.scoreEstimate.credibilityMultiplier + ? [ + { + gate: "credibility_floor" as const, + current: `${roundScore(current.gates.credibilityObserved)} observed, multiplier ${current.scoreEstimate.credibilityMultiplier}`, + projected: `${roundScore(best.gates.credibilityObserved)} projected, multiplier ${best.scoreEstimate.credibilityMultiplier}`, + explanation: `Credibility changes estimated score ${current.scoreEstimate.estimatedMergedScore} -> ${best.scoreEstimate.estimatedMergedScore}.`, + }, + ] + : []), + ...(current.scoreEstimate.issueMultiplier !== linked.scoreEstimate.issueMultiplier + ? [ + { + gate: "linked_issue_multiplier" as const, + current: `${current.scoreEstimate.issueMultiplier}`, + projected: `${linked.scoreEstimate.issueMultiplier}`, + explanation: `Linked issue/no-issue context changes estimated score ${current.scoreEstimate.estimatedMergedScore} -> ${linked.scoreEstimate.estimatedMergedScore}.`, + }, + ] + : []), + ]; +} + +function warningsFor(input: ScorePreviewInput, repo: RepositoryRecord | null, core: ScoreCore): string[] { + return blockedByFor(input, repo, core).map((blocker) => blocker.detail); +} + +function statusFor( + repo: RepositoryRecord | null, + blockedBy: ScoreGateBlocker[], + effectiveEstimatedScore: number, + scenarios: ScoreScenarioPreview[], +): ScorePreviewResult["scoreabilityStatus"] { + if (!repo?.isRegistered || blockedBy.some((blocker) => blocker.code === "inactive_allocation")) return "hold"; + if (effectiveEstimatedScore > 0 && !blockedBy.some((blocker) => blocker.severity === "blocker")) return "scoreable"; + if (scenarios.some((scenarioPreview) => scenarioPreview.name !== "current" && scenarioPreview.effectiveEstimatedScore > effectiveEstimatedScore)) { + return "conditionally_scoreable"; + } + return "blocked"; +} + +function deltaExplanationFor(core: ScoreCore, blockedBy: ScoreGateBlocker[]): string { + if (blockedBy.length === 0) return `Currently scoreable at ${core.scoreEstimate.estimatedMergedScore}; underlying potential ${core.scoreEstimate.pendingSaturationScore}.`; + return `Effective score ${core.scoreEstimate.estimatedMergedScore}; underlying potential ${core.scoreEstimate.pendingSaturationScore}; blocked or reduced by ${blockedBy.map((blocker) => blocker.code).join(", ")}.`; +} + function selectLabelMultiplier(labels: string[], multipliers: Record, fallback: number): number { const normalized = new Set(labels.map((label) => label.toLowerCase())); return Math.max( diff --git a/src/signals/engine.ts b/src/signals/engine.ts index b989dc5d89..a64be8c086 100644 --- a/src/signals/engine.ts +++ b/src/signals/engine.ts @@ -1633,7 +1633,7 @@ export function buildLocalDiffPreflightResult( action: "Split unrelated work or clearly explain why the scope needs to stay together.", }); } - if (codeFileCount > 0 && testFileCount === 0) { + if (codeFileCount > 0 && testFileCount === 0 && (input.tests ?? []).length === 0) { findings.push({ code: "local_diff_missing_tests", severity: "warning", @@ -2488,7 +2488,13 @@ function isCodeFile(file: string): boolean { } function isTestFile(file: string): boolean { - return /(^|\/)(test|tests|spec|__tests__)\//i.test(file) || /\.(test|spec)\.(ts|tsx|js|jsx|py|rb|rs)$/i.test(file); + return ( + /(^|\/)(test|tests|spec|__tests__)\//i.test(file) || + /(^|\/)src\/test\//i.test(file) || + /(^|\/)[^/]+_test\.(go|py|rb)$/i.test(file) || + /(^|\/)[^/]+_spec\.rb$/i.test(file) || + /\.(test|spec)\.(ts|tsx|js|jsx|py|rb|rs)$/i.test(file) + ); } function riskRank(risk: CollisionCluster["risk"]): number { diff --git a/src/signals/local-branch.ts b/src/signals/local-branch.ts index a9efa801d1..4123d5f972 100644 --- a/src/signals/local-branch.ts +++ b/src/signals/local-branch.ts @@ -47,6 +47,10 @@ export type LocalBranchAnalysisInput = { baseRef?: string | undefined; headRef?: string | undefined; branchName?: string | undefined; + baseSha?: string | undefined; + headSha?: string | undefined; + mergeBaseSha?: string | undefined; + remoteTrackingSha?: string | undefined; commitMessages?: string[] | undefined; changedFiles?: LocalBranchChangedFile[] | undefined; validation?: LocalBranchValidation[] | undefined; @@ -55,6 +59,12 @@ export type LocalBranchAnalysisInput = { title?: string | undefined; body?: string | undefined; localScorer?: LocalBranchScorer | undefined; + pendingMergedPrCount?: number | undefined; + pendingClosedPrCount?: number | undefined; + approvedPrCount?: number | undefined; + expectedOpenPrCountAfterMerge?: number | undefined; + projectedCredibility?: number | undefined; + scenarioNotes?: string[] | undefined; }; export type LocalBranchAnalysis = { @@ -64,12 +74,35 @@ export type LocalBranchAnalysis = { baseRef?: string | undefined; headRef?: string | undefined; branchName?: string | undefined; + baseFreshness: { + status: "fresh" | "stale" | "possibly_stale" | "unknown"; + baseRef?: string | undefined; + baseSha?: string | undefined; + headSha?: string | undefined; + mergeBaseSha?: string | undefined; + remoteTrackingSha?: string | undefined; + changedFileCount: number; + testFileCount: number; + passedValidationCount: number; + warnings: string[]; + recommendation?: string | undefined; + }; lane: ReturnType; roleContext: RoleContext; preflight: LocalDiffPreflightResult; scorePreview: ScorePreviewResult; + scenarioScorePreview: { + current: ScorePreviewResult["scenarioPreviews"][number]; + bestReasonableCase: ScorePreviewResult["scenarioPreviews"][number]; + afterPendingMerges?: ScorePreviewResult["scenarioPreviews"][number] | undefined; + gateDeltas: ScorePreviewResult["gateDeltas"]; + blockedBy: ScorePreviewResult["blockedBy"]; + }; rewardRisk: RepoRewardRisk; scoreBlockers: string[]; + branchQualityBlockers: string[]; + accountStateBlockers: string[]; + recommendedRerunCondition: string; localFindings: Array<{ code: string; severity: "info" | "warning" | "critical"; @@ -161,6 +194,8 @@ export function buildLocalBranchAnalysis(args: { repo: args.repo, snapshot: args.scoringSnapshot, }); + const validationSummary = summarizeValidation(args.input.validation ?? []); + const baseFreshness = buildBaseFreshness(args.input, changedFiles.length, testFiles.length, validationSummary.passed); const rewardRisk = buildRepoRewardRisk({ login: args.input.login, repo: args.repo, @@ -182,8 +217,19 @@ export function buildLocalBranchAnalysis(args: { issues: args.issues, pullRequests: args.pullRequests, }); - const localFindings = buildLocalFindings(args.input, changedFiles, preflight, scorePreview); - const validationSummary = summarizeValidation(args.input.validation ?? []); + const localFindings = buildLocalFindings(args.input, changedFiles, preflight, scorePreview, baseFreshness); + const branchQualityBlockers = branchQualityBlockersFor(preflight, localFindings); + const accountStateBlockers = accountStateBlockersFor(scorePreview); + const currentScenario = scorePreview.scenarioPreviews.find((scenario) => scenario.name === "current") ?? scorePreview.scenarioPreviews[0]!; + const bestReasonableScenario = scorePreview.scenarioPreviews.find((scenario) => scenario.name === "bestReasonableCase") ?? currentScenario; + const scenarioScorePreview = { + current: currentScenario, + bestReasonableCase: bestReasonableScenario, + afterPendingMerges: scorePreview.scenarioPreviews.find((scenario) => scenario.name === "afterPendingMerges"), + gateDeltas: scorePreview.gateDeltas, + blockedBy: scorePreview.blockedBy, + }; + const recommendedRerunCondition = recommendedRerunFor(baseFreshness, branchQualityBlockers, accountStateBlockers, scorePreview); const prPacket = buildPublicSafePrPacket({ title, preflight, @@ -205,12 +251,17 @@ export function buildLocalBranchAnalysis(args: { baseRef: args.input.baseRef, headRef: args.input.headRef, branchName: args.input.branchName, + baseFreshness, lane, roleContext, preflight, scorePreview, + scenarioScorePreview, rewardRisk, scoreBlockers: [...new Set(scoreBlockers)], + branchQualityBlockers, + accountStateBlockers, + recommendedRerunCondition, localFindings, maintainerFit: { recommendation: recommendation.recommendation, @@ -221,7 +272,7 @@ export function buildLocalBranchAnalysis(args: { risks: recommendation.risks, }, prPacket, - nextActions: rewardRisk.actions.slice(0, 6), + nextActions: withSituationalAction(rewardRisk.actions, branchQualityBlockers, accountStateBlockers, scorePreview).slice(0, 6), summary: `${args.input.repoFullName}: local branch analysis is ${preflight.status}; ${rewardRisk.actions[0]?.actionKind ?? "no ranked action"} is the top private next action.`, }; } @@ -257,6 +308,12 @@ function buildLocalScoreInput(args: { openPrCount: args.outcomeHistory.totals.openPullRequests, credibility: args.repoOutcome?.credibility ?? args.outcomeHistory.totals.credibility, metadataOnly: scorer?.mode !== "gittensor_root" && scorer?.mode !== "external_command", + pendingMergedPrCount: args.input.pendingMergedPrCount, + pendingClosedPrCount: args.input.pendingClosedPrCount, + approvedPrCount: args.input.approvedPrCount, + expectedOpenPrCountAfterMerge: args.input.expectedOpenPrCountAfterMerge, + projectedCredibility: args.input.projectedCredibility, + scenarioNotes: args.input.scenarioNotes, }; } @@ -265,6 +322,7 @@ function buildLocalFindings( changedFiles: LocalBranchChangedFile[], preflight: LocalDiffPreflightResult, scorePreview: ScorePreviewResult, + baseFreshness: LocalBranchAnalysis["baseFreshness"], ): LocalBranchAnalysis["localFindings"] { const failedValidation = (input.validation ?? []).filter((entry) => entry.status === "failed"); return [ @@ -306,6 +364,17 @@ function buildLocalFindings( }, ] : []), + ...(baseFreshness.status === "stale" || baseFreshness.status === "possibly_stale" + ? [ + { + code: "stale_base_ref", + severity: "warning" as const, + title: "Base ref may be stale", + detail: baseFreshness.warnings.join(" "), + action: baseFreshness.recommendation, + }, + ] + : []), ...scorePreview.warnings.map((warning) => ({ code: "score_preview_warning", severity: /not registered|no active|exceeds|credibility/i.test(warning) ? ("warning" as const) : ("info" as const), @@ -322,6 +391,106 @@ function buildLocalFindings( ]; } +function buildBaseFreshness( + input: LocalBranchAnalysisInput, + changedFileCount: number, + testFileCount: number, + passedValidationCount: number, +): LocalBranchAnalysis["baseFreshness"] { + const warnings: string[] = []; + if (input.remoteTrackingSha && input.baseSha && input.remoteTrackingSha !== input.baseSha) { + warnings.push(`Local base ${input.baseRef ?? "base"} is behind remote tracking SHA; current diff has ${changedFileCount} changed file(s).`); + } + if (input.mergeBaseSha && input.baseSha && input.mergeBaseSha !== input.baseSha) { + warnings.push(`Merge-base does not match the selected base ref; current diff has ${changedFileCount} changed file(s).`); + } + if (changedFileCount >= 50 && !input.remoteTrackingSha) { + warnings.push(`Large local diff has ${changedFileCount} changed file(s), but remote base freshness could not be verified.`); + } + const status = + warnings.length === 0 && input.remoteTrackingSha && input.baseSha + ? "fresh" + : warnings.some((warning) => /behind remote|Merge-base/i.test(warning)) + ? "stale" + : warnings.length > 0 + ? "possibly_stale" + : "unknown"; + return { + status, + baseRef: input.baseRef, + baseSha: input.baseSha, + headSha: input.headSha, + mergeBaseSha: input.mergeBaseSha, + remoteTrackingSha: input.remoteTrackingSha, + changedFileCount, + testFileCount, + passedValidationCount, + warnings, + recommendation: warnings.length > 0 ? "Run `git fetch origin` and rerun Gittensory branch analysis against the refreshed base." : undefined, + }; +} + +function branchQualityBlockersFor(preflight: LocalDiffPreflightResult, localFindings: LocalBranchAnalysis["localFindings"]): string[] { + return [ + ...preflight.findings.filter((finding) => finding.severity !== "info").map((finding) => finding.title), + ...localFindings + .filter((finding) => finding.severity !== "info" && finding.code !== "score_preview_warning") + .map((finding) => finding.title), + ].filter(unique); +} + +function accountStateBlockersFor(scorePreview: ScorePreviewResult): string[] { + return scorePreview.blockedBy + .filter((blocker) => ["repo_not_registered", "inactive_allocation", "open_pr_threshold", "credibility_floor"].includes(blocker.code)) + .map((blocker) => blocker.detail) + .filter(unique); +} + +function recommendedRerunFor( + baseFreshness: LocalBranchAnalysis["baseFreshness"], + branchQualityBlockers: string[], + accountStateBlockers: string[], + scorePreview: ScorePreviewResult, +): string { + if (baseFreshness.status === "stale" || baseFreshness.status === "possibly_stale") return "Run `git fetch origin` and rerun; current diff size may be inflated by stale base state."; + if (branchQualityBlockers.length > 0) return "Rerun after fixing branch-quality blockers or adding explicit validation/linked-context evidence."; + const afterPending = scorePreview.scenarioPreviews.find((scenario) => scenario.name === "afterPendingMerges"); + if (accountStateBlockers.length > 0 && afterPending && afterPending.effectiveEstimatedScore > scorePreview.effectiveEstimatedScore) { + return `Rerun after pending PRs merge/close or after open PR count is at or below ${afterPending.gates.openPrThreshold}; projected score changes ${scorePreview.effectiveEstimatedScore} -> ${afterPending.effectiveEstimatedScore}.`; + } + if (accountStateBlockers.length > 0) return "Rerun after account/queue maturity blockers clear."; + return "Rerun after any branch, base, or PR state changes before opening/submitting."; +} + +function withSituationalAction( + actions: RewardRiskAction[], + branchQualityBlockers: string[], + accountStateBlockers: string[], + scorePreview: ScorePreviewResult, +): RewardRiskAction[] { + const afterPending = scorePreview.scenarioPreviews.find((scenario) => scenario.name === "afterPendingMerges"); + if (branchQualityBlockers.length > 0 || accountStateBlockers.length === 0 || !afterPending || afterPending.effectiveEstimatedScore <= scorePreview.effectiveEstimatedScore) { + return actions; + } + const waitAction: RewardRiskAction = { + actionKind: "land_existing_prs", + repoFullName: scorePreview.repoFullName, + priorityScore: Math.max(95, actions[0]?.priorityScore ?? 0), + laneValueScore: 0, + scoreabilityScore: afterPending.effectiveEstimatedScore, + personalFitScore: 0, + riskPenalty: 0, + maintainerFrictionPenalty: 0, + actionLeverageScore: 100, + whyThisHelps: [ + `Branch metadata is not the main blocker; waiting for pending PRs to merge/close changes effective score ${scorePreview.effectiveEstimatedScore} -> ${afterPending.effectiveEstimatedScore}.`, + afterPending.deltaExplanation, + ], + nextActions: ["Wait for approved/pending PRs to merge or close, then rerun branch analysis before opening more work."], + }; + return [waitAction, ...actions]; +} + function buildPublicSafePrPacket(args: { title: string; preflight: LocalDiffPreflightResult; @@ -404,7 +573,13 @@ function isPublicSafeText(text: string): boolean { } function isTestFile(file: string): boolean { - return /(^|\/)(test|tests|spec|__tests__)\//i.test(file) || /\.(test|spec)\.(ts|tsx|js|jsx|py|rb|rs)$/i.test(file); + return ( + /(^|\/)(test|tests|spec|__tests__)\//i.test(file) || + /(^|\/)src\/test\//i.test(file) || + /(^|\/)[^/]+_test\.(go|py|rb)$/i.test(file) || + /(^|\/)[^/]+_spec\.rb$/i.test(file) || + /\.(test|spec)\.(ts|tsx|js|jsx|py|rb|rs)$/i.test(file) + ); } function isCodeFile(file: string): boolean { @@ -418,3 +593,7 @@ function sameRepo(left: string, right: string): boolean { function nonNegative(value: number | undefined): number { return Number.isFinite(value) ? Math.max(0, value ?? 0) : 0; } + +function unique(value: T, index: number, values: T[]): boolean { + return values.indexOf(value) === index; +} diff --git a/test/unit/local-branch.test.ts b/test/unit/local-branch.test.ts index 3596fd4d94..7674b4f3b3 100644 --- a/test/unit/local-branch.test.ts +++ b/test/unit/local-branch.test.ts @@ -50,6 +50,96 @@ describe("local branch analysis", () => { expect(JSON.stringify(analysis.prPacket)).not.toMatch(/reward|score|wallet|hotkey|farming|payout|ranking|trust score/i); }); + it("projects a blocked local branch into a useful after-pending-merge scenario", () => { + const pressuredHistory: ContributorOutcomeHistory = { + ...outcomeHistory, + totals: { ...outcomeHistory.totals, openPullRequests: 3, credibility: 0 }, + repoOutcomes: [ + { + ...outcomeHistory.repoOutcomes[0]!, + openPullRequests: 3, + credibility: 0, + closedPullRequestRate: 0, + closedPullRequests: 0, + }, + ], + }; + const analysis = buildLocalBranchAnalysis({ + input: { + login: "oktofeesh1", + repoFullName: repo.fullName, + baseRef: "upstream/main", + branchName: "fix-15233-entity-model", + body: "Fixes #15233", + changedFiles: [ + { path: "internal/entity/model.go", additions: 30, deletions: 4, status: "modified" }, + { path: "internal/entity/model_test.go", additions: 44, deletions: 0, status: "modified" }, + { path: "internal/service/entity.go", additions: 12, deletions: 2, status: "modified" }, + { path: "docs/entity.md", additions: 8, deletions: 1, status: "modified" }, + ], + validation: [{ command: "go test ./internal/entity ./internal/service", status: "passed", summary: "focused Go tests passed" }], + pendingMergedPrCount: 3, + projectedCredibility: 0.8, + scenarioNotes: ["three approved PRs are expected to merge"], + localScorer: { + mode: "external_command", + sourceTokenScore: 60, + totalTokenScore: 100, + sourceLines: 80, + testTokenScore: 44, + }, + }, + repo, + issues: [{ repoFullName: repo.fullName, number: 15233, title: "Entity model edge case", state: "open", labels: ["bug"], linkedPrs: [] }], + pullRequests: [], + profile, + outcomeHistory: pressuredHistory, + scoringSnapshot, + scoringProfile, + }); + + expect(analysis.preflight.localDiff).toMatchObject({ changedFileCount: 4, testFileCount: 1, codeFileCount: 2, inferredLinkedIssues: [15233] }); + expect(analysis.scorePreview.effectiveEstimatedScore).toBe(0); + expect(analysis.scorePreview.underlyingPotentialScore).toBeGreaterThan(0); + expect(analysis.scenarioScorePreview.afterPendingMerges?.source).toBe("user_supplied"); + expect(analysis.scenarioScorePreview.afterPendingMerges?.effectiveEstimatedScore).toBeGreaterThan(0); + expect(analysis.accountStateBlockers.join(" ")).toMatch(/Open PR count|Credibility/i); + expect(analysis.branchQualityBlockers.join(" ")).not.toMatch(/test/i); + expect(analysis.recommendedRerunCondition).toMatch(/pending PRs merge\/close|open PR count/i); + expect(analysis.nextActions[0]?.whyThisHelps.join(" ")).toMatch(/waiting for pending PRs/i); + }); + + it("classifies stale base state and treats passed validation as test evidence", () => { + const analysis = buildLocalBranchAnalysis({ + input: { + login: "oktofeesh1", + repoFullName: repo.fullName, + baseRef: "origin/main", + baseSha: "old-base", + headSha: "head", + mergeBaseSha: "old-base", + remoteTrackingSha: "new-base", + body: "Fixes #7", + changedFiles: [{ path: "internal/entity/model.go", additions: 10, deletions: 2, status: "modified" }], + validation: [{ command: "go test ./internal/entity", status: "passed", summary: "focused regression passed" }], + }, + repo, + issues: [{ repoFullName: repo.fullName, number: 7, title: "Entity model edge case", state: "open", labels: ["bug"], linkedPrs: [] }], + pullRequests: [], + profile, + outcomeHistory, + scoringSnapshot, + scoringProfile, + }); + + expect(analysis.baseFreshness.status).toBe("stale"); + expect(analysis.baseFreshness.warnings.join(" ")).toMatch(/behind remote tracking SHA/i); + expect(analysis.localFindings).toEqual(expect.arrayContaining([expect.objectContaining({ code: "stale_base_ref" })])); + expect(analysis.preflight.findings.map((finding) => finding.code)).not.toContain("missing_test_evidence"); + expect(analysis.preflight.findings.map((finding) => finding.code)).not.toContain("local_diff_missing_tests"); + expect(analysis.recommendedRerunCondition).toMatch(/git fetch origin/i); + }); + it("keeps unregistered gittensory work in product/maintainer context instead of miner target context", () => { const analysis = buildLocalBranchAnalysis({ input: { diff --git a/test/unit/openapi.test.ts b/test/unit/openapi.test.ts index d80ef4610d..54cf628622 100644 --- a/test/unit/openapi.test.ts +++ b/test/unit/openapi.test.ts @@ -58,5 +58,8 @@ describe("OpenAPI contract", () => { expect(spec.components?.schemas?.PullRequestMaintainerPacket).toBeDefined(); expect(spec.components?.schemas?.PullRequestReviewability).toBeDefined(); expect(spec.components?.schemas?.LocalBranchAnalysis).toBeDefined(); + expect(JSON.stringify(spec.components?.schemas?.ScorePreviewResult)).toContain("scenarioPreviews"); + expect(JSON.stringify(spec.components?.schemas?.LocalBranchAnalysis)).toContain("baseFreshness"); + expect(JSON.stringify(spec.components?.schemas?.LocalBranchAnalysis)).toContain("recommendedRerunCondition"); }); }); diff --git a/test/unit/scoring.test.ts b/test/unit/scoring.test.ts index 6a338f9197..04e2efd041 100644 --- a/test/unit/scoring.test.ts +++ b/test/unit/scoring.test.ts @@ -97,6 +97,57 @@ IGNORED = "not numeric" expect(preview.privateOnly).toBe(true); }); + it("shows conditional scoreability when current open PR pressure zeroes the effective score", () => { + const preview = buildScorePreview({ + repo, + snapshot, + input: { + repoFullName: repo.fullName, + linkedIssueMode: "standard", + sourceTokenScore: 60, + totalTokenScore: 90, + sourceLines: 50, + openPrCount: 3, + credibility: 1, + pendingMergedPrCount: 1, + }, + }); + expect(preview.effectiveEstimatedScore).toBe(0); + expect(preview.underlyingPotentialScore).toBeGreaterThan(0); + expect(preview.scoreabilityStatus).toBe("conditionally_scoreable"); + expect(preview.blockedBy).toEqual(expect.arrayContaining([expect.objectContaining({ code: "open_pr_threshold" })])); + expect(preview.scenarioPreviews.find((scenario) => scenario.name === "cleanGates")?.scoreEstimate.openPrMultiplier).toBe(1); + expect(preview.scenarioPreviews.find((scenario) => scenario.name === "afterPendingMerges")?.effectiveEstimatedScore).toBeGreaterThan(0); + expect(preview.gateDeltas).toEqual(expect.arrayContaining([expect.objectContaining({ gate: "open_pr_threshold" })])); + }); + + it("projects credibility and linked-issue scenarios without claiming guaranteed payouts", () => { + const preview = buildScorePreview({ + repo, + snapshot, + input: { + repoFullName: repo.fullName, + sourceTokenScore: 60, + totalTokenScore: 90, + sourceLines: 50, + openPrCount: 0, + credibility: 0, + approvedPrCount: 3, + projectedCredibility: 0.8, + scenarioNotes: ["three approved PRs are expected to merge tonight"], + }, + }); + const afterPending = preview.scenarioPreviews.find((scenario) => scenario.name === "afterPendingMerges"); + const linkedIssueFixed = preview.scenarioPreviews.find((scenario) => scenario.name === "linkedIssueFixed"); + expect(preview.effectiveEstimatedScore).toBe(0); + expect(preview.blockedBy).toEqual(expect.arrayContaining([expect.objectContaining({ code: "credibility_floor" })])); + expect(afterPending?.source).toBe("user_supplied"); + expect(afterPending?.gates.credibilityObserved).toBe(0.8); + expect(afterPending?.effectiveEstimatedScore).toBeGreaterThan(0); + expect(linkedIssueFixed?.scoreEstimate.issueMultiplier).toBe(1.33); + expect(JSON.stringify(preview)).not.toMatch(/guaranteed payout|wallet|hotkey|farming/i); + }); + it("warns on metadata-only weak previews without using public reward or wallet language", () => { const preview = buildScorePreview({ repo: null,