diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 0fc7235fed..4bffeb4a19 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -87,6 +87,7 @@ import { } from "../signals/engine"; import { buildContributorOpenPrMonitor } from "../signals/contributor-open-pr-monitor"; import { buildLocalBranchAnalysis, findCurrentBranchPullRequest } from "../signals/local-branch"; +import { computeLocalScorerTokens } from "../signals/local-scorer"; import { loadRepoFocusManifest } from "../signals/focus-manifest-loader"; import { buildPredictedGateVerdict } from "../rules/predicted-gate"; import { buildIssueSlopAssessment, buildSlopAssessment, ISSUE_SLOP_RUBRIC_MARKDOWN, SLOP_RUBRIC_MARKDOWN } from "../signals/slop"; @@ -181,6 +182,51 @@ const branchEligibilityShape = { stale: z.boolean().optional(), }; +// Changed-file metadata + local validation results — shared by the local-branch analysis and the #782 local +// scorer. METADATA ONLY (paths + line counts), never source content, so the no-upload boundary holds. +const changedFileSchema = z + .object({ + path: z.string().min(1), + previousPath: z.string().min(1).optional(), + additions: z.number().int().min(0).optional(), + deletions: z.number().int().min(0).optional(), + status: z.enum(["added", "modified", "deleted", "renamed", "copied", "unknown"]).optional(), + binary: z.boolean().optional(), + }) + .strict(); + +const validationEntrySchema = z + .object({ + command: z.string().min(1), + status: z.enum(["passed", "failed", "not_run", "skipped", "focused", "unknown"]), + summary: z.string().optional(), + durationMs: z.number().int().min(0).optional(), + exitCode: z.number().int().min(0).optional(), + }) + .strict(); + +// #782 run_local_scorer input — changed-file metadata + the local validation results. +const runLocalScorerShape = { + changedFiles: z.array(changedFileSchema).min(1).max(500), + validation: z.array(validationEntrySchema).max(50).optional(), +}; + +const runLocalScorerOutputSchema = { + tokenScores: z + .object({ + mode: z.string(), + activeModel: z.string().optional(), + sourceTokenScore: z.number().optional(), + totalTokenScore: z.number().optional(), + sourceLines: z.number().optional(), + testTokenScore: z.number().optional(), + nonCodeTokenScore: z.number().optional(), + warnings: z.array(z.string()).optional(), + }) + .optional(), + usage: z.string().optional(), +}; + const localBranchAnalysisShape = { login: z.string().min(1).max(SCENARIO_MAX_BRANCH_REF_CHARS), repoFullName: z.string().min(3).max(SCENARIO_MAX_REPO_FULL_NAME_CHARS), @@ -192,35 +238,8 @@ const localBranchAnalysisShape = { mergeBaseSha: z.string().min(1).optional(), remoteTrackingSha: z.string().min(1).optional(), commitMessages: z.array(z.string()).max(30).optional(), - changedFiles: z - .array( - z - .object({ - path: z.string().min(1), - previousPath: z.string().min(1).optional(), - additions: z.number().int().min(0).optional(), - deletions: z.number().int().min(0).optional(), - status: z.enum(["added", "modified", "deleted", "renamed", "copied", "unknown"]).optional(), - binary: z.boolean().optional(), - }) - .strict(), - ) - .max(500) - .optional(), - validation: z - .array( - z - .object({ - command: z.string().min(1), - status: z.enum(["passed", "failed", "not_run", "skipped", "focused", "unknown"]), - summary: z.string().optional(), - durationMs: z.number().int().min(0).optional(), - exitCode: z.number().int().min(0).optional(), - }) - .strict(), - ) - .max(50) - .optional(), + changedFiles: z.array(changedFileSchema).max(500).optional(), + validation: z.array(validationEntrySchema).max(50).optional(), linkedIssues: z.array(z.number().int().positive()).max(SCENARIO_MAX_LINKED_ISSUE_NUMBERS).optional(), labels: z.array(z.string()).optional(), title: z.string().min(1).optional(), @@ -996,6 +1015,17 @@ export class GittensoryMcp { async (input) => this.toolResult(await this.previewScore(input)), ); + server.registerTool( + "gittensory_run_local_scorer", + { + description: + "Run Gittensory's deterministic local token scorer over changed-file metadata + local validation results (no source content). Returns token scores to pass back as the `localScorer` field of the score-preview / analyze tools (external_command mode), so the miner never runs the gittensor-root scorer by hand.", + inputSchema: runLocalScorerShape, + outputSchema: runLocalScorerOutputSchema, + }, + async (input) => this.toolResult(this.runLocalScorer(input)), + ); + server.registerTool( "gittensory_explain_score_breakdown", { @@ -1738,6 +1768,19 @@ export class GittensoryMcp { }; } + // #782 — pure deterministic token scorer over caller-supplied changed-file metadata. No repo/contributor + // access required: it reveals nothing beyond a computation on the caller's own diff stats. + private runLocalScorer(input: z.infer>): ToolPayload { + const tokenScores = computeLocalScorerTokens({ changedFiles: input.changedFiles, validation: input.validation }); + return { + summary: `Local token scores — ${tokenScores.sourceTokenScore} source / ${tokenScores.testTokenScore} test / ${tokenScores.nonCodeTokenScore} non-code (total ${tokenScores.totalTokenScore}).`, + data: { + tokenScores: tokenScores as unknown as Record, + usage: "Pass `tokenScores` as the `localScorer` field of gittensory_preview_local_pr_score or the analyze tools to score this branch in external_command mode (off metadata-only).", + }, + }; + } + private async explainScoreBreakdown(input: z.infer>): Promise { if (!input.contributorLogin) throw new Error("contributorLogin is required for score breakdown."); this.requireContributorAccess(input.contributorLogin); diff --git a/src/signals/local-scorer.ts b/src/signals/local-scorer.ts new file mode 100644 index 0000000000..ef4d706b70 --- /dev/null +++ b/src/signals/local-scorer.ts @@ -0,0 +1,34 @@ +import { isCodeFile, isTestFile, type LocalBranchChangedFile, type LocalBranchScorer, type LocalBranchValidation } from "./local-branch"; + +// #782 deterministic local scorer. Replicates the gittensor-root token-scoring view from changed-file METADATA +// (paths + line counts) — never source content, so the no-upload boundary holds and it runs in every surface +// (stdio package AND hosted Worker). It mirrors buildScorePreview's source/test/non-code classification, so +// feeding its output back in as `localScorer` (mode external_command) flips the preview off metadata-only with +// numbers it would otherwise have derived itself — closing the "miner runs the scorer manually" gap. + +const fileLines = (file: LocalBranchChangedFile): number => Math.max(0, file.additions ?? 0) + Math.max(0, file.deletions ?? 0); + +/** + * Compute token scores from changed-file metadata + the local validation results. `isCodeFile` already excludes + * tests, so source / test / non-code are disjoint. Binary files carry no token value and are dropped. A failed + * validation does not change the scores (they describe the diff) but is surfaced as a warning. Pure. + */ +export function computeLocalScorerTokens(input: { changedFiles: LocalBranchChangedFile[]; validation?: LocalBranchValidation[] | undefined }): LocalBranchScorer { + const files = input.changedFiles.filter((file) => !file.binary); + const testTokenScore = files.filter((file) => isTestFile(file.path)).reduce((sum, file) => sum + fileLines(file), 0); + const sourceTokenScore = files.filter((file) => isCodeFile(file.path)).reduce((sum, file) => sum + fileLines(file), 0); + const totalTokenScore = files.reduce((sum, file) => sum + fileLines(file), 0); + const nonCodeTokenScore = Math.max(0, totalTokenScore - sourceTokenScore - testTokenScore); + const failed = (input.validation ?? []).some((entry) => entry.status === "failed"); + const warnings = failed ? ["Local validation reported failures — token scores describe the diff, not a passing build."] : []; + return { + mode: "external_command", + activeModel: "gittensory-deterministic", + sourceTokenScore, + totalTokenScore, + sourceLines: Math.max(1, sourceTokenScore || totalTokenScore || 1), + testTokenScore, + nonCodeTokenScore, + ...(warnings.length > 0 ? { warnings } : {}), + }; +} diff --git a/test/unit/local-scorer.test.ts b/test/unit/local-scorer.test.ts new file mode 100644 index 0000000000..2ee2f60700 --- /dev/null +++ b/test/unit/local-scorer.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { computeLocalScorerTokens } from "../../src/signals/local-scorer"; + +describe("computeLocalScorerTokens (#782)", () => { + it("classifies source / test / non-code from metadata and sums additions + deletions", () => { + const scorer = computeLocalScorerTokens({ + changedFiles: [ + { path: "src/foo.ts", additions: 10, deletions: 2 }, + { path: "src/foo.test.ts", additions: 8, deletions: 0 }, + { path: "README.md", additions: 5, deletions: 1 }, + ], + }); + expect(scorer).toMatchObject({ + mode: "external_command", + activeModel: "gittensory-deterministic", + sourceTokenScore: 12, + testTokenScore: 8, + nonCodeTokenScore: 6, + totalTokenScore: 26, + sourceLines: 12, + }); + expect(scorer.warnings).toBeUndefined(); + }); + + it("drops binary files; with no source, sourceLines falls back to total (matching buildScorePreview)", () => { + const scorer = computeLocalScorerTokens({ + changedFiles: [ + { path: "img.png", additions: 100, binary: true }, + { path: "docs.md", additions: 3 }, + ], + }); + expect(scorer.totalTokenScore).toBe(3); // the binary file carries no token value + expect(scorer.sourceTokenScore).toBe(0); + expect(scorer.nonCodeTokenScore).toBe(3); + expect(scorer.sourceLines).toBe(3); // no source → falls back to total, floored at 1 + }); + + it("floors sourceLines at 1 for a diff with no line counts at all", () => { + const scorer = computeLocalScorerTokens({ changedFiles: [{ path: "docs.md" }] }); // additions/deletions omitted + expect(scorer.totalTokenScore).toBe(0); + expect(scorer.sourceLines).toBe(1); + }); + + it("surfaces a warning when local validation reports failures, without changing the scores", () => { + const scorer = computeLocalScorerTokens({ + changedFiles: [{ path: "src/a.ts", additions: 4 }], + validation: [ + { command: "npm test", status: "passed" }, + { command: "npm run typecheck", status: "failed" }, + ], + }); + expect(scorer.sourceTokenScore).toBe(4); + expect(scorer.warnings?.[0]).toMatch(/validation reported failures/i); + }); + + it("emits no warning when validation passed or was not supplied", () => { + expect(computeLocalScorerTokens({ changedFiles: [{ path: "src/a.ts", additions: 1 }], validation: [{ command: "t", status: "passed" }] }).warnings).toBeUndefined(); + expect(computeLocalScorerTokens({ changedFiles: [{ path: "src/a.ts", additions: 1 }] }).warnings).toBeUndefined(); + }); +}); diff --git a/test/unit/mcp-run-local-scorer.test.ts b/test/unit/mcp-run-local-scorer.test.ts new file mode 100644 index 0000000000..b5d451b95d --- /dev/null +++ b/test/unit/mcp-run-local-scorer.test.ts @@ -0,0 +1,45 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { describe, expect, it } from "vitest"; +import { GittensoryMcp } from "../../src/mcp/server"; +import { createTestEnv } from "../helpers/d1"; + +async function connect() { + const server = new GittensoryMcp(createTestEnv()).createServer(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + const client = new Client({ name: "gittensory-scorer-test", version: "0.1.0" }, { capabilities: {} }); + await client.connect(clientTransport); + return client; +} + +describe("MCP gittensory_run_local_scorer (#782)", () => { + it("returns deterministic token scores from changed-file metadata (no repo/auth needed)", async () => { + const client = await connect(); + const result = await client.callTool({ + name: "gittensory_run_local_scorer", + arguments: { + changedFiles: [ + { path: "src/foo.ts", additions: 10, deletions: 2 }, + { path: "src/foo.test.ts", additions: 8 }, + { path: "README.md", additions: 5 }, + ], + }, + }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as { tokenScores: { mode: string; sourceTokenScore: number; testTokenScore: number; nonCodeTokenScore: number; totalTokenScore: number }; usage: string }; + expect(data.tokenScores).toMatchObject({ mode: "external_command", sourceTokenScore: 12, testTokenScore: 8, nonCodeTokenScore: 5, totalTokenScore: 25 }); + expect(data.usage).toMatch(/localScorer/); + expect(JSON.stringify(data)).not.toMatch(/wallet|hotkey|reward|payout|trust score/i); + }); + + it("surfaces a validation-failure warning", async () => { + const client = await connect(); + const result = await client.callTool({ + name: "gittensory_run_local_scorer", + arguments: { changedFiles: [{ path: "src/a.ts", additions: 4 }], validation: [{ command: "npm test", status: "failed" }] }, + }); + const data = result.structuredContent as { tokenScores: { warnings?: string[] } }; + expect(data.tokenScores.warnings?.[0]).toMatch(/validation reported failures/i); + }); +});