From 62fc726e022bbfaecfe4386d4d4f3ec2039536de Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Fri, 17 Jul 2026 23:35:03 +0800 Subject: [PATCH] feat(api): REST + CLI mirror for loopover_check_improvement_potential Closes #6748. Co-authored-by: Cursor --- packages/loopover-mcp/bin/loopover-mcp.js | 48 ++++++++ src/api/routes.ts | 48 ++++++++ .../mcp-cli-improvement-potential.test.ts | 96 +++++++++++++++ .../unit/routes-improvement-potential.test.ts | 110 ++++++++++++++++++ test/unit/support/mcp-cli-harness.ts | 56 +++++++++ 5 files changed, 358 insertions(+) create mode 100644 test/unit/mcp-cli-improvement-potential.test.ts create mode 100644 test/unit/routes-improvement-potential.test.ts diff --git a/packages/loopover-mcp/bin/loopover-mcp.js b/packages/loopover-mcp/bin/loopover-mcp.js index 7928263cc6..c89c4037fd 100644 --- a/packages/loopover-mcp/bin/loopover-mcp.js +++ b/packages/loopover-mcp/bin/loopover-mcp.js @@ -94,6 +94,7 @@ const CLI_COMMAND_SPEC = { "lint-pr-text": [], "validate-config": [], "slop-risk": [], + "improvement-potential": [], "issue-slop": [], profile: ["list", "create", "switch", "remove"], cache: ["status", "clear", "list"], @@ -3201,6 +3202,7 @@ async function runCli(args) { if (command === "lint-pr-text") return lintPrTextCli(args.slice(1)); if (command === "validate-config") return validateConfigCli(args.slice(1)); if (command === "slop-risk") return slopRiskCli(args.slice(1)); + if (command === "improvement-potential") return improvementPotentialCli(args.slice(1)); if (command === "issue-slop") return issueSlopCli(args.slice(1)); if (command === "decision-pack") return decisionPackCli(options); if (command === "repo-decision") return repoDecisionCli(options); @@ -3502,6 +3504,51 @@ async function slopRiskCli(args) { process.stdout.write(`- ${sanitizePlainTextTerminalOutput(finding.title)}: ${sanitizePlainTextTerminalOutput(finding.detail)}\n`); } +function printImprovementPotentialHelp() { + process.stdout.write( + [ + "Usage: loopover-mcp improvement-potential [--changed-file ]... [--test ]... [--test-file ]... [--patch-coverage-delta ] [--json]", + "", + "Assess deterministic structural-improvement potential from local diff metadata.", + "Mirrors the loopover_check_improvement_potential MCP tool and POST /v1/lint/improvement-potential. No source upload.", + "", + "Pass --json for machine-readable output.", + ].join("\n") + "\n", + ); +} + +async function improvementPotentialCli(args) { + // #6748: shell CLI mirror of loopover_check_improvement_potential, matching slopRiskCli's HTTP-proxy pattern + // (the pure builder lives in src/signals/improvement.ts, not yet an @loopover/engine export for in-process use). + if (!args.length || args[0] === "--help" || args[0] === "help") return printImprovementPotentialHelp(); + const options = parseOptions(args); + const changedFiles = stringArrayOption(options.changedFile).map(parseChangedFileSpec); + const tests = stringArrayOption(options.test); + const testFiles = stringArrayOption(options.testFile); + let patchCoverageDeltaPercent; + if (options.patchCoverageDelta !== undefined) { + patchCoverageDeltaPercent = Number(options.patchCoverageDelta); + if (!Number.isFinite(patchCoverageDeltaPercent)) { + throw new Error("--patch-coverage-delta must be a finite number"); + } + } + const payload = await apiPost("/v1/lint/improvement-potential", { + ...(changedFiles.length ? { changedFiles } : {}), + ...(tests.length ? { tests } : {}), + ...(testFiles.length ? { testFiles } : {}), + ...(patchCoverageDeltaPercent !== undefined ? { patchCoverageDeltaPercent } : {}), + }); + if (options.json) { + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + return; + } + process.stdout.write( + `Improvement potential: ${sanitizePlainTextTerminalOutput(payload.improvementScore)} (${sanitizePlainTextTerminalOutput(payload.band)})\n`, + ); + for (const finding of payload.findings ?? []) + process.stdout.write(`- ${sanitizePlainTextTerminalOutput(finding.title)}: ${sanitizePlainTextTerminalOutput(finding.detail)}\n`); +} + function printIssueSlopHelp() { process.stdout.write( [ @@ -4087,6 +4134,7 @@ function printHelp() { loopover-mcp lint-pr-text [--commit ]... [--body ] [--body-file ] [--linked-issue ] [--json] loopover-mcp validate-config --file [--source repo_file|api_record|none] [--json] loopover-mcp slop-risk [--description ] [--description-file ] [--changed-file ]... [--test ]... [--test-file ]... [--json] + loopover-mcp improvement-potential [--changed-file ]... [--test ]... [--test-file ]... [--patch-coverage-delta ] [--json] loopover-mcp issue-slop [--title ] [--body ] [--body-file ] [--json] loopover-mcp agent plan --login [--repo owner/repo] [--json] loopover-mcp agent status [--json] diff --git a/src/api/routes.ts b/src/api/routes.ts index 9edc02215e..1f02055a5a 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -201,6 +201,7 @@ import { runFindOpportunities, validateFindOpportunitiesInput, type FindOpportun import { runIssueRagRetrieval, validateIssueRagInput, type IssueRagInput } from "../mcp/issue-rag"; import { buildBoundaryTestGenerationFinding, buildBoundaryTestGenerationSpec } from "../signals/boundary-test-generation"; import { buildTestEvidenceReport } from "../signals/test-evidence"; +import { buildStructuralImprovementAssessment } from "../signals/improvement"; import { evaluateEscalation } from "../loop-escalation"; import { buildResultsPayload } from "../results-payload"; import { buildProgressSnapshot } from "../loop-progress"; @@ -578,6 +579,42 @@ const slopRiskSchema = z.object({ hasLinkedIssue: z.boolean().optional(), issueDiscoveryLane: z.boolean().optional(), }); + +// #6748: mirrors checkImprovementPotentialShape in src/mcp/server.ts VERBATIM (same bounds, same optionality) +// so the REST surface can never accept an input the MCP tool would reject, or vice versa. +const improvementPotentialSchema = z.object({ + changedFiles: z + .array(z.object({ path: z.string().min(1).max(400), additions: z.number().int().min(0).optional(), deletions: z.number().int().min(0).optional() })) + .max(2000) + .optional(), + tests: z.array(z.string().max(400)).max(2000).optional(), + testFiles: z.array(z.string().max(400)).max(2000).optional(), + patchCoverageDeltaPercent: z.number().optional(), + complexityDeltas: z + .array( + z.object({ + file: z.string().min(1).max(400), + line: z.number().int().min(1), + name: z.string().min(1).max(400), + before: z.number().int().min(0), + after: z.number().int().min(0), + delta: z.number().int(), + }), + ) + .max(2000) + .optional(), + duplicationDeltas: z + .array( + z.object({ + file: z.string().min(1).max(400), + line: z.number().int().min(1), + duplicateOfLine: z.number().int().min(1), + lines: z.number().int().min(1), + }), + ) + .max(2000) + .optional(), +}); const issueSlopSchema = z.object({ title: z.string().max(500).optional(), body: z.string().max(40000).optional(), @@ -3306,6 +3343,17 @@ export function createApp() { return c.json({ ...buildSlopAssessment(parsed.data), rubric: SLOP_RUBRIC_MARKDOWN }); }); + // #6748: REST mirror of the loopover_check_improvement_potential MCP tool, bringing it to the same parity its + // same-tier sibling /v1/lint/slop-risk (directly above) already has. Both are pure, source-free evaluators over + // caller-supplied local-diff metadata, so this route delegates to the same buildStructuralImprovementAssessment + // the tool calls and adds no logic of its own. Advisory-only — improvementScore never gates. + app.post("/v1/lint/improvement-potential", async (c) => { + const body = await c.req.json().catch(() => null); + const parsed = improvementPotentialSchema.safeParse(body); + if (!parsed.success) return c.json({ error: "invalid_improvement_potential_request", issues: parsed.error.issues }, 400); + return c.json(buildStructuralImprovementAssessment(parsed.data)); + }); + // #6751: REST mirror of the loopover_simulate_open_pr_pressure MCP tool — deterministic, public-safe, and // read-only (no repo access, no GitHub writes), the same tier as the lint routes it sits with. Parses with the // tool's OWN exported simulateOpenPrPressureShape so the two surfaces cannot diverge on accepted input, then diff --git a/test/unit/mcp-cli-improvement-potential.test.ts b/test/unit/mcp-cli-improvement-potential.test.ts new file mode 100644 index 0000000000..85891faedd --- /dev/null +++ b/test/unit/mcp-cli-improvement-potential.test.ts @@ -0,0 +1,96 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + closeFixtureServer, + runAsync, + startFixtureServer, +} from "./support/mcp-cli-harness"; + +// #6748: shell CLI mirror of loopover_check_improvement_potential (slopRiskCli pattern — HTTP proxy to +// POST /v1/lint/improvement-potential). Asserts the CLI surfaces the fixture assessment and rejects bad flags. +describe("loopover-mcp CLI — improvement-potential (#6748)", () => { + let tempDir: string | null = null; + + afterEach(async () => { + await closeFixtureServer(); + if (tempDir) rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; + }); + + async function env() { + tempDir = mkdtempSync(join(tmpdir(), "loopover-cli-")); + const url = await startFixtureServer(); + return { + LOOPOVER_API_URL: url, + LOOPOVER_TOKEN: "session-token", + LOOPOVER_CONFIG_DIR: tempDir, + LOOPOVER_API_TIMEOUT_MS: "1000", + }; + } + + it("assesses improvement potential via the API and prints plain or json output", async () => { + const e = await env(); + const plain = await runAsync( + [ + "improvement-potential", + "--changed-file", + "src/widget.ts:80:2", + "--test-file", + "test/unit/widget.test.ts", + ], + e, + ); + expect(plain).toMatch(/Improvement potential: 10 \(minor\)/); + + const json = JSON.parse( + await runAsync( + [ + "improvement-potential", + "--changed-file", + "src/widget.ts:80:2", + "--test-file", + "test/unit/widget.test.ts", + "--json", + ], + e, + ), + ) as { improvementScore: number; band: string; findings: unknown[] }; + expect(json).toMatchObject({ + improvementScore: 10, + band: "minor", + findings: expect.any(Array), + }); + expect(JSON.stringify(json)).not.toMatch( + /wallet|hotkey|reward|trust score/i, + ); + }); + + it("accepts --patch-coverage-delta and rejects a non-finite value", async () => { + const e = await env(); + const json = JSON.parse( + await runAsync( + ["improvement-potential", "--patch-coverage-delta", "4.5", "--json"], + e, + ), + ) as { + band: string; + }; + expect(json.band).toBe("minor"); + + await expect( + runAsync( + ["improvement-potential", "--patch-coverage-delta", "not-a-number"], + e, + ), + ).rejects.toThrow(/patch-coverage-delta must be a finite number/); + }); + + it("prints help without calling the API", async () => { + const e = await env(); + const help = await runAsync(["improvement-potential", "--help"], e); + expect(help).toMatch(/POST \/v1\/lint\/improvement-potential/); + expect(help).toMatch(/loopover_check_improvement_potential/); + }); +}); diff --git a/test/unit/routes-improvement-potential.test.ts b/test/unit/routes-improvement-potential.test.ts new file mode 100644 index 0000000000..043d591397 --- /dev/null +++ b/test/unit/routes-improvement-potential.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; +import { createApp } from "../../src/api/routes"; +import { buildStructuralImprovementAssessment } from "../../src/signals/improvement"; +import { createTestEnv } from "../helpers/d1"; + +// #6748: POST /v1/lint/improvement-potential — the REST mirror bringing loopover_check_improvement_potential +// to the parity its same-tier sibling /v1/lint/slop-risk already has. The builder's own correctness is pinned +// independently by improvement.test.ts; these assert the ROUTE contract: schema parity with the MCP tool's +// shape, the assessment passed through unmodified, and 400s on invalid input. +const apiHeaders = (env: Env) => ({ + authorization: `Bearer ${env.LOOPOVER_API_TOKEN}`, + "content-type": "application/json", +}); +const PATH = "/v1/lint/improvement-potential"; +const post = (env: Env, body: unknown) => + createApp().request( + PATH, + { method: "POST", headers: apiHeaders(env), body: JSON.stringify(body) }, + env, + ); + +describe("POST /v1/lint/improvement-potential (#6748)", () => { + it("returns the shared builder's assessment for every arm", async () => { + const env = createTestEnv(); + const cases = [ + {}, + { changedFiles: [{ path: "src/a.ts", additions: 10, deletions: 2 }] }, + { + changedFiles: [{ path: "src/a.ts", additions: 10, deletions: 2 }], + testFiles: ["test/a.test.ts"], + }, + { patchCoverageDeltaPercent: 5 }, + { + complexityDeltas: [ + { + file: "src/a.ts", + line: 1, + name: "fn", + before: 8, + after: 3, + delta: -5, + }, + ], + }, + { + duplicationDeltas: [ + { file: "src/a.ts", line: 10, duplicateOfLine: 40, lines: 6 }, + ], + }, + ]; + for (const body of cases) { + const response = await post(env, body); + expect(response.status, JSON.stringify(body)).toBe(200); + await expect(response.json()).resolves.toEqual( + JSON.parse(JSON.stringify(buildStructuralImprovementAssessment(body))), + ); + } + }); + + it("rejects an invalid or unparseable body with 400", async () => { + const env = createTestEnv(); + for (const body of [ + { changedFiles: "nope" }, + { changedFiles: [{ path: "" }] }, + { tests: "free text is not an array" }, + { patchCoverageDeltaPercent: "high" }, + { + complexityDeltas: [ + { + file: "src/a.ts", + line: 0, + name: "fn", + before: 1, + after: 1, + delta: 0, + }, + ], + }, + ]) { + const response = await post(env, body); + expect(response.status, JSON.stringify(body)).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + error: "invalid_improvement_potential_request", + }); + } + const malformed = await createApp().request( + PATH, + { + method: "POST", + headers: apiHeaders(createTestEnv()), + body: "{not json", + }, + createTestEnv(), + ); + expect(malformed.status).toBe(400); + }); + + it("uploads no source and leaks no private terms — path metadata only", async () => { + const env = createTestEnv(); + const text = JSON.stringify( + await ( + await post(env, { + changedFiles: [{ path: "src/a.ts" }], + testFiles: ["test/a.test.ts"], + }) + ).json(), + ); + expect(text).not.toMatch(/wallet|hotkey|coldkey|trust score|reward/i); + }); +}); diff --git a/test/unit/support/mcp-cli-harness.ts b/test/unit/support/mcp-cli-harness.ts index ff124b8a02..682ec32907 100644 --- a/test/unit/support/mcp-cli-harness.ts +++ b/test/unit/support/mcp-cli-harness.ts @@ -380,6 +380,16 @@ export async function startFixtureServer( response.end(JSON.stringify(withTerminalInjection(slopRiskFixture(body), options.terminalInjection))); return; } + if (request.url === "/v1/lint/improvement-potential" && request.method === "POST") { + const body = (await readJsonRequest(request)) as { + changedFiles?: Array<{ path: string; additions?: number; deletions?: number }>; + tests?: string[]; + testFiles?: string[]; + patchCoverageDeltaPercent?: number; + }; + response.end(JSON.stringify(withTerminalInjection(improvementPotentialFixture(body), options.terminalInjection))); + return; + } if (request.url === "/v1/lint/issue-slop" && request.method === "POST") { const body = (await readJsonRequest(request)) as { title?: string; body?: string }; response.end(JSON.stringify(withTerminalInjection(issueSlopFixture(body), options.terminalInjection))); @@ -879,6 +889,52 @@ export function slopRiskFixture(input: { }; } +/** #6748: fixture for POST /v1/lint/improvement-potential — mirrors a mild positive signal when tests accompany code. */ +export function improvementPotentialFixture(input: { + changedFiles?: Array<{ path: string; additions?: number; deletions?: number }>; + tests?: string[]; + testFiles?: string[]; + patchCoverageDeltaPercent?: number; +} = {}) { + const changedFiles = input.changedFiles ?? []; + const hasCodeChange = changedFiles.some((file) => /\.(ts|tsx|js|jsx)$/i.test(file.path) && !file.path.includes(".test.")); + const hasTestEvidence = + changedFiles.some((file) => file.path.includes(".test.")) || (input.testFiles?.length ?? 0) > 0 || (input.tests?.length ?? 0) > 0; + const coverageUp = typeof input.patchCoverageDeltaPercent === "number" && input.patchCoverageDeltaPercent > 0; + if (!hasCodeChange && !coverageUp) { + return { improvementScore: 0, band: "insufficient-signal", findings: [] }; + } + if (hasCodeChange && hasTestEvidence) { + return { + improvementScore: 10, + band: "minor", + findings: [ + { + code: "added_test_evidence", + title: "Added test evidence", + severity: "info", + detail: "Fixture: code change accompanied by tests.", + }, + ], + }; + } + if (coverageUp) { + return { + improvementScore: 20, + band: "minor", + findings: [ + { + code: "increased_patch_coverage", + title: "Increased patch coverage", + severity: "info", + detail: "Fixture: patch coverage delta is positive.", + }, + ], + }; + } + return { improvementScore: 0, band: "none", findings: [] }; +} + export function issueSlopFixture(input: { title?: string; body?: string } = {}) { const bodyText = typeof input.body === "string" ? input.body : ""; const emptyBody = !bodyText.trim();