@vitest-agent/mcp 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +44 -0
- package/bin/vitest-agent-mcp.js +93 -0
- package/context.js +72 -0
- package/index.d.ts +1577 -0
- package/index.js +19 -0
- package/layers/McpLive.js +30 -0
- package/middleware/idempotency.js +128 -0
- package/package.json +58 -0
- package/prompts/explain-failure.js +27 -0
- package/prompts/index.js +89 -0
- package/prompts/regression-since-pass.js +28 -0
- package/prompts/tdd-resume.js +28 -0
- package/prompts/triage.js +24 -0
- package/prompts/why-flaky.js +30 -0
- package/prompts/wrapup.js +19 -0
- package/resources/index.js +155 -0
- package/resources/indexes.js +77 -0
- package/resources/manifest-schema.js +46 -0
- package/resources/paths.js +20 -0
- package/resources/patterns.js +22 -0
- package/resources/upstream-docs.js +22 -0
- package/router.js +74 -0
- package/server.js +838 -0
- package/tools/_tdd-error-envelope.js +98 -0
- package/tools/acceptance-metrics.js +75 -0
- package/tools/cache-health.js +83 -0
- package/tools/commit-changes.js +64 -0
- package/tools/configure.js +107 -0
- package/tools/coverage.js +76 -0
- package/tools/errors.js +151 -0
- package/tools/failure-signature-get.js +73 -0
- package/tools/file-coverage.js +106 -0
- package/tools/help.js +146 -0
- package/tools/history.js +121 -0
- package/tools/hypothesis.js +127 -0
- package/tools/inventory.js +377 -0
- package/tools/note.js +208 -0
- package/tools/overview.js +92 -0
- package/tools/ping.js +22 -0
- package/tools/register-agent.js +135 -0
- package/tools/run-tests.js +359 -0
- package/tools/settings-list.js +48 -0
- package/tools/status.js +74 -0
- package/tools/tdd-artifact.js +101 -0
- package/tools/tdd-behavior.js +177 -0
- package/tools/tdd-goal.js +147 -0
- package/tools/tdd-phase-transition-request.js +212 -0
- package/tools/tdd-task.js +278 -0
- package/tools/test.js +281 -0
- package/tools/trends.js +112 -0
- package/tools/triage-brief.js +42 -0
- package/tools/turn-search.js +60 -0
- package/tools/wrapup-prompt.js +49 -0
- package/tsdoc-metadata.json +11 -0
- package/utils/effect-to-zod.js +81 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { idempotentProcedure } from "../middleware/idempotency.js";
|
|
2
|
+
import { DataReader, DataStore, GoalDetail } from "@vitest-agent/sdk";
|
|
3
|
+
import { Effect, Match, Option, ParseResult, Schema } from "effect";
|
|
4
|
+
|
|
5
|
+
//#region src/tools/tdd-task.ts
|
|
6
|
+
/**
|
|
7
|
+
* Consolidated `tdd_task` MCP tool — Schema-driven implementation.
|
|
8
|
+
*
|
|
9
|
+
* `start` and `end` mutate; `get` and `resume` read. Every action
|
|
10
|
+
* now returns a structured payload — `get` carries the full nested
|
|
11
|
+
* `TddTaskDetail` tree plus the `currentPhase` lookup, and `resume`
|
|
12
|
+
* carries a compact summary discriminated by `phaseAvailable`. The
|
|
13
|
+
* boundary in server.ts uses `formatTddTaskMarkdown` to render
|
|
14
|
+
* `get` / `resume` text.
|
|
15
|
+
*
|
|
16
|
+
* @packageDocumentation
|
|
17
|
+
*/
|
|
18
|
+
const TddPhaseRow = Schema.Struct({
|
|
19
|
+
id: Schema.Number,
|
|
20
|
+
behaviorId: Schema.NullOr(Schema.Number),
|
|
21
|
+
phase: Schema.String,
|
|
22
|
+
startedAt: Schema.String,
|
|
23
|
+
endedAt: Schema.NullOr(Schema.String),
|
|
24
|
+
transitionReason: Schema.NullOr(Schema.String)
|
|
25
|
+
}).annotations({ identifier: "TddTaskPhaseRow" });
|
|
26
|
+
const TddArtifactDetailRow = Schema.Struct({
|
|
27
|
+
id: Schema.Number,
|
|
28
|
+
phaseId: Schema.Number,
|
|
29
|
+
artifactKind: Schema.String,
|
|
30
|
+
testCaseId: Schema.NullOr(Schema.Number),
|
|
31
|
+
testRunId: Schema.NullOr(Schema.Number),
|
|
32
|
+
recordedAt: Schema.String
|
|
33
|
+
}).annotations({ identifier: "TddTaskArtifactRow" });
|
|
34
|
+
const TddTaskDetailSchema = Schema.Struct({
|
|
35
|
+
tddTaskId: Schema.Number,
|
|
36
|
+
sessionId: Schema.Number,
|
|
37
|
+
goal: Schema.String,
|
|
38
|
+
startedAt: Schema.String,
|
|
39
|
+
endedAt: Schema.NullOr(Schema.String),
|
|
40
|
+
outcome: Schema.NullOr(Schema.String),
|
|
41
|
+
runId: Schema.NullOr(Schema.String),
|
|
42
|
+
goals: Schema.Array(GoalDetail),
|
|
43
|
+
phases: Schema.Array(TddPhaseRow),
|
|
44
|
+
artifacts: Schema.Array(TddArtifactDetailRow)
|
|
45
|
+
}).annotations({ identifier: "TddTaskDetailSchema" });
|
|
46
|
+
const CurrentPhaseLookup = Schema.Struct({
|
|
47
|
+
id: Schema.Number,
|
|
48
|
+
phase: Schema.String,
|
|
49
|
+
startedAt: Schema.String,
|
|
50
|
+
behaviorId: Schema.NullOr(Schema.Number)
|
|
51
|
+
}).annotations({ identifier: "TddTaskCurrentPhaseLookup" });
|
|
52
|
+
const TddTaskStartOk = Schema.Struct({
|
|
53
|
+
action: Schema.Literal("start"),
|
|
54
|
+
tddTaskId: Schema.Number,
|
|
55
|
+
goal: Schema.String,
|
|
56
|
+
runId: Schema.optional(Schema.String)
|
|
57
|
+
}).annotations({ identifier: "TddTaskStartOk" });
|
|
58
|
+
const TddTaskEndOk = Schema.Struct({
|
|
59
|
+
action: Schema.Literal("end"),
|
|
60
|
+
tddTaskId: Schema.Number,
|
|
61
|
+
outcome: Schema.Literal("succeeded", "blocked", "abandoned")
|
|
62
|
+
}).annotations({ identifier: "TddTaskEndOk" });
|
|
63
|
+
const TddTaskGetFound = Schema.Struct({
|
|
64
|
+
action: Schema.Literal("get"),
|
|
65
|
+
found: Schema.Literal(true),
|
|
66
|
+
task: TddTaskDetailSchema,
|
|
67
|
+
currentPhase: Schema.NullOr(CurrentPhaseLookup)
|
|
68
|
+
}).annotations({ identifier: "TddTaskGetFound" });
|
|
69
|
+
const TddTaskGetMissing = Schema.Struct({
|
|
70
|
+
action: Schema.Literal("get"),
|
|
71
|
+
found: Schema.Literal(false),
|
|
72
|
+
tddTaskId: Schema.Number
|
|
73
|
+
}).annotations({ identifier: "TddTaskGetMissing" });
|
|
74
|
+
const TddTaskResumeFound = Schema.Struct({
|
|
75
|
+
action: Schema.Literal("resume"),
|
|
76
|
+
found: Schema.Literal(true),
|
|
77
|
+
tddTaskId: Schema.Number,
|
|
78
|
+
goal: Schema.String,
|
|
79
|
+
status: Schema.String,
|
|
80
|
+
currentPhase: Schema.NullOr(CurrentPhaseLookup),
|
|
81
|
+
phasesRecorded: Schema.Number,
|
|
82
|
+
artifactsRecorded: Schema.Number
|
|
83
|
+
}).annotations({ identifier: "TddTaskResumeFound" });
|
|
84
|
+
const TddTaskResumeMissing = Schema.Struct({
|
|
85
|
+
action: Schema.Literal("resume"),
|
|
86
|
+
found: Schema.Literal(false),
|
|
87
|
+
tddTaskId: Schema.Number
|
|
88
|
+
}).annotations({ identifier: "TddTaskResumeMissing" });
|
|
89
|
+
const TddTaskResult = Schema.Union(TddTaskStartOk, TddTaskEndOk, TddTaskGetFound, TddTaskGetMissing, TddTaskResumeFound, TddTaskResumeMissing).annotations({
|
|
90
|
+
identifier: "TddTaskResult",
|
|
91
|
+
title: "tdd_task result",
|
|
92
|
+
description: "Discriminate on `action`. `get` and `resume` further discriminate on `found`. `get` carries the full nested task tree."
|
|
93
|
+
});
|
|
94
|
+
const formatTddTaskMarkdown = (data) => {
|
|
95
|
+
if (data.action === "start" || data.action === "end") return JSON.stringify(data, null, 2);
|
|
96
|
+
if (data.action === "get") {
|
|
97
|
+
if (!data.found) return `No TDD task with tddTaskId=${data.tddTaskId}.`;
|
|
98
|
+
const s = data.task;
|
|
99
|
+
const currentPhaseLine = data.currentPhase === null ? "- current phase: (none — no open phase)" : `- current phase: ${data.currentPhase.phase} [phaseId=${data.currentPhase.id}]${data.currentPhase.behaviorId !== null ? ` behaviorId=${data.currentPhase.behaviorId}` : ""}`;
|
|
100
|
+
const lines = [
|
|
101
|
+
`# TDD Task ${s.tddTaskId}`,
|
|
102
|
+
"",
|
|
103
|
+
`- goal: ${s.goal}`,
|
|
104
|
+
`- run_id: ${s.runId ?? "(none — run_id not recorded)"}`,
|
|
105
|
+
`- sessionId: ${s.sessionId}`,
|
|
106
|
+
`- started: ${s.startedAt}`,
|
|
107
|
+
`- ended: ${s.endedAt ?? "still open"}`,
|
|
108
|
+
`- outcome: ${s.outcome ?? "pending"}`,
|
|
109
|
+
currentPhaseLine
|
|
110
|
+
];
|
|
111
|
+
if (s.phases.length > 0) {
|
|
112
|
+
lines.push("", "## Phases", "");
|
|
113
|
+
for (const p of s.phases) {
|
|
114
|
+
const duration = p.endedAt ? ` -> ${p.endedAt}` : " (current)";
|
|
115
|
+
lines.push(`- **${p.phase}** [id=${p.id}] ${p.startedAt}${duration}`);
|
|
116
|
+
if (p.transitionReason !== null) lines.push(` - reason: ${p.transitionReason}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (s.artifacts.length > 0) {
|
|
120
|
+
lines.push("", "## Artifacts", "");
|
|
121
|
+
for (const a of s.artifacts) lines.push(`- **${a.artifactKind}** [id=${a.id}, phase=${a.phaseId}] at=${a.recordedAt}${a.testRunId !== null ? ` run=${a.testRunId}` : ""}`);
|
|
122
|
+
}
|
|
123
|
+
if (s.goals.length > 0) {
|
|
124
|
+
lines.push("", "## Goals and Behaviors", "");
|
|
125
|
+
for (const g of s.goals) {
|
|
126
|
+
lines.push(`### Goal ${g.ordinal + 1}: ${g.goal} [${g.status}]`);
|
|
127
|
+
if (g.behaviors.length > 0) {
|
|
128
|
+
lines.push("");
|
|
129
|
+
for (const b of g.behaviors) lines.push(`- **${b.behavior}** [${b.status}]`);
|
|
130
|
+
}
|
|
131
|
+
lines.push("");
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return lines.join("\n");
|
|
135
|
+
}
|
|
136
|
+
if (!data.found) return `No TDD task with tddTaskId=${data.tddTaskId}.`;
|
|
137
|
+
const lines = [
|
|
138
|
+
`# TDD task #${data.tddTaskId}: ${data.goal}`,
|
|
139
|
+
"",
|
|
140
|
+
`**Status:** ${data.status}`
|
|
141
|
+
];
|
|
142
|
+
if (data.currentPhase !== null) lines.push(`**Current phase:** ${data.currentPhase.phase} (started ${data.currentPhase.startedAt})`);
|
|
143
|
+
else lines.push("**Current phase:** none (TDD cycle not yet entered)");
|
|
144
|
+
lines.push("", `**Phases recorded:** ${data.phasesRecorded}`);
|
|
145
|
+
if (data.artifactsRecorded > 0) lines.push(`**Artifacts:** ${data.artifactsRecorded}`);
|
|
146
|
+
lines.push("", `Use \`tdd_task({ action: "get", tddTaskId: ${data.tddTaskId} })\` for the full detail tree, or call \`tdd_phase_transition_request\` to advance.`);
|
|
147
|
+
return lines.join("\n");
|
|
148
|
+
};
|
|
149
|
+
const TddTaskAsMarkdown = Schema.transformOrFail(TddTaskResult, Schema.String, {
|
|
150
|
+
strict: true,
|
|
151
|
+
decode: (data) => ParseResult.succeed(formatTddTaskMarkdown(data)),
|
|
152
|
+
encode: (text, _options, ast) => ParseResult.fail(new ParseResult.Forbidden(ast, text, "TddTaskAsMarkdown is one-way."))
|
|
153
|
+
});
|
|
154
|
+
const StartVariant = Schema.Struct({
|
|
155
|
+
action: Schema.Literal("start"),
|
|
156
|
+
goal: Schema.String,
|
|
157
|
+
sessionId: Schema.optional(Schema.Number),
|
|
158
|
+
chatId: Schema.optional(Schema.String),
|
|
159
|
+
parentTddTaskId: Schema.optional(Schema.Number),
|
|
160
|
+
startedAt: Schema.optional(Schema.String),
|
|
161
|
+
runId: Schema.optional(Schema.String)
|
|
162
|
+
});
|
|
163
|
+
const EndVariant = Schema.Struct({
|
|
164
|
+
action: Schema.Literal("end"),
|
|
165
|
+
tddTaskId: Schema.Number,
|
|
166
|
+
outcome: Schema.Literal("succeeded", "blocked", "abandoned"),
|
|
167
|
+
summaryNoteId: Schema.optional(Schema.Number)
|
|
168
|
+
});
|
|
169
|
+
const GetVariant = Schema.Struct({
|
|
170
|
+
action: Schema.Literal("get"),
|
|
171
|
+
tddTaskId: Schema.Number
|
|
172
|
+
});
|
|
173
|
+
const ResumeVariant = Schema.Struct({
|
|
174
|
+
action: Schema.Literal("resume"),
|
|
175
|
+
tddTaskId: Schema.Number
|
|
176
|
+
});
|
|
177
|
+
const TddTaskInput = Schema.Union(StartVariant, EndVariant, GetVariant, ResumeVariant);
|
|
178
|
+
const tddTask = idempotentProcedure.input(Schema.standardSchemaV1(TddTaskInput)).mutation(async ({ ctx, input }) => {
|
|
179
|
+
return ctx.runtime.runPromise(Match.value(input).pipe(Match.discriminatorsExhaustive("action")({
|
|
180
|
+
start: (variant) => Effect.gen(function* () {
|
|
181
|
+
const reader = yield* DataReader;
|
|
182
|
+
const store = yield* DataStore;
|
|
183
|
+
let sessionId;
|
|
184
|
+
if (variant.sessionId !== void 0) sessionId = variant.sessionId;
|
|
185
|
+
else if (variant.chatId !== void 0) {
|
|
186
|
+
const opt = yield* reader.getSessionByChatId(variant.chatId);
|
|
187
|
+
if (Option.isNone(opt)) return yield* Effect.fail(/* @__PURE__ */ new Error(`Unknown chatId: ${variant.chatId}. Run record session-start first.`));
|
|
188
|
+
sessionId = opt.value.id;
|
|
189
|
+
} else return yield* Effect.fail(/* @__PURE__ */ new Error("tdd_task action=start: provide sessionId or chatId"));
|
|
190
|
+
if (variant.runId !== void 0 && variant.runId.trim().length === 0) return yield* Effect.fail(/* @__PURE__ */ new Error("tdd_task action=start: runId must not be blank"));
|
|
191
|
+
return {
|
|
192
|
+
action: "start",
|
|
193
|
+
tddTaskId: yield* store.writeTddTask({
|
|
194
|
+
sessionId,
|
|
195
|
+
goal: variant.goal,
|
|
196
|
+
startedAt: variant.startedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
197
|
+
...variant.runId !== void 0 && { runId: variant.runId },
|
|
198
|
+
...variant.parentTddTaskId !== void 0 && { parentTddTaskId: variant.parentTddTaskId }
|
|
199
|
+
}),
|
|
200
|
+
goal: variant.goal,
|
|
201
|
+
...variant.runId !== void 0 && { runId: variant.runId }
|
|
202
|
+
};
|
|
203
|
+
}),
|
|
204
|
+
end: (variant) => Effect.gen(function* () {
|
|
205
|
+
yield* (yield* DataStore).endTddTask({
|
|
206
|
+
id: variant.tddTaskId,
|
|
207
|
+
outcome: variant.outcome,
|
|
208
|
+
endedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
209
|
+
...variant.summaryNoteId !== void 0 && { summaryNoteId: variant.summaryNoteId }
|
|
210
|
+
});
|
|
211
|
+
return {
|
|
212
|
+
action: "end",
|
|
213
|
+
tddTaskId: variant.tddTaskId,
|
|
214
|
+
outcome: variant.outcome
|
|
215
|
+
};
|
|
216
|
+
}),
|
|
217
|
+
get: (variant) => Effect.gen(function* () {
|
|
218
|
+
const reader = yield* DataReader;
|
|
219
|
+
const opt = yield* reader.getTddTaskById(variant.tddTaskId);
|
|
220
|
+
if (Option.isNone(opt)) return {
|
|
221
|
+
action: "get",
|
|
222
|
+
found: false,
|
|
223
|
+
tddTaskId: variant.tddTaskId
|
|
224
|
+
};
|
|
225
|
+
const currentOpt = yield* reader.getCurrentTddPhase(variant.tddTaskId);
|
|
226
|
+
const { id, ...rest } = opt.value;
|
|
227
|
+
return {
|
|
228
|
+
action: "get",
|
|
229
|
+
found: true,
|
|
230
|
+
task: {
|
|
231
|
+
tddTaskId: id,
|
|
232
|
+
...rest
|
|
233
|
+
},
|
|
234
|
+
currentPhase: Option.match(currentOpt, {
|
|
235
|
+
onNone: () => null,
|
|
236
|
+
onSome: (p) => ({
|
|
237
|
+
id: p.id,
|
|
238
|
+
phase: p.phase,
|
|
239
|
+
startedAt: p.startedAt,
|
|
240
|
+
behaviorId: p.behaviorId
|
|
241
|
+
})
|
|
242
|
+
})
|
|
243
|
+
};
|
|
244
|
+
}),
|
|
245
|
+
resume: (variant) => Effect.gen(function* () {
|
|
246
|
+
const reader = yield* DataReader;
|
|
247
|
+
const tddOpt = yield* reader.getTddTaskById(variant.tddTaskId);
|
|
248
|
+
if (Option.isNone(tddOpt)) return {
|
|
249
|
+
action: "resume",
|
|
250
|
+
found: false,
|
|
251
|
+
tddTaskId: variant.tddTaskId
|
|
252
|
+
};
|
|
253
|
+
const tdd = tddOpt.value;
|
|
254
|
+
const currentOpt = yield* reader.getCurrentTddPhase(variant.tddTaskId);
|
|
255
|
+
return {
|
|
256
|
+
action: "resume",
|
|
257
|
+
found: true,
|
|
258
|
+
tddTaskId: tdd.id,
|
|
259
|
+
goal: tdd.goal,
|
|
260
|
+
status: tdd.outcome ?? "in progress",
|
|
261
|
+
currentPhase: Option.match(currentOpt, {
|
|
262
|
+
onNone: () => null,
|
|
263
|
+
onSome: (p) => ({
|
|
264
|
+
id: p.id,
|
|
265
|
+
phase: p.phase,
|
|
266
|
+
startedAt: p.startedAt,
|
|
267
|
+
behaviorId: p.behaviorId
|
|
268
|
+
})
|
|
269
|
+
}),
|
|
270
|
+
phasesRecorded: tdd.phases.length,
|
|
271
|
+
artifactsRecorded: tdd.artifacts.length
|
|
272
|
+
};
|
|
273
|
+
})
|
|
274
|
+
})));
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
//#endregion
|
|
278
|
+
export { TddTaskAsMarkdown, TddTaskResult, tddTask };
|
package/tools/test.js
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { publicProcedure } from "../context.js";
|
|
2
|
+
import { DataReader } from "@vitest-agent/sdk";
|
|
3
|
+
import { Effect, Match, Option, ParseResult, Schema } from "effect";
|
|
4
|
+
|
|
5
|
+
//#region src/tools/test.ts
|
|
6
|
+
/**
|
|
7
|
+
* Consolidated `test` MCP tool — Schema-driven implementation.
|
|
8
|
+
*
|
|
9
|
+
* Replaces `test_list`, `test_get`, and `test_for_file` with one
|
|
10
|
+
* tool keyed on `action`. Result variants discriminate on
|
|
11
|
+
* `inventoryKind` so a single Effect Schema describes every shape
|
|
12
|
+
* the tool can emit.
|
|
13
|
+
*
|
|
14
|
+
* @packageDocumentation
|
|
15
|
+
*/
|
|
16
|
+
const TestRowSchema = Schema.Struct({
|
|
17
|
+
id: Schema.Number,
|
|
18
|
+
fullName: Schema.String,
|
|
19
|
+
state: Schema.String,
|
|
20
|
+
duration: Schema.NullOr(Schema.Number),
|
|
21
|
+
module: Schema.String,
|
|
22
|
+
classification: Schema.NullOr(Schema.String)
|
|
23
|
+
}).annotations({ identifier: "TestListRow" });
|
|
24
|
+
const TestErrorRowMini = Schema.Struct({
|
|
25
|
+
name: Schema.NullOr(Schema.String),
|
|
26
|
+
message: Schema.String,
|
|
27
|
+
diff: Schema.NullOr(Schema.String),
|
|
28
|
+
stack: Schema.NullOr(Schema.String)
|
|
29
|
+
}).annotations({ identifier: "TestGetErrorRow" });
|
|
30
|
+
const TestRunRow = Schema.Struct({
|
|
31
|
+
state: Schema.Literal("passed", "failed"),
|
|
32
|
+
timestamp: Schema.String
|
|
33
|
+
}).annotations({ identifier: "TestGetRunRow" });
|
|
34
|
+
const TestListGroup = Schema.Struct({
|
|
35
|
+
project: Schema.String,
|
|
36
|
+
tests: Schema.Array(TestRowSchema)
|
|
37
|
+
});
|
|
38
|
+
const TestListResult = Schema.Struct({
|
|
39
|
+
action: Schema.Literal("list"),
|
|
40
|
+
count: Schema.Number,
|
|
41
|
+
groups: Schema.Array(TestListGroup)
|
|
42
|
+
}).annotations({ identifier: "TestList" });
|
|
43
|
+
const TestGetFound = Schema.Struct({
|
|
44
|
+
action: Schema.Literal("get"),
|
|
45
|
+
found: Schema.Literal(true),
|
|
46
|
+
project: Schema.String,
|
|
47
|
+
test: TestRowSchema,
|
|
48
|
+
errors: Schema.Array(TestErrorRowMini),
|
|
49
|
+
runs: Schema.Array(TestRunRow)
|
|
50
|
+
}).annotations({ identifier: "TestGetFound" });
|
|
51
|
+
const TestGetMissing = Schema.Struct({
|
|
52
|
+
action: Schema.Literal("get"),
|
|
53
|
+
found: Schema.Literal(false),
|
|
54
|
+
project: Schema.String,
|
|
55
|
+
fullName: Schema.String
|
|
56
|
+
}).annotations({ identifier: "TestGetMissing" });
|
|
57
|
+
const TestForFileResult = Schema.Struct({
|
|
58
|
+
action: Schema.Literal("for_file"),
|
|
59
|
+
filePath: Schema.String,
|
|
60
|
+
count: Schema.Number,
|
|
61
|
+
testFiles: Schema.Array(Schema.String)
|
|
62
|
+
}).annotations({ identifier: "TestForFile" });
|
|
63
|
+
const TestForTagResult = Schema.Struct({
|
|
64
|
+
action: Schema.Literal("for_tag"),
|
|
65
|
+
tag: Schema.String,
|
|
66
|
+
count: Schema.Number,
|
|
67
|
+
groups: Schema.Array(TestListGroup)
|
|
68
|
+
}).annotations({ identifier: "TestForTag" });
|
|
69
|
+
const TestResult = Schema.Union(TestListResult, TestGetFound, TestGetMissing, TestForFileResult, TestForTagResult).annotations({
|
|
70
|
+
identifier: "TestResult",
|
|
71
|
+
title: "test result",
|
|
72
|
+
description: "Discriminate on `action`. `get` further discriminates on `found`. `list`, `for_file`, and `for_tag` all carry counted arrays — `list` and `for_tag` group by project."
|
|
73
|
+
});
|
|
74
|
+
const formatTestMarkdown = (data) => {
|
|
75
|
+
if (data.action === "list") {
|
|
76
|
+
if (data.count === 0) return "No tests found. Run run_tests({}) to execute tests and populate the database.";
|
|
77
|
+
const lines = ["## Tests", ""];
|
|
78
|
+
for (const g of data.groups) {
|
|
79
|
+
lines.push(`### ${g.project}`, "", "| ID | Full Name | State | Duration | Module | Classification |", "| --- | --- | --- | --- | --- | --- |");
|
|
80
|
+
for (const t of g.tests) {
|
|
81
|
+
const duration = t.duration !== null ? `${t.duration}ms` : "—";
|
|
82
|
+
const classification = t.classification ?? "—";
|
|
83
|
+
lines.push(`| ${t.id} | ${t.fullName} | ${t.state} | ${duration} | ${t.module} | ${classification} |`);
|
|
84
|
+
}
|
|
85
|
+
lines.push("");
|
|
86
|
+
}
|
|
87
|
+
return lines.join("\n").trimEnd();
|
|
88
|
+
}
|
|
89
|
+
if (data.action === "get") {
|
|
90
|
+
if (!data.found) return `Test not found: \`${data.fullName}\`\n\nUse test({ action: "list" }) to discover available tests (format: "Suite > test name").`;
|
|
91
|
+
const t = data.test;
|
|
92
|
+
const lines = [
|
|
93
|
+
`# Test: ${t.fullName}`,
|
|
94
|
+
"",
|
|
95
|
+
"## Details",
|
|
96
|
+
"",
|
|
97
|
+
"| Field | Value |",
|
|
98
|
+
"| --- | --- |",
|
|
99
|
+
`| State | ${t.state} |`,
|
|
100
|
+
`| Duration | ${t.duration !== null ? `${t.duration}ms` : "—"} |`,
|
|
101
|
+
`| Module | \`${t.module}\` |`,
|
|
102
|
+
`| Classification | ${t.classification ?? "—"} |`,
|
|
103
|
+
""
|
|
104
|
+
];
|
|
105
|
+
if (data.errors.length > 0) {
|
|
106
|
+
lines.push("## Errors", "");
|
|
107
|
+
for (const err of data.errors) {
|
|
108
|
+
lines.push(`**${err.name ?? "(unnamed)"}**`);
|
|
109
|
+
lines.push(`> ${err.message.split("\n").join("\n> ")}`);
|
|
110
|
+
if (err.diff !== null) {
|
|
111
|
+
lines.push("", "```diff", err.diff.slice(0, 1e3));
|
|
112
|
+
if (err.diff.length > 1e3) lines.push(`... (truncated, ${err.diff.length} chars total)`);
|
|
113
|
+
lines.push("```");
|
|
114
|
+
}
|
|
115
|
+
if (err.stack !== null && err.diff === null) {
|
|
116
|
+
lines.push("", "```", err.stack.slice(0, 1e3));
|
|
117
|
+
if (err.stack.length > 1e3) lines.push(`... (truncated, ${err.stack.length} chars total)`);
|
|
118
|
+
lines.push("```");
|
|
119
|
+
}
|
|
120
|
+
lines.push("");
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (data.runs.length > 0) {
|
|
124
|
+
const viz = data.runs.map((r) => r.state === "passed" ? "P" : "F").join("");
|
|
125
|
+
const passCount = data.runs.filter((r) => r.state === "passed").length;
|
|
126
|
+
const failCount = data.runs.filter((r) => r.state === "failed").length;
|
|
127
|
+
lines.push("## Run History", "", `Pass rate: ${passCount}/${data.runs.length} (${Math.round(passCount / data.runs.length * 100)}%)`, `Recent runs: \`${viz}\` (P=passed F=failed S=skipped, newest last)`);
|
|
128
|
+
if (failCount > 0 && passCount > 0) lines.push("Pattern: **flaky** (mixed pass/fail)");
|
|
129
|
+
else if (failCount > 0) lines.push(`Pattern: **persistent failure** (${failCount} consecutive)`);
|
|
130
|
+
lines.push("");
|
|
131
|
+
}
|
|
132
|
+
if (t.state === "failed") lines.push("## Next steps", "", `- Re-run: run_tests({ files: ["${t.module}"] })`, `- Use test({ action: "for_file", filePath: "${t.module}" }) to find related tests`, "- Use note({ action: \"create\", ... }) to record debugging findings");
|
|
133
|
+
return lines.join("\n");
|
|
134
|
+
}
|
|
135
|
+
if (data.action === "for_tag") {
|
|
136
|
+
if (data.count === 0) return `No tests found tagged \`${data.tag}\`. Use \`inventory({ kind: "tag" })\` to discover available tags.`;
|
|
137
|
+
const lines = [
|
|
138
|
+
`# Tests tagged \`${data.tag}\``,
|
|
139
|
+
"",
|
|
140
|
+
`Found ${data.count} test${data.count === 1 ? "" : "s"} across ${data.groups.length} project${data.groups.length === 1 ? "" : "s"}:`,
|
|
141
|
+
""
|
|
142
|
+
];
|
|
143
|
+
for (const g of data.groups) {
|
|
144
|
+
lines.push(`### ${g.project}`, "", "| ID | Full Name | State | Duration | Module |", "| --- | --- | --- | --- | --- |");
|
|
145
|
+
for (const t of g.tests) {
|
|
146
|
+
const duration = t.duration !== null ? `${t.duration}ms` : "—";
|
|
147
|
+
lines.push(`| ${t.id} | ${t.fullName} | ${t.state} | ${duration} | ${t.module} |`);
|
|
148
|
+
}
|
|
149
|
+
lines.push("");
|
|
150
|
+
}
|
|
151
|
+
return lines.join("\n").trimEnd();
|
|
152
|
+
}
|
|
153
|
+
if (data.count === 0) return `No test modules found covering \`${data.filePath}\`. Run run_tests({}) to populate the database, or check the file path.`;
|
|
154
|
+
const lines = [
|
|
155
|
+
`# Tests for \`${data.filePath}\``,
|
|
156
|
+
"",
|
|
157
|
+
`Found ${data.count} test module${data.count === 1 ? "" : "s"}:`,
|
|
158
|
+
""
|
|
159
|
+
];
|
|
160
|
+
for (const f of data.testFiles) lines.push(`- \`${f}\``);
|
|
161
|
+
return lines.join("\n");
|
|
162
|
+
};
|
|
163
|
+
const TestAsMarkdown = Schema.transformOrFail(TestResult, Schema.String, {
|
|
164
|
+
strict: true,
|
|
165
|
+
decode: (data) => ParseResult.succeed(formatTestMarkdown(data)),
|
|
166
|
+
encode: (text, _options, ast) => ParseResult.fail(new ParseResult.Forbidden(ast, text, "TestAsMarkdown is one-way."))
|
|
167
|
+
});
|
|
168
|
+
const ListVariant = Schema.Struct({
|
|
169
|
+
action: Schema.Literal("list"),
|
|
170
|
+
project: Schema.optional(Schema.String),
|
|
171
|
+
state: Schema.optional(Schema.String),
|
|
172
|
+
module: Schema.optional(Schema.String),
|
|
173
|
+
limit: Schema.optional(Schema.Number)
|
|
174
|
+
});
|
|
175
|
+
const GetVariant = Schema.Struct({
|
|
176
|
+
action: Schema.Literal("get"),
|
|
177
|
+
fullName: Schema.String,
|
|
178
|
+
project: Schema.optional(Schema.String)
|
|
179
|
+
});
|
|
180
|
+
const ForFileVariant = Schema.Struct({
|
|
181
|
+
action: Schema.Literal("for_file"),
|
|
182
|
+
filePath: Schema.String
|
|
183
|
+
});
|
|
184
|
+
const ForTagVariant = Schema.Struct({
|
|
185
|
+
action: Schema.Literal("for_tag"),
|
|
186
|
+
tag: Schema.String,
|
|
187
|
+
project: Schema.optional(Schema.String)
|
|
188
|
+
});
|
|
189
|
+
const TestInput = Schema.Union(ListVariant, GetVariant, ForFileVariant, ForTagVariant);
|
|
190
|
+
const test = publicProcedure.input(Schema.standardSchemaV1(TestInput)).query(async ({ ctx, input }) => {
|
|
191
|
+
return ctx.runtime.runPromise(Match.value(input).pipe(Match.discriminatorsExhaustive("action")({
|
|
192
|
+
list: (variant) => Effect.gen(function* () {
|
|
193
|
+
const reader = yield* DataReader;
|
|
194
|
+
const opts = {};
|
|
195
|
+
if (variant.state !== void 0) opts.state = variant.state;
|
|
196
|
+
if (variant.module !== void 0) opts.module = variant.module;
|
|
197
|
+
if (variant.limit !== void 0) opts.limit = variant.limit;
|
|
198
|
+
const targets = variant.project ? [{ project: variant.project }] : yield* reader.getRunsByProject().pipe(Effect.map((rs) => rs.map((r) => ({ project: r.project }))));
|
|
199
|
+
const groups = [];
|
|
200
|
+
let total = 0;
|
|
201
|
+
for (const t of targets) {
|
|
202
|
+
const tests = yield* reader.listTests(t.project, opts);
|
|
203
|
+
if (tests.length > 0) {
|
|
204
|
+
groups.push({
|
|
205
|
+
project: t.project,
|
|
206
|
+
tests
|
|
207
|
+
});
|
|
208
|
+
total += tests.length;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
action: "list",
|
|
213
|
+
count: total,
|
|
214
|
+
groups
|
|
215
|
+
};
|
|
216
|
+
}),
|
|
217
|
+
get: (variant) => Effect.gen(function* () {
|
|
218
|
+
const reader = yield* DataReader;
|
|
219
|
+
const candidates = variant.project ? [variant.project] : yield* reader.getRunsByProject().pipe(Effect.map((rs) => rs.map((r) => r.project)));
|
|
220
|
+
for (const project of candidates) {
|
|
221
|
+
const testOpt = yield* reader.getTestByFullName(project, variant.fullName);
|
|
222
|
+
if (Option.isNone(testOpt)) continue;
|
|
223
|
+
const matchingErrors = (yield* reader.getErrors(project)).filter((e) => e.testFullName === variant.fullName).map((e) => ({
|
|
224
|
+
name: e.name,
|
|
225
|
+
message: e.message,
|
|
226
|
+
diff: e.diff,
|
|
227
|
+
stack: e.stack
|
|
228
|
+
}));
|
|
229
|
+
const testHistory = (yield* reader.getHistory(project)).tests.find((entry) => entry.fullName === variant.fullName);
|
|
230
|
+
return {
|
|
231
|
+
action: "get",
|
|
232
|
+
found: true,
|
|
233
|
+
project,
|
|
234
|
+
test: testOpt.value,
|
|
235
|
+
errors: matchingErrors,
|
|
236
|
+
runs: testHistory ? testHistory.runs : []
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
return {
|
|
240
|
+
action: "get",
|
|
241
|
+
found: false,
|
|
242
|
+
project: variant.project ?? candidates[0] ?? "",
|
|
243
|
+
fullName: variant.fullName
|
|
244
|
+
};
|
|
245
|
+
}),
|
|
246
|
+
for_file: (variant) => Effect.gen(function* () {
|
|
247
|
+
const testFiles = yield* (yield* DataReader).getTestsForFile(variant.filePath);
|
|
248
|
+
return {
|
|
249
|
+
action: "for_file",
|
|
250
|
+
filePath: variant.filePath,
|
|
251
|
+
count: testFiles.length,
|
|
252
|
+
testFiles
|
|
253
|
+
};
|
|
254
|
+
}),
|
|
255
|
+
for_tag: (variant) => Effect.gen(function* () {
|
|
256
|
+
const reader = yield* DataReader;
|
|
257
|
+
const targets = variant.project ? [{ project: variant.project }] : yield* reader.getRunsByProject().pipe(Effect.map((rs) => rs.map((r) => ({ project: r.project }))));
|
|
258
|
+
const groups = [];
|
|
259
|
+
let total = 0;
|
|
260
|
+
for (const t of targets) {
|
|
261
|
+
const tests = yield* reader.listTestsForTag(variant.tag, { project: t.project });
|
|
262
|
+
if (tests.length > 0) {
|
|
263
|
+
groups.push({
|
|
264
|
+
project: t.project,
|
|
265
|
+
tests
|
|
266
|
+
});
|
|
267
|
+
total += tests.length;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return {
|
|
271
|
+
action: "for_tag",
|
|
272
|
+
tag: variant.tag,
|
|
273
|
+
count: total,
|
|
274
|
+
groups
|
|
275
|
+
};
|
|
276
|
+
})
|
|
277
|
+
})));
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
//#endregion
|
|
281
|
+
export { TestAsMarkdown, TestResult, test };
|
package/tools/trends.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { publicProcedure } from "../context.js";
|
|
2
|
+
import { DataReader, TrendRecord } from "@vitest-agent/sdk";
|
|
3
|
+
import { Effect, Option, ParseResult, Schema } from "effect";
|
|
4
|
+
|
|
5
|
+
//#region src/tools/trends.ts
|
|
6
|
+
/**
|
|
7
|
+
* `test_trends` MCP tool — Schema-driven implementation.
|
|
8
|
+
*
|
|
9
|
+
* Wraps the existing `TrendRecord` Schema in a result envelope that
|
|
10
|
+
* carries the project name and a `dataAvailable` flag, so callers
|
|
11
|
+
* can distinguish "no trend data yet" from "data plus rendering"
|
|
12
|
+
* without parsing prose.
|
|
13
|
+
*
|
|
14
|
+
* @packageDocumentation
|
|
15
|
+
*/
|
|
16
|
+
const TrendsAvailable = Schema.Struct({
|
|
17
|
+
dataAvailable: Schema.Literal(true).annotations({ description: "Discriminant — `true` when at least one trend entry exists for the project." }),
|
|
18
|
+
project: Schema.String,
|
|
19
|
+
trends: TrendRecord.annotations({ description: "Trend entries oldest-first; the latest entry drives `direction` and the headline metrics." })
|
|
20
|
+
}).annotations({ identifier: "TestTrendsAvailable" });
|
|
21
|
+
const TrendsAbsent = Schema.Struct({
|
|
22
|
+
dataAvailable: Schema.Literal(false).annotations({ description: "Discriminant — `false` when fewer than two runs have been recorded for the project." }),
|
|
23
|
+
project: Schema.String
|
|
24
|
+
}).annotations({ identifier: "TestTrendsAbsent" });
|
|
25
|
+
const TestTrendsResult = Schema.Union(TrendsAvailable, TrendsAbsent).annotations({
|
|
26
|
+
identifier: "TestTrendsResult",
|
|
27
|
+
title: "test_trends result",
|
|
28
|
+
description: "Coverage trend record per project. Discriminate on `dataAvailable` to handle the cold-start case."
|
|
29
|
+
});
|
|
30
|
+
const SPARKLINE_CHARS = [
|
|
31
|
+
"▁",
|
|
32
|
+
"▂",
|
|
33
|
+
"▃",
|
|
34
|
+
"▄",
|
|
35
|
+
"▅",
|
|
36
|
+
"▆",
|
|
37
|
+
"▇",
|
|
38
|
+
"█"
|
|
39
|
+
];
|
|
40
|
+
function toSparkline(values) {
|
|
41
|
+
if (values.length === 0) return "";
|
|
42
|
+
const min = Math.min(...values);
|
|
43
|
+
const range = Math.max(...values) - min;
|
|
44
|
+
return values.map((v) => {
|
|
45
|
+
return SPARKLINE_CHARS[range === 0 ? 4 : Math.round((v - min) / range * (SPARKLINE_CHARS.length - 1))] ?? "▄";
|
|
46
|
+
}).join("");
|
|
47
|
+
}
|
|
48
|
+
const directionIcon = (d) => d === "improving" ? "📈" : d === "regressing" ? "📉" : "➡️";
|
|
49
|
+
const formatTestTrendsMarkdown = (data) => {
|
|
50
|
+
if (data.dataAvailable === false) return `No trend data available for project \`${data.project}\`. Run tests multiple times to build trend history.`;
|
|
51
|
+
const entries = data.trends.entries;
|
|
52
|
+
const latest = entries[entries.length - 1];
|
|
53
|
+
if (latest === void 0) return `No trend data available for project \`${data.project}\`.`;
|
|
54
|
+
const lines = [`# Coverage Trends: ${data.project}`, ""];
|
|
55
|
+
lines.push(`${directionIcon(latest.direction)} **Overall direction:** ${latest.direction} over ${entries.length} run${entries.length === 1 ? "" : "s"}`);
|
|
56
|
+
lines.push("", "## Latest Coverage", "", "| Metric | Value | Δ |", "| --- | --- | --- |");
|
|
57
|
+
const metrics = [
|
|
58
|
+
"statements",
|
|
59
|
+
"branches",
|
|
60
|
+
"functions",
|
|
61
|
+
"lines"
|
|
62
|
+
];
|
|
63
|
+
for (const metric of metrics) {
|
|
64
|
+
const value = latest.coverage[metric];
|
|
65
|
+
const delta = latest.delta[metric];
|
|
66
|
+
const deltaStr = delta > 0 ? `+${delta.toFixed(2)}%` : delta < 0 ? `${delta.toFixed(2)}%` : "—";
|
|
67
|
+
const deltaIcon = delta > .1 ? "↑" : delta < -.1 ? "↓" : "";
|
|
68
|
+
lines.push(`| ${metric} | ${value.toFixed(2)}% | ${deltaIcon} ${deltaStr} |`);
|
|
69
|
+
}
|
|
70
|
+
lines.push("");
|
|
71
|
+
if (entries.length >= 2) {
|
|
72
|
+
lines.push("## Trajectory", "");
|
|
73
|
+
for (const metric of metrics) {
|
|
74
|
+
const values = entries.map((e) => e.coverage[metric]);
|
|
75
|
+
lines.push(`- **${metric}**: \`${toSparkline(values)}\``);
|
|
76
|
+
}
|
|
77
|
+
lines.push("");
|
|
78
|
+
}
|
|
79
|
+
const recentEntries = entries.slice(-10);
|
|
80
|
+
if (recentEntries.length > 0) {
|
|
81
|
+
lines.push("## Recent Runs", "", "| Date | Lines | Branches | Functions | Statements | Direction |", "| --- | --- | --- | --- | --- | --- |");
|
|
82
|
+
for (const entry of recentEntries) {
|
|
83
|
+
const date = new Date(entry.timestamp).toLocaleDateString();
|
|
84
|
+
lines.push(`| ${date} | ${entry.coverage.lines.toFixed(1)}% | ${entry.coverage.branches.toFixed(1)}% | ${entry.coverage.functions.toFixed(1)}% | ${entry.coverage.statements.toFixed(1)}% | ${directionIcon(entry.direction)} |`);
|
|
85
|
+
}
|
|
86
|
+
lines.push("");
|
|
87
|
+
}
|
|
88
|
+
return lines.join("\n");
|
|
89
|
+
};
|
|
90
|
+
const TestTrendsAsMarkdown = Schema.transformOrFail(TestTrendsResult, Schema.String, {
|
|
91
|
+
strict: true,
|
|
92
|
+
decode: (data) => ParseResult.succeed(formatTestTrendsMarkdown(data)),
|
|
93
|
+
encode: (text, _options, ast) => ParseResult.fail(new ParseResult.Forbidden(ast, text, "TestTrendsAsMarkdown is one-way: markdown cannot be parsed back to TestTrendsResult."))
|
|
94
|
+
});
|
|
95
|
+
const testTrends = publicProcedure.input(Schema.standardSchemaV1(Schema.Struct({
|
|
96
|
+
project: Schema.String,
|
|
97
|
+
limit: Schema.optional(Schema.Number)
|
|
98
|
+
}))).query(async ({ ctx, input }) => ctx.runtime.runPromise(Effect.gen(function* () {
|
|
99
|
+
const trendsOpt = yield* (yield* DataReader).getTrends(input.project, input.limit);
|
|
100
|
+
if (Option.isNone(trendsOpt) || trendsOpt.value.entries.length === 0) return {
|
|
101
|
+
dataAvailable: false,
|
|
102
|
+
project: input.project
|
|
103
|
+
};
|
|
104
|
+
return {
|
|
105
|
+
dataAvailable: true,
|
|
106
|
+
project: input.project,
|
|
107
|
+
trends: trendsOpt.value
|
|
108
|
+
};
|
|
109
|
+
})));
|
|
110
|
+
|
|
111
|
+
//#endregion
|
|
112
|
+
export { TestTrendsAsMarkdown, TestTrendsResult, testTrends };
|