From f9fc2b03ef62ffe7b64a89d73ce9329a5114afc9 Mon Sep 17 00:00:00 2001 From: jimcody1995 Date: Fri, 3 Jul 2026 00:44:10 +0000 Subject: [PATCH] feat(mcp): add slop-risk CLI for pre-push slop self-checks Co-authored-by: Cursor --- packages/gittensory-mcp/README.md | 1 + packages/gittensory-mcp/bin/gittensory-mcp.js | 66 ++++++++++- test/unit/mcp-cli-slop-risk.test.ts | 106 ++++++++++++++++++ test/unit/support/mcp-cli-harness.ts | 36 ++++++ 4 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 test/unit/mcp-cli-slop-risk.test.ts diff --git a/packages/gittensory-mcp/README.md b/packages/gittensory-mcp/README.md index a6da3b9656..e403e30ec4 100644 --- a/packages/gittensory-mcp/README.md +++ b/packages/gittensory-mcp/README.md @@ -58,6 +58,7 @@ gittensory-mcp repo-decision --login jsonbored --repo we-promise/sure --json gittensory-mcp analyze-branch --login jsonbored --json gittensory-mcp preflight --login jsonbored --json gittensory-mcp lint-pr-text --commit "feat(mcp): add doctor grouping" --body "Fixes #160. Validated with npm test." --linked-issue 160 --json +gittensory-mcp slop-risk --changed-file src/widget.ts:80:2 --description "Adds retry handling." --test-file test/unit/widget.test.ts --json gittensory-mcp agent plan --login jsonbored --json gittensory-mcp agent packet --login jsonbored --json gittensory-mcp agent status --json diff --git a/packages/gittensory-mcp/bin/gittensory-mcp.js b/packages/gittensory-mcp/bin/gittensory-mcp.js index 280189ec7c..183a93b182 100755 --- a/packages/gittensory-mcp/bin/gittensory-mcp.js +++ b/packages/gittensory-mcp/bin/gittensory-mcp.js @@ -40,6 +40,7 @@ const CLI_COMMAND_SPEC = { "analyze-branch": [], preflight: [], "lint-pr-text": [], + "slop-risk": [], profile: ["list", "create", "switch", "remove"], cache: ["status", "clear"], agent: ["plan", "status", "explain", "packet"], @@ -1404,6 +1405,7 @@ async function runCli(args) { if (command === "doctor") return doctor(options); if (command === "init-client") return initClient(options); if (command === "lint-pr-text") return lintPrTextCli(args.slice(1)); + if (command === "slop-risk") return slopRiskCli(args.slice(1)); if (command === "decision-pack") return decisionPackCli(options); if (command === "repo-decision") return repoDecisionCli(options); if (command !== "analyze-branch" && command !== "preflight") { @@ -1479,6 +1481,67 @@ async function lintPrTextCli(args) { for (const fix of payload.fixes ?? []) process.stdout.write(`- ${fix}\n`); } +function printSlopRiskHelp() { + process.stdout.write( + [ + "Usage: gittensory-mcp slop-risk [--description ] [--description-file ] [--changed-file ]... [--test ]... [--test-file ]... [--json]", + "", + "Assess deterministic slop risk from local diff metadata and a PR description.", + "Mirrors the gittensory_check_slop_risk MCP tool and POST /v1/lint/slop-risk. No source upload.", + "", + "Pass --json for machine-readable output.", + ].join("\n") + "\n", + ); +} + +function stringArrayOption(value) { + if (!value) return []; + return Array.isArray(value) ? value : [value]; +} + +function parseChangedFileSpec(raw) { + const [path, additions, deletions] = String(raw).split(":"); + if (!path) throw new Error(`Invalid --changed-file value: ${raw}`); + const entry = { path }; + if (additions !== undefined && additions !== "") { + const parsedAdditions = Number(additions); + if (!Number.isInteger(parsedAdditions) || parsedAdditions < 0) throw new Error(`Invalid additions in --changed-file: ${raw}`); + entry.additions = parsedAdditions; + } + if (deletions !== undefined && deletions !== "") { + const parsedDeletions = Number(deletions); + if (!Number.isInteger(parsedDeletions) || parsedDeletions < 0) throw new Error(`Invalid deletions in --changed-file: ${raw}`); + entry.deletions = parsedDeletions; + } + return entry; +} + +async function slopRiskCli(args) { + if (!args.length || args[0] === "--help" || args[0] === "help") return printSlopRiskHelp(); + const options = parseOptions(args); + let description = options.description ?? options.body; + const descriptionFile = options.descriptionFile ?? options.bodyFile; + if (descriptionFile) { + if (!existsSync(descriptionFile)) throw new Error(`Description file not found: ${descriptionFile}`); + description = readFileSync(descriptionFile, "utf8"); + } + const changedFiles = stringArrayOption(options.changedFile).map(parseChangedFileSpec); + const tests = stringArrayOption(options.test); + const testFiles = stringArrayOption(options.testFile); + const payload = await apiPost("/v1/lint/slop-risk", { + ...(changedFiles.length ? { changedFiles } : {}), + ...(description !== undefined ? { description } : {}), + ...(tests.length ? { tests } : {}), + ...(testFiles.length ? { testFiles } : {}), + }); + if (options.json) { + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + return; + } + process.stdout.write(`Slop risk: ${payload.slopRisk} (${payload.band})\n`); + for (const finding of payload.findings ?? []) process.stdout.write(`- ${finding.title}: ${finding.detail}\n`); +} + async function decisionPackCli(options) { const login = options.login ?? process.env.GITTENSORY_LOGIN ?? process.env.GITHUB_LOGIN; if (!login) throw new Error("Pass --login or set GITTENSORY_LOGIN."); @@ -1866,6 +1929,7 @@ function printHelp() { gittensory-mcp analyze-branch --login [--repo owner/repo] [--base origin/main] [--branch-eligibility eligible|ineligible|unknown] [--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] [--branch-eligibility eligible|ineligible|unknown] [--pending-merged-prs 3] [--expected-open-prs 0] [--projected-credibility 0.8] [--validation "passed|npm test|summary"] [--json] gittensory-mcp lint-pr-text [--commit ]... [--body ] [--body-file ] [--linked-issue ] [--json] + gittensory-mcp slop-risk [--description ] [--description-file ] [--changed-file ]... [--test ]... [--test-file ]... [--json] gittensory-mcp agent plan --login [--repo owner/repo] [--json] gittensory-mcp agent status [--json] gittensory-mcp agent explain [--json] @@ -1920,7 +1984,7 @@ Use --profile or GITTENSORY_PROFILE to run login, logout, whoami, status, function parseOptions(args) { const options = {}; - const repeatable = new Set(["label", "issue", "commit", "validation", "validationCommand", "validationStatus", "validationSummary", "validationDuration", "scenarioNote"]); + const repeatable = new Set(["label", "issue", "commit", "changedFile", "test", "testFile", "validation", "validationCommand", "validationStatus", "validationSummary", "validationDuration", "scenarioNote"]); for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg === "--json") { diff --git a/test/unit/mcp-cli-slop-risk.test.ts b/test/unit/mcp-cli-slop-risk.test.ts new file mode 100644 index 0000000000..c1318482c5 --- /dev/null +++ b/test/unit/mcp-cli-slop-risk.test.ts @@ -0,0 +1,106 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { closeFixtureServer, run, runAsync, startFixtureServer } from "./support/mcp-cli-harness"; + +describe("gittensory-mcp CLI — slop-risk", () => { + 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(), "gittensory-cli-")); + const url = await startFixtureServer(); + return { GITTENSORY_API_URL: url, GITTENSORY_TOKEN: "session-token", GITTENSORY_CONFIG_DIR: tempDir, GITTENSORY_API_TIMEOUT_MS: "1000" }; + } + + it("assesses slop risk via the API and prints plain or json output", async () => { + const e = await env(); + const plain = await runAsync( + [ + "slop-risk", + "--changed-file", + "src/widget.ts:80:2", + "--description", + "Adds retry handling for transient widget failures. Validated with npm test.", + "--test", + "npm test", + ], + e, + ); + expect(plain).toMatch(/Slop risk: 0 \(clean\)/); + + const json = JSON.parse( + await runAsync( + [ + "slop-risk", + "--changed-file", + "src/widget.ts:80:2", + "--description", + "Adds retry handling for transient widget failures.", + "--test-file", + "test/unit/widget.test.ts", + "--json", + ], + e, + ), + ) as { slopRisk: number; band: string; findings: unknown[]; rubric: string }; + expect(json).toMatchObject({ slopRisk: 0, band: "clean", findings: [], rubric: expect.any(String) }); + expect(JSON.stringify(json)).not.toMatch(/wallet|hotkey|reward|trust score/i); + }); + + it("reads descriptions from --description-file and supports repeated changed files", async () => { + const e = await env(); + const descriptionPath = join(tempDir!, "description.md"); + writeFileSync(descriptionPath, "Fixes #7 with focused retry handling.", "utf8"); + const json = JSON.parse( + await runAsync( + [ + "slop-risk", + "--changed-file", + "src/widget.ts:12:1", + "--changed-file", + "test/unit/widget.test.ts:40:0", + "--description-file", + descriptionPath, + "--json", + ], + e, + ), + ) as { band: string }; + expect(json.band).toBe("clean"); + }); + + it("surfaces elevated slop findings in plain output", async () => { + const e = await env(); + const json = JSON.parse(await runAsync(["slop-risk", "--changed-file", "src/widget.ts:80:2", "--json"], e)) as { + slopRisk: number; + band: string; + findings: Array<{ title: string }>; + }; + expect(json).toMatchObject({ slopRisk: 45, band: "elevated" }); + const plain = await runAsync(["slop-risk", "--changed-file", "src/widget.ts:80:2"], e); + expect(plain).toMatch(/Slop risk: 45 \(elevated\)/); + expect(plain).toMatch(/Empty PR description/); + }); + + it("validates inputs and prints help", async () => { + const e = await env(); + await expect(runAsync(["slop-risk", "--changed-file", ":1:2"], e)).rejects.toThrow(/Invalid --changed-file/); + await expect(runAsync(["slop-risk", "--changed-file", "src/a.ts:-1"], e)).rejects.toThrow(/Invalid additions/); + await expect(runAsync(["slop-risk", "--description-file", "/tmp/missing-gittensory-slop-description.md"], e)).rejects.toThrow(/Description file not found/); + const help = run(["slop-risk", "--help"]); + expect(help).toMatch(/Usage: gittensory-mcp slop-risk/); + expect(help).toMatch(/gittensory_check_slop_risk/); + expect(help).toMatch(/--changed-file/); + }); + + it("suggests slop-risk for close typos", () => { + expect(() => run(["slop-rsk"])).toThrow(/Did you mean `slop-risk`\?/); + }); +}); diff --git a/test/unit/support/mcp-cli-harness.ts b/test/unit/support/mcp-cli-harness.ts index 1d686e9b98..bd1225358d 100644 --- a/test/unit/support/mcp-cli-harness.ts +++ b/test/unit/support/mcp-cli-harness.ts @@ -234,6 +234,16 @@ export async function startFixtureServer( response.end(JSON.stringify(lintPrTextFixture(body))); return; } + if (request.url === "/v1/lint/slop-risk" && request.method === "POST") { + const body = (await readJsonRequest(request)) as { + changedFiles?: Array<{ path: string; additions?: number; deletions?: number }>; + description?: string; + tests?: string[]; + testFiles?: string[]; + }; + response.end(JSON.stringify(slopRiskFixture(body))); + return; + } // #784 maintainer controls (agent approval queue + kill-switch). if (request.url === "/v1/repos/owner/repo/agent/pending-actions" && request.method === "GET") { response.end(JSON.stringify({ repoFullName: "owner/repo", pendingActions: [{ id: "pa-1", actionClass: "merge", pullNumber: 7, reason: "clean", status: "pending" }] })); @@ -438,3 +448,29 @@ export function lintPrTextFixture(input: { commitMessages?: string[]; prBody?: s ], }; } + +export function slopRiskFixture(input: { + changedFiles?: Array<{ path: string; additions?: number; deletions?: number }>; + description?: string; + tests?: string[]; + testFiles?: string[]; +} = {}) { + const changedFiles = input.changedFiles ?? []; + const hasCodeChange = changedFiles.some((file) => !file.path.includes(".test.")); + const hasTestEvidence = changedFiles.some((file) => file.path.includes(".test.")) || (input.testFiles?.length ?? 0) > 0 || (input.tests?.length ?? 0) > 0; + const emptyDescription = !input.description?.trim(); + const elevated = hasCodeChange && (!hasTestEvidence || emptyDescription); + const slopRisk = elevated ? 45 : 0; + const findings = + elevated && emptyDescription + ? [{ code: "empty_description", title: "Empty PR description", severity: "warning", detail: "Add a specific summary of what changed and why." }] + : elevated + ? [{ code: "missing_test_evidence", title: "Missing test evidence", severity: "warning", detail: "Add or update tests for the changed behavior." }] + : []; + return { + slopRisk, + band: slopRisk <= 0 ? "clean" : slopRisk < 25 ? "low" : slopRisk < 60 ? "elevated" : "high", + findings, + rubric: "Fixture slop rubric.", + }; +}