-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathverify-completion.ts
More file actions
182 lines (167 loc) · 7.85 KB
/
verify-completion.ts
File metadata and controls
182 lines (167 loc) · 7.85 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
import { z } from "zod";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { run, getStatus } from "../lib/git.js";
import { PROJECT_DIR } from "../lib/files.js";
import { existsSync, readFileSync } from "fs";
import { execFileSync } from "child_process";
import { join } from "path";
/** Detect package manager from lockfiles */
function detectPM(): string {
if (existsSync(join(PROJECT_DIR, "pnpm-lock.yaml"))) return "pnpm";
if (existsSync(join(PROJECT_DIR, "yarn.lock"))) return "yarn";
if (existsSync(join(PROJECT_DIR, "bun.lockb"))) return "bun";
return "npx";
}
/** Detect test runner from config/dependencies */
function detectTestRunner(): string | null {
// Check for common test configs
const configs = [
"playwright.config.ts", "playwright.config.js",
"vitest.config.ts", "vitest.config.js",
"jest.config.ts", "jest.config.js", "jest.config.mjs",
];
for (const c of configs) {
if (existsSync(join(PROJECT_DIR, c))) {
if (c.startsWith("playwright")) return "playwright";
if (c.startsWith("vitest")) return "vitest";
if (c.startsWith("jest")) return "jest";
}
}
return null;
}
/** Check if a build script exists in package.json */
function hasBuildScript(): boolean {
try {
const pkg = JSON.parse(readFileSync(join(PROJECT_DIR, "package.json"), "utf-8"));
return !!pkg?.scripts?.build;
} catch { return false; }
}
/** Run an arbitrary command (non-git) safely, returning stdout or empty string on error */
function execCmd(cmd: string, args: string[], opts: { timeout?: number } = {}): string {
try {
return execFileSync(cmd, args, {
cwd: PROJECT_DIR,
encoding: "utf-8",
timeout: opts.timeout || 30000,
maxBuffer: 1024 * 1024,
stdio: ["pipe", "pipe", "pipe"],
}).trim();
} catch { return ""; }
}
export function registerVerifyCompletion(server: McpServer): void {
server.tool(
"verify_completion",
`Verify that work is actually complete before declaring done. Runs type check, relevant tests, checks for uncommitted files, and validates against the original task criteria. Call this BEFORE saying "done" or committing final work.`,
{
task_description: z.string().describe("What was the task? Used to check if success criteria are met."),
test_scope: z.string().optional().describe("Which tests to run: 'all', a directory/keyword, or a specific spec file path. Default: auto-detect from changed files."),
skip_tests: z.boolean().optional().describe("Skip running tests (only check types + git state). Default: false."),
skip_build: z.boolean().optional().describe("Skip build check. Default: false."),
},
async ({ task_description, test_scope, skip_tests, skip_build }) => {
const pm = detectPM();
const sections: string[] = [];
const checks: { name: string; passed: boolean; detail: string }[] = [];
// 1. Type check (single invocation, extract both result and count)
const tscBin = pm === "npx" ? "npx" : pm;
const tscArgs = pm === "npx" ? ["tsc", "--noEmit"] : ["exec", "tsc", "--noEmit"];
const tscOutput = execCmd(tscBin, tscArgs, { timeout: 60000 });
const errorLines = tscOutput.split("\n").filter(l => /error TS\d+/.test(l));
const typePassed = errorLines.length === 0;
checks.push({
name: "Type Check",
passed: typePassed,
detail: typePassed
? "✅ Clean"
: `❌ ${errorLines.length} errors\n${errorLines.slice(0, 10).join("\n")}${errorLines.length > 10 ? `\n... and ${errorLines.length - 10} more` : ""}`,
});
// 2. Git state
const dirty = getStatus();
const dirtyCount = dirty ? dirty.split("\n").filter(Boolean).length : 0;
checks.push({
name: "Git State",
passed: true, // informational, not a blocker
detail: dirtyCount > 0
? `${dirtyCount} uncommitted files:\n\`\`\`\n${dirty}\n\`\`\``
: "✅ Clean working tree",
});
// 3. Tests
if (!skip_tests) {
const runner = detectTestRunner();
const changedFiles = run(["diff", "--name-only", "HEAD~1"]).split("\n").filter(Boolean);
// Build test command args (run via execCmd, not git run)
let testBin = "";
let testArgs: string[] = [];
if (runner === "playwright") {
testBin = pm === "npx" ? "npx" : pm;
const baseArgs = pm === "npx" ? ["playwright", "test"] : ["exec", "playwright", "test"];
if (test_scope && test_scope !== "all") {
testArgs = test_scope.endsWith(".spec.ts") || test_scope.endsWith(".test.ts")
? [...baseArgs, test_scope, "--reporter=line"]
: [...baseArgs, "--grep", test_scope, "--reporter=line"];
} else {
const changedTests = changedFiles.filter(f => /\.(spec|test)\.(ts|tsx|js)$/.test(f)).slice(0, 5);
if (changedTests.length > 0) {
testArgs = [...baseArgs, ...changedTests, "--reporter=line"];
}
}
} else if (runner === "vitest" || runner === "jest") {
testBin = pm === "npx" ? "npx" : pm;
const baseArgs = pm === "npx" ? [runner, "--run"] : ["exec", runner, "--run"];
if (test_scope && test_scope !== "all") {
testArgs = [...baseArgs, test_scope];
} else {
const changedTests = changedFiles.filter(f => /\.(spec|test)\.(ts|tsx|js)$/.test(f)).slice(0, 5);
if (changedTests.length > 0) {
testArgs = [...baseArgs, ...changedTests];
}
}
} else if (test_scope) {
testBin = pm;
testArgs = ["test"];
}
if (testBin && testArgs.length > 0) {
const fullOutput = execCmd(testBin, testArgs, { timeout: 120000 });
const testResult = fullOutput.split("\n").slice(-20).join("\n");
const testPassed = /pass/i.test(testResult) && !/fail/i.test(testResult);
checks.push({
name: "Tests",
passed: testPassed,
detail: testPassed ? `✅ Tests passed\n${testResult}` : `❌ Tests failed\n${testResult}`,
});
} else {
checks.push({
name: "Tests",
passed: true,
detail: `⚠️ No relevant tests identified${runner ? ` (runner: ${runner})` : ""}. Consider running full suite.`,
});
}
}
// 4. Build check (only if build script exists and not skipped)
if (!skip_build && hasBuildScript()) {
const buildBin = pm === "npx" ? "npm" : pm;
const buildArgs = pm === "npx" ? ["run", "build"] : ["build"];
const fullBuild = execCmd(buildBin, buildArgs, { timeout: 60000 });
const buildCheck = fullBuild.split("\n").slice(-10).join("\n");
const buildPassed = !/\b[Ee]rror\b/.test(buildCheck) || /Successfully compiled/.test(buildCheck);
checks.push({
name: "Build",
passed: buildPassed,
detail: buildPassed ? "✅ Build succeeds" : `❌ Build failed\n${buildCheck}`,
});
} else if (!skip_build) {
checks.push({ name: "Build", passed: true, detail: "⚠️ No build script found — skipped" });
}
const allPassed = checks.every(c => c.passed);
sections.push(`## Verification Report\n**Task**: ${task_description}\n\n${checks.map(c => `### ${c.name}\n${c.detail}`).join("\n\n")}`);
sections.push(`## Verdict\n${allPassed
? "✅ **ALL CHECKS PASSED.** Safe to commit and declare done."
: "❌ **CHECKS FAILED.** Fix the issues above before committing."
}`);
if (!allPassed) {
sections.push(`## Do NOT:\n- Commit with failing checks\n- Say "done" without green tests\n- Push broken code to remote\n\n## DO:\n- Fix each failing check\n- Re-run \`verify_completion\` after fixes\n- Then commit`);
}
return { content: [{ type: "text" as const, text: sections.join("\n\n") }] };
}
);
}