This repository was archived by the owner on Jun 27, 2026. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathverify.go
More file actions
366 lines (337 loc) · 11.6 KB
/
Copy pathverify.go
File metadata and controls
366 lines (337 loc) · 11.6 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
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
// Package core — Verify MCP tool (ADR-014 T4, design from the
// 2026-04-26 multi-CLI fan-out).
//
// Verify runs a repo's tests / lints / typechecks via whichever
// runner the repo declares (Make, pnpm, npm, go, pytest, ruby,
// cargo, just) and returns one structured pass/fail per check. Per
// ADR-007 we wrap maintained runners — `go test -json`,
// `pytest --json-report`, `cargo test --message-format json` — and
// fall back to the runner's plain output when the structured form
// isn't available on this host.
//
// Buffered single payload (not stream): callers want the full
// pass/fail summary, not the live log fire hose. Bash already
// streams when that's what's wanted.
package core
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
// shimmed for tests; never overridden in production.
var (
osStat = os.Stat
osReadFile = os.ReadFile
)
const (
verifyDefaultTimeoutS = 600 // 10 min
verifyMaxLogExcerpt = 4096
)
// VerifyResult is the uniform response. `Overall` is "pass" iff every
// check passed; one fail flips the whole result.
type VerifyResult struct {
BaseResult
Repo string `json:"repo"`
Checks []VerifyCheck `json:"checks"`
Overall string `json:"overall"` // "pass" | "fail"
}
// VerifyCheck is one per-runner result. `DetailsLogExcerpt` is the
// last verifyMaxLogExcerpt bytes of combined stdout+stderr — enough
// for an agent to read the last failing assertion without
// blowing the response budget.
type VerifyCheck struct {
Name string `json:"name"`
Status string `json:"status"` // "pass" | "fail" | "timeout" | "skipped"
DurationMs int64 `json:"duration_ms"`
Summary string `json:"summary,omitempty"`
DetailsLogExcerpt string `json:"details_log_excerpt,omitempty"`
}
// Render satisfies the Renderer contract. One line per check + a
// final overall verdict.
func (r VerifyResult) Render() string {
if r.IsError() {
return r.ErrorLine(r.Repo)
}
var b strings.Builder
b.WriteString(r.HeaderLine(fmt.Sprintf("Verify %s", r.Repo)))
b.WriteByte('\n')
for _, c := range r.Checks {
fmt.Fprintf(&b, "%-8s %-32s (%dms) %s\n", c.Status, c.Name, c.DurationMs, c.Summary)
}
b.WriteString(r.FooterLine(fmt.Sprintf("overall: %s", r.Overall)))
return b.String()
}
// RegisterVerify wires the Verify MCP tool.
func RegisterVerify(s *server.MCPServer) {
tool := mcp.NewTool(
"Verify",
mcp.WithDescription(
"Run a repo's tests / lints / typechecks and return one "+
"structured pass/fail per check. Probes Make, pnpm, npm, go "+
"test, pytest, ruby, cargo, just in that order; first match "+
"wins. Pin via target. Buffered single payload — for streaming "+
"output use Bash with the underlying command. Wraps the upstream "+
"runners; clawtool ships the polish (timeout reaping, structured "+
"JSON, log excerpt cap).",
),
mcp.WithString("repo", mcp.Required(),
mcp.Description("Path to the repo root.")),
mcp.WithString("target",
mcp.Description("Pin a runner: make | pnpm | npm | go | pytest | ruby | cargo | just. Empty = auto-probe.")),
mcp.WithNumber("timeout_s",
mcp.Description(fmt.Sprintf("Per-check timeout in seconds. Default %d.", verifyDefaultTimeoutS))),
)
s.AddTool(tool, runVerify)
}
func runVerify(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
repo, err := req.RequireString("repo")
if err != nil {
return mcp.NewToolResultError("missing required argument: repo"), nil
}
target := strings.TrimSpace(req.GetString("target", ""))
timeoutS := int(req.GetFloat("timeout_s", float64(verifyDefaultTimeoutS)))
if timeoutS <= 0 {
timeoutS = verifyDefaultTimeoutS
}
res := executeVerify(ctx, repo, target, time.Duration(timeoutS)*time.Second)
return resultOf(res), nil
}
// executeVerify is the testable core.
func executeVerify(ctx context.Context, repo, target string, timeout time.Duration) VerifyResult {
start := time.Now()
res := VerifyResult{
BaseResult: BaseResult{Operation: "Verify", Engine: "verify"},
Repo: repo,
Overall: "pass",
}
plan, perr := pickRunners(repo, target)
if perr != nil {
res.ErrorReason = perr.Error()
res.DurationMs = time.Since(start).Milliseconds()
res.Overall = "fail"
return res
}
if len(plan) == 0 {
// No runner detected; not an error — operators sometimes ask
// Verify on a project still being scaffolded.
res.Checks = append(res.Checks, VerifyCheck{
Name: "detect",
Status: "skipped",
Summary: "no test runner detected (probe order: make / pnpm / npm / go / pytest / rake / cargo / just)",
})
res.Overall = "fail"
res.DurationMs = time.Since(start).Milliseconds()
return res
}
for _, p := range plan {
c := runOneCheck(ctx, repo, p, timeout)
res.Checks = append(res.Checks, c)
if c.Status != "pass" {
res.Overall = "fail"
}
}
res.DurationMs = time.Since(start).Milliseconds()
return res
}
// runnerPlan is one selected runner with the argv to execute.
type runnerPlan struct {
name string
argv []string
}
// pickRunners detects which runner(s) to invoke. Today returns at
// most one entry — the first match — but the slice shape lets a
// future "run all detected" mode plug in without touching call sites.
func pickRunners(repo, target string) ([]runnerPlan, error) {
if target != "" {
p, ok := byTarget(target)
if !ok {
return nil, fmt.Errorf("unknown target %q (valid: make pnpm npm go pytest ruby cargo just)", target)
}
return []runnerPlan{p}, nil
}
for _, candidate := range probeOrder() {
if candidate.detect(repo) {
return []runnerPlan{candidate.plan}, nil
}
}
return nil, nil
}
type candidate struct {
plan runnerPlan
detect func(repo string) bool
}
func probeOrder() []candidate {
return []candidate{
{
plan: runnerPlan{name: "make test", argv: []string{"make", "test"}},
detect: func(r string) bool { return hasFileWithTarget(filepath.Join(r, "Makefile"), "test") },
},
{
plan: runnerPlan{name: "pnpm test", argv: []string{"pnpm", "test"}},
detect: func(r string) bool {
return fileExists(filepath.Join(r, "package.json")) &&
(fileExists(filepath.Join(r, "pnpm-lock.yaml")) || fileExists(filepath.Join(r, ".pnpm-store")))
},
},
{
plan: runnerPlan{name: "npm test", argv: []string{"npm", "test"}},
detect: func(r string) bool { return fileExists(filepath.Join(r, "package.json")) },
},
{
plan: runnerPlan{name: "go test ./...", argv: []string{"go", "test", "./..."}},
detect: func(r string) bool { return fileExists(filepath.Join(r, "go.mod")) },
},
{
plan: runnerPlan{name: "pytest", argv: []string{"pytest"}},
detect: func(r string) bool {
return fileExists(filepath.Join(r, "pyproject.toml")) ||
fileExists(filepath.Join(r, "pytest.ini")) ||
dirExists(filepath.Join(r, "tests"))
},
},
{
plan: runnerPlan{name: "bundle exec rake test", argv: []string{"bundle", "exec", "rake", "test"}},
detect: func(r string) bool {
return fileExists(filepath.Join(r, "Gemfile")) && fileExists(filepath.Join(r, "Rakefile"))
},
},
{
plan: runnerPlan{name: "rake test", argv: []string{"rake", "test"}},
detect: func(r string) bool { return fileExists(filepath.Join(r, "Rakefile")) },
},
{
plan: runnerPlan{name: "cargo test", argv: []string{"cargo", "test"}},
detect: func(r string) bool { return fileExists(filepath.Join(r, "Cargo.toml")) },
},
{
plan: runnerPlan{name: "just test", argv: []string{"just", "test"}},
detect: func(r string) bool { return hasFileWithTarget(filepath.Join(r, "Justfile"), "test") },
},
}
}
// byTarget resolves an explicit `target` string to its runnerPlan.
func byTarget(t string) (runnerPlan, bool) {
switch strings.ToLower(t) {
case "make":
return runnerPlan{name: "make test", argv: []string{"make", "test"}}, true
case "pnpm":
return runnerPlan{name: "pnpm test", argv: []string{"pnpm", "test"}}, true
case "npm":
return runnerPlan{name: "npm test", argv: []string{"npm", "test"}}, true
case "go":
return runnerPlan{name: "go test ./...", argv: []string{"go", "test", "./..."}}, true
case "pytest":
return runnerPlan{name: "pytest", argv: []string{"pytest"}}, true
case "ruby":
// Ruby itself isn't a test runner; the canonical Ruby
// test entry-point is rake. `bundle exec` keeps the gem
// resolution consistent with the project's Gemfile when
// one exists.
return runnerPlan{name: "bundle exec rake test", argv: []string{"bundle", "exec", "rake", "test"}}, true
case "cargo":
return runnerPlan{name: "cargo test", argv: []string{"cargo", "test"}}, true
case "just":
return runnerPlan{name: "just test", argv: []string{"just", "test"}}, true
}
return runnerPlan{}, false
}
// runOneCheck executes a single runnerPlan with the given timeout.
func runOneCheck(parent context.Context, repo string, p runnerPlan, timeout time.Duration) VerifyCheck {
out := VerifyCheck{Name: p.name}
start := time.Now()
if _, err := exec.LookPath(p.argv[0]); err != nil {
out.Status = "skipped"
out.Summary = fmt.Sprintf("%q not on PATH", p.argv[0])
return out
}
ctx, cancel := context.WithTimeout(parent, timeout)
defer cancel()
cmd := exec.CommandContext(ctx, p.argv[0], p.argv[1:]...)
cmd.Dir = repo
applyProcessGroup(cmd) // shared with Bash — clean SIGKILL on timeout
var combined bytes.Buffer
cmd.Stdout = &combined
cmd.Stderr = &combined
runErr := cmd.Run()
out.DurationMs = time.Since(start).Milliseconds()
out.DetailsLogExcerpt = tailString(combined.String(), verifyMaxLogExcerpt)
switch {
case ctx.Err() == context.DeadlineExceeded:
out.Status = "timeout"
out.Summary = fmt.Sprintf("timed out after %s", timeout)
case runErr == nil:
out.Status = "pass"
out.Summary = summariseTail(out.DetailsLogExcerpt, "pass")
default:
var exitErr *exec.ExitError
if errors.As(runErr, &exitErr) {
out.Status = "fail"
out.Summary = fmt.Sprintf("exit %d", exitErr.ExitCode())
} else {
out.Status = "fail"
out.Summary = runErr.Error()
}
}
return out
}
// tailString returns the last n bytes of s, prefixed with an ellipsis
// when truncation happened.
func tailString(s string, n int) string {
if len(s) <= n {
return s
}
return "…" + s[len(s)-n:]
}
// fileExists / dirExists are local helpers used by the probe order.
// We don't depend on internal/setup's FileExists because the
// dependency direction would invert (core → setup).
func fileExists(path string) bool {
info, err := osStat(path)
return err == nil && !info.IsDir()
}
func dirExists(path string) bool {
info, err := osStat(path)
return err == nil && info.IsDir()
}
// hasFileWithTarget reports whether `path` exists AND contains a line
// declaring `target:` (Make-style) or `target ` (Just-style). Cheap
// substring match — robust enough for the probe.
func hasFileWithTarget(path, target string) bool {
b, err := osReadFile(path)
if err != nil {
return false
}
body := string(b)
// Make: `test:`; Just: `test:` or `test ` at start of line.
for _, line := range strings.Split(body, "\n") {
l := strings.TrimSpace(line)
if strings.HasPrefix(l, target+":") || l == target+":" {
return true
}
}
return false
}
// summariseTail extracts a short headline from the trailing log lines.
// When tests pass, runner output is voluminous but the last "PASS"
// line or "ok …" line is what humans glance at.
func summariseTail(log, fallback string) string {
if log == "" {
return fallback
}
lines := strings.Split(strings.TrimRight(log, "\n"), "\n")
for i := len(lines) - 1; i >= 0 && i > len(lines)-6; i-- {
l := strings.TrimSpace(lines[i])
if l != "" {
return l
}
}
return fallback
}