Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions packages/loopover-mcp/bin/loopover-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 <path[:additions:deletions]>]... [--test <command>]... [--test-file <path>]... [--patch-coverage-delta <percent>] [--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(
[
Expand Down Expand Up @@ -4087,6 +4134,7 @@ function printHelp() {
loopover-mcp lint-pr-text [--commit <message>]... [--body <text>] [--body-file <path>] [--linked-issue <number>] [--json]
loopover-mcp validate-config --file <path> [--source repo_file|api_record|none] [--json]
loopover-mcp slop-risk [--description <text>] [--description-file <path>] [--changed-file <path[:additions:deletions]>]... [--test <command>]... [--test-file <path>]... [--json]
loopover-mcp improvement-potential [--changed-file <path[:additions:deletions]>]... [--test <command>]... [--test-file <path>]... [--patch-coverage-delta <percent>] [--json]
loopover-mcp issue-slop [--title <text>] [--body <text>] [--body-file <path>] [--json]
loopover-mcp agent plan --login <github-login> [--repo owner/repo] [--json]
loopover-mcp agent status <run-id> [--json]
Expand Down
48 changes: 48 additions & 0 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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
Expand Down
96 changes: 96 additions & 0 deletions test/unit/mcp-cli-improvement-potential.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
110 changes: 110 additions & 0 deletions test/unit/routes-improvement-potential.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading