requ-mcp 0.1.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 +217 -0
- package/dist/conductor.d.ts +46 -0
- package/dist/conductor.js +175 -0
- package/dist/conductor.js.map +1 -0
- package/dist/coverage.d.ts +109 -0
- package/dist/coverage.js +157 -0
- package/dist/coverage.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +684 -0
- package/dist/index.js.map +1 -0
- package/dist/ingest.d.ts +19 -0
- package/dist/ingest.js +47 -0
- package/dist/ingest.js.map +1 -0
- package/dist/schema.d.ts +262 -0
- package/dist/schema.js +134 -0
- package/dist/schema.js.map +1 -0
- package/dist/storage.d.ts +48 -0
- package/dist/storage.js +184 -0
- package/dist/storage.js.map +1 -0
- package/package.json +55 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,684 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { promises as fs } from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import url from "node:url";
|
|
8
|
+
import { Store } from "./storage.js";
|
|
9
|
+
import { CoverageMode, Priority, PhaseStatus, RequirementStatus, StoryStatus, TestStatus, testKey, } from "./schema.js";
|
|
10
|
+
import { danglingStoryTags, indexConductor, linkedScenarioKeys, scenariosByStory, validateTestRef, } from "./conductor.js";
|
|
11
|
+
import { buildReport, buildTrend, findGaps, resolveStatuses } from "./coverage.js";
|
|
12
|
+
import { parseCucumberJson } from "./ingest.js";
|
|
13
|
+
const now = () => new Date().toISOString();
|
|
14
|
+
function json(data) {
|
|
15
|
+
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
|
16
|
+
}
|
|
17
|
+
function text(s) {
|
|
18
|
+
return { content: [{ type: "text", text: s }] };
|
|
19
|
+
}
|
|
20
|
+
function fail(message, extra) {
|
|
21
|
+
return {
|
|
22
|
+
isError: true,
|
|
23
|
+
content: [{ type: "text", text: JSON.stringify({ error: message, ...extra }, null, 2) }],
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
const server = new McpServer({ name: "requ-mcp", version: "0.1.0" });
|
|
27
|
+
// ===========================================================================
|
|
28
|
+
// Project resolution
|
|
29
|
+
//
|
|
30
|
+
// A user-level (global) server must figure out which project each call targets.
|
|
31
|
+
// Precedence: explicit projectPath → REQU_ROOT → a workspace root (MCP roots)
|
|
32
|
+
// or cwd-ancestor that already contains `.requ/` → first workspace root → cwd.
|
|
33
|
+
// ===========================================================================
|
|
34
|
+
let cachedRoots = null;
|
|
35
|
+
async function workspaceRoots() {
|
|
36
|
+
if (cachedRoots)
|
|
37
|
+
return cachedRoots;
|
|
38
|
+
try {
|
|
39
|
+
const res = await server.server.listRoots();
|
|
40
|
+
cachedRoots = (res.roots ?? [])
|
|
41
|
+
.map((r) => r.uri)
|
|
42
|
+
.filter((u) => u.startsWith("file://"))
|
|
43
|
+
.map((u) => url.fileURLToPath(u));
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
cachedRoots = []; // client doesn't support roots
|
|
47
|
+
}
|
|
48
|
+
return cachedRoots;
|
|
49
|
+
}
|
|
50
|
+
async function hasRequ(dir) {
|
|
51
|
+
try {
|
|
52
|
+
await fs.access(path.join(dir, ".requ"));
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
async function findUp(start) {
|
|
60
|
+
let dir = path.resolve(start);
|
|
61
|
+
for (;;) {
|
|
62
|
+
if (await hasRequ(dir))
|
|
63
|
+
return dir;
|
|
64
|
+
const parent = path.dirname(dir);
|
|
65
|
+
if (parent === dir)
|
|
66
|
+
return null;
|
|
67
|
+
dir = parent;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
async function resolveRoot(explicit) {
|
|
71
|
+
if (explicit)
|
|
72
|
+
return path.resolve(explicit);
|
|
73
|
+
if (process.env.REQU_ROOT)
|
|
74
|
+
return path.resolve(process.env.REQU_ROOT);
|
|
75
|
+
const roots = await workspaceRoots();
|
|
76
|
+
for (const r of roots)
|
|
77
|
+
if (await hasRequ(r))
|
|
78
|
+
return r; // initialized workspace wins
|
|
79
|
+
const found = await findUp(process.cwd());
|
|
80
|
+
if (found)
|
|
81
|
+
return found;
|
|
82
|
+
return roots[0] ?? process.cwd(); // not yet initialized
|
|
83
|
+
}
|
|
84
|
+
async function getStore(explicit) {
|
|
85
|
+
return new Store(await resolveRoot(explicit));
|
|
86
|
+
}
|
|
87
|
+
const projectPathSchema = z
|
|
88
|
+
.string()
|
|
89
|
+
.optional()
|
|
90
|
+
.describe("Absolute path to the project root (the dir containing .requ/). Omit to auto-detect: REQU_ROOT, else a workspace root or ancestor of the cwd that contains .requ/.");
|
|
91
|
+
/** Register a tool that auto-injects `projectPath` and resolves the Store. */
|
|
92
|
+
function tool(name, config, handler) {
|
|
93
|
+
const inputSchema = { ...(config.inputSchema ?? {}), projectPath: projectPathSchema };
|
|
94
|
+
server.registerTool(name, { title: config.title, description: config.description, inputSchema }, async (args) => {
|
|
95
|
+
try {
|
|
96
|
+
const store = await getStore(args.projectPath);
|
|
97
|
+
return (await handler(args, store));
|
|
98
|
+
}
|
|
99
|
+
catch (e) {
|
|
100
|
+
return fail(e.message);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
async function ensureInit(store) {
|
|
105
|
+
if (!(await store.isInitialized())) {
|
|
106
|
+
throw new Error(`requ project not initialized at ${store.root}. Run \`init_project\` first (pass projectPath to target a specific directory).`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async function loadConductorIndex(store) {
|
|
110
|
+
const root = await store.conductorRoot();
|
|
111
|
+
return { root, index: await indexConductor(root) };
|
|
112
|
+
}
|
|
113
|
+
// ===========================================================================
|
|
114
|
+
// Setup
|
|
115
|
+
// ===========================================================================
|
|
116
|
+
tool("init_project", {
|
|
117
|
+
title: "Initialize requ project",
|
|
118
|
+
description: "Create the `.requ/` directory at the resolved project root, record the Conductor project path and optional cucumber-json report path, and optionally create an initial phase. Idempotent.",
|
|
119
|
+
inputSchema: {
|
|
120
|
+
name: z.string().optional(),
|
|
121
|
+
conductorPath: z.string().optional().describe("Path to the Conductor project root (has features/). Default '.'."),
|
|
122
|
+
conductorReportPath: z
|
|
123
|
+
.string()
|
|
124
|
+
.optional()
|
|
125
|
+
.describe("Default path to Conductor's cucumber-json result file, for import_execution_report."),
|
|
126
|
+
initialPhase: z.string().optional().describe("If set, create and activate a first phase with this name."),
|
|
127
|
+
},
|
|
128
|
+
}, async (args, store) => {
|
|
129
|
+
const existing = (await store.isInitialized()) ? await store.readConfig() : null;
|
|
130
|
+
const config = {
|
|
131
|
+
name: args.name ?? existing?.name ?? path.basename(store.root),
|
|
132
|
+
conductorPath: args.conductorPath ?? existing?.conductorPath ?? ".",
|
|
133
|
+
conductorReportPath: args.conductorReportPath ?? existing?.conductorReportPath,
|
|
134
|
+
activePhase: existing?.activePhase,
|
|
135
|
+
};
|
|
136
|
+
await store.init(config);
|
|
137
|
+
let phase;
|
|
138
|
+
if (args.initialPhase) {
|
|
139
|
+
phase = {
|
|
140
|
+
id: "PHASE-001",
|
|
141
|
+
name: args.initialPhase,
|
|
142
|
+
order: 1,
|
|
143
|
+
status: "active",
|
|
144
|
+
description: "",
|
|
145
|
+
createdAt: now(),
|
|
146
|
+
updatedAt: now(),
|
|
147
|
+
};
|
|
148
|
+
await store.writePhase(phase);
|
|
149
|
+
await store.writeConfig({ ...config, activePhase: phase.id });
|
|
150
|
+
}
|
|
151
|
+
return json({ initialized: true, root: store.root, config: await store.readConfig(), phase });
|
|
152
|
+
});
|
|
153
|
+
// ===========================================================================
|
|
154
|
+
// Requirements
|
|
155
|
+
// ===========================================================================
|
|
156
|
+
tool("create_requirement", {
|
|
157
|
+
title: "Create requirement",
|
|
158
|
+
description: "Register an imported requirement (the upstream 'what must be built').",
|
|
159
|
+
inputSchema: {
|
|
160
|
+
title: z.string(),
|
|
161
|
+
description: z.string().optional(),
|
|
162
|
+
source: z.string().optional().describe("Provenance: doc, spec section, ticket id."),
|
|
163
|
+
priority: Priority.optional(),
|
|
164
|
+
components: z.array(z.string()).optional().describe("Sub-systems/modules this requirement belongs to."),
|
|
165
|
+
tags: z.array(z.string()).optional(),
|
|
166
|
+
id: z.string().regex(/^REQ-\d+$/).optional(),
|
|
167
|
+
},
|
|
168
|
+
}, async (args, store) => {
|
|
169
|
+
await ensureInit(store);
|
|
170
|
+
const existing = await store.listRequirements();
|
|
171
|
+
const id = args.id ?? Store.nextId("REQ", existing.map((r) => r.id));
|
|
172
|
+
if (existing.some((r) => r.id === id))
|
|
173
|
+
return fail(`Requirement ${id} already exists.`);
|
|
174
|
+
const req = {
|
|
175
|
+
id,
|
|
176
|
+
title: args.title,
|
|
177
|
+
description: args.description ?? "",
|
|
178
|
+
source: args.source ?? "",
|
|
179
|
+
priority: args.priority ?? "medium",
|
|
180
|
+
components: args.components ?? [],
|
|
181
|
+
tags: args.tags ?? [],
|
|
182
|
+
status: "active",
|
|
183
|
+
createdAt: now(),
|
|
184
|
+
updatedAt: now(),
|
|
185
|
+
};
|
|
186
|
+
await store.writeRequirement(req);
|
|
187
|
+
return json(req);
|
|
188
|
+
});
|
|
189
|
+
tool("list_requirements", {
|
|
190
|
+
title: "List requirements",
|
|
191
|
+
description: "List requirements, optionally filtered by status, component, or tag.",
|
|
192
|
+
inputSchema: {
|
|
193
|
+
status: RequirementStatus.optional(),
|
|
194
|
+
component: z.string().optional(),
|
|
195
|
+
tag: z.string().optional(),
|
|
196
|
+
},
|
|
197
|
+
}, async (args, store) => {
|
|
198
|
+
await ensureInit(store);
|
|
199
|
+
let reqs = await store.listRequirements();
|
|
200
|
+
if (args.status)
|
|
201
|
+
reqs = reqs.filter((r) => r.status === args.status);
|
|
202
|
+
if (args.component)
|
|
203
|
+
reqs = reqs.filter((r) => r.components.includes(args.component));
|
|
204
|
+
if (args.tag)
|
|
205
|
+
reqs = reqs.filter((r) => r.tags.includes(args.tag));
|
|
206
|
+
return json(reqs);
|
|
207
|
+
});
|
|
208
|
+
tool("get_requirement", {
|
|
209
|
+
title: "Get requirement",
|
|
210
|
+
description: "Fetch a requirement and the stories that trace to it.",
|
|
211
|
+
inputSchema: { id: z.string().regex(/^REQ-\d+$/) },
|
|
212
|
+
}, async (args, store) => {
|
|
213
|
+
await ensureInit(store);
|
|
214
|
+
const req = await store.getRequirement(args.id);
|
|
215
|
+
if (!req)
|
|
216
|
+
return fail(`Requirement ${args.id} not found.`);
|
|
217
|
+
const stories = await store.listStories();
|
|
218
|
+
return json({ ...req, linkedStories: stories.filter((s) => s.requirements.includes(args.id)).map((s) => s.id) });
|
|
219
|
+
});
|
|
220
|
+
tool("update_requirement", {
|
|
221
|
+
title: "Update requirement",
|
|
222
|
+
description: "Update a requirement's fields (title, description, priority, components, tags, status).",
|
|
223
|
+
inputSchema: {
|
|
224
|
+
id: z.string().regex(/^REQ-\d+$/),
|
|
225
|
+
title: z.string().optional(),
|
|
226
|
+
description: z.string().optional(),
|
|
227
|
+
source: z.string().optional(),
|
|
228
|
+
priority: Priority.optional(),
|
|
229
|
+
components: z.array(z.string()).optional(),
|
|
230
|
+
tags: z.array(z.string()).optional(),
|
|
231
|
+
status: RequirementStatus.optional(),
|
|
232
|
+
},
|
|
233
|
+
}, async (args, store) => {
|
|
234
|
+
await ensureInit(store);
|
|
235
|
+
const req = await store.getRequirement(args.id);
|
|
236
|
+
if (!req)
|
|
237
|
+
return fail(`Requirement ${args.id} not found.`);
|
|
238
|
+
for (const k of ["title", "description", "source", "priority", "components", "tags", "status"]) {
|
|
239
|
+
if (args[k] !== undefined)
|
|
240
|
+
req[k] = args[k];
|
|
241
|
+
}
|
|
242
|
+
req.updatedAt = now();
|
|
243
|
+
await store.writeRequirement(req);
|
|
244
|
+
return json(req);
|
|
245
|
+
});
|
|
246
|
+
// ===========================================================================
|
|
247
|
+
// User Stories (PO agent)
|
|
248
|
+
// ===========================================================================
|
|
249
|
+
tool("create_user_story", {
|
|
250
|
+
title: "Create user story",
|
|
251
|
+
description: "Author a user story. MUST link ≥1 existing requirement (validated). Acceptance criteria are descriptive; tests are linked by tagging scenarios with @<this story id> in the feature files.",
|
|
252
|
+
inputSchema: {
|
|
253
|
+
title: z.string(),
|
|
254
|
+
requirements: z.array(z.string().regex(/^REQ-\d+$/)).min(1),
|
|
255
|
+
description: z.string().optional(),
|
|
256
|
+
acceptanceCriteria: z.array(z.string()).optional().describe("Descriptive criterion texts."),
|
|
257
|
+
id: z.string().regex(/^US-\d+$/).optional(),
|
|
258
|
+
},
|
|
259
|
+
}, async (args, store) => {
|
|
260
|
+
await ensureInit(store);
|
|
261
|
+
const missing = [];
|
|
262
|
+
for (const reqId of args.requirements)
|
|
263
|
+
if (!(await store.getRequirement(reqId)))
|
|
264
|
+
missing.push(reqId);
|
|
265
|
+
if (missing.length)
|
|
266
|
+
return fail(`Unknown requirement(s): ${missing.join(", ")}`);
|
|
267
|
+
const existing = await store.listStories();
|
|
268
|
+
const id = args.id ?? Store.nextId("US", existing.map((s) => s.id));
|
|
269
|
+
if (existing.some((s) => s.id === id))
|
|
270
|
+
return fail(`Story ${id} already exists.`);
|
|
271
|
+
const acceptanceCriteria = (args.acceptanceCriteria ?? []).map((t, i) => ({
|
|
272
|
+
id: `AC-${i + 1}`,
|
|
273
|
+
text: t,
|
|
274
|
+
}));
|
|
275
|
+
const story = {
|
|
276
|
+
id,
|
|
277
|
+
title: args.title,
|
|
278
|
+
description: args.description ?? "",
|
|
279
|
+
requirements: args.requirements,
|
|
280
|
+
acceptanceCriteria,
|
|
281
|
+
status: "draft",
|
|
282
|
+
createdAt: now(),
|
|
283
|
+
updatedAt: now(),
|
|
284
|
+
};
|
|
285
|
+
await store.writeStory(story);
|
|
286
|
+
return json({ ...story, hint: `Tag scenarios with @${id} in your feature files to link tests to this story.` });
|
|
287
|
+
});
|
|
288
|
+
tool("list_user_stories", {
|
|
289
|
+
title: "List user stories",
|
|
290
|
+
description: "List user stories, optionally filtered by status or linked requirement.",
|
|
291
|
+
inputSchema: { status: StoryStatus.optional(), requirement: z.string().regex(/^REQ-\d+$/).optional() },
|
|
292
|
+
}, async (args, store) => {
|
|
293
|
+
await ensureInit(store);
|
|
294
|
+
let stories = await store.listStories();
|
|
295
|
+
if (args.status)
|
|
296
|
+
stories = stories.filter((s) => s.status === args.status);
|
|
297
|
+
if (args.requirement)
|
|
298
|
+
stories = stories.filter((s) => s.requirements.includes(args.requirement));
|
|
299
|
+
return json(stories);
|
|
300
|
+
});
|
|
301
|
+
tool("get_user_story", {
|
|
302
|
+
title: "Get user story",
|
|
303
|
+
description: "Fetch one user story with its descriptive acceptance criteria and the scenarios tagged to it (with their latest status in the active phase, cumulative).",
|
|
304
|
+
inputSchema: { id: z.string().regex(/^US-\d+$/) },
|
|
305
|
+
}, async (args, store) => {
|
|
306
|
+
await ensureInit(store);
|
|
307
|
+
const story = await store.getStory(args.id);
|
|
308
|
+
if (!story)
|
|
309
|
+
return fail(`Story ${args.id} not found.`);
|
|
310
|
+
const { index } = await loadConductorIndex(store);
|
|
311
|
+
const scs = scenariosByStory(index).get(args.id) ?? [];
|
|
312
|
+
const [phases, execByPhase] = await Promise.all([store.listPhases(), store.readAllExecutions()]);
|
|
313
|
+
const phaseId = await store.resolvePhaseId();
|
|
314
|
+
const status = resolveStatuses(execByPhase, phases, phaseId, "cumulative");
|
|
315
|
+
return json({
|
|
316
|
+
...story,
|
|
317
|
+
phase: phaseId,
|
|
318
|
+
linkedScenarios: scs.map((sc) => ({
|
|
319
|
+
feature: sc.feature,
|
|
320
|
+
name: sc.name,
|
|
321
|
+
status: status.get(testKey(sc)) ?? "pending",
|
|
322
|
+
})),
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
tool("update_user_story", {
|
|
326
|
+
title: "Update user story",
|
|
327
|
+
description: "Update a story's title, description, status, or linked requirements (re-validated, must stay ≥1).",
|
|
328
|
+
inputSchema: {
|
|
329
|
+
id: z.string().regex(/^US-\d+$/),
|
|
330
|
+
title: z.string().optional(),
|
|
331
|
+
description: z.string().optional(),
|
|
332
|
+
status: StoryStatus.optional(),
|
|
333
|
+
requirements: z.array(z.string().regex(/^REQ-\d+$/)).min(1).optional(),
|
|
334
|
+
},
|
|
335
|
+
}, async (args, store) => {
|
|
336
|
+
await ensureInit(store);
|
|
337
|
+
const story = await store.getStory(args.id);
|
|
338
|
+
if (!story)
|
|
339
|
+
return fail(`Story ${args.id} not found.`);
|
|
340
|
+
if (args.requirements) {
|
|
341
|
+
const missing = [];
|
|
342
|
+
for (const reqId of args.requirements)
|
|
343
|
+
if (!(await store.getRequirement(reqId)))
|
|
344
|
+
missing.push(reqId);
|
|
345
|
+
if (missing.length)
|
|
346
|
+
return fail(`Unknown requirement(s): ${missing.join(", ")}`);
|
|
347
|
+
story.requirements = args.requirements;
|
|
348
|
+
}
|
|
349
|
+
if (args.title !== undefined)
|
|
350
|
+
story.title = args.title;
|
|
351
|
+
if (args.description !== undefined)
|
|
352
|
+
story.description = args.description;
|
|
353
|
+
if (args.status !== undefined)
|
|
354
|
+
story.status = args.status;
|
|
355
|
+
story.updatedAt = now();
|
|
356
|
+
await store.writeStory(story);
|
|
357
|
+
return json(story);
|
|
358
|
+
});
|
|
359
|
+
tool("add_acceptance_criterion", {
|
|
360
|
+
title: "Add acceptance criterion",
|
|
361
|
+
description: "Append a descriptive acceptance criterion to a story.",
|
|
362
|
+
inputSchema: { storyId: z.string().regex(/^US-\d+$/), text: z.string() },
|
|
363
|
+
}, async (args, store) => {
|
|
364
|
+
await ensureInit(store);
|
|
365
|
+
const story = await store.getStory(args.storyId);
|
|
366
|
+
if (!story)
|
|
367
|
+
return fail(`Story ${args.storyId} not found.`);
|
|
368
|
+
const maxN = story.acceptanceCriteria.reduce((m, c) => {
|
|
369
|
+
const n = parseInt(c.id.replace("AC-", ""), 10);
|
|
370
|
+
return Number.isFinite(n) ? Math.max(m, n) : m;
|
|
371
|
+
}, 0);
|
|
372
|
+
const ac = { id: `AC-${maxN + 1}`, text: args.text };
|
|
373
|
+
story.acceptanceCriteria.push(ac);
|
|
374
|
+
story.updatedAt = now();
|
|
375
|
+
await store.writeStory(story);
|
|
376
|
+
return json({ storyId: args.storyId, criterion: ac });
|
|
377
|
+
});
|
|
378
|
+
// ===========================================================================
|
|
379
|
+
// Phases / Releases
|
|
380
|
+
// ===========================================================================
|
|
381
|
+
tool("create_phase", {
|
|
382
|
+
title: "Create phase/release",
|
|
383
|
+
description: "Create a phase (sprint or release) to capture coverage at a point in time. Order auto-increments. Optionally make it the active phase.",
|
|
384
|
+
inputSchema: {
|
|
385
|
+
name: z.string().describe("e.g. 'v1.0', 'Sprint 3'."),
|
|
386
|
+
order: z.number().int().optional().describe("Sort key; defaults to max+1."),
|
|
387
|
+
description: z.string().optional(),
|
|
388
|
+
activate: z.boolean().optional(),
|
|
389
|
+
},
|
|
390
|
+
}, async (args, store) => {
|
|
391
|
+
await ensureInit(store);
|
|
392
|
+
const existing = await store.listPhases();
|
|
393
|
+
const id = Store.nextId("PHASE", existing.map((p) => p.id));
|
|
394
|
+
const order = args.order ?? (existing.reduce((m, p) => Math.max(m, p.order), 0) + 1);
|
|
395
|
+
const phase = {
|
|
396
|
+
id,
|
|
397
|
+
name: args.name,
|
|
398
|
+
order,
|
|
399
|
+
status: args.activate ? "active" : "planned",
|
|
400
|
+
description: args.description ?? "",
|
|
401
|
+
createdAt: now(),
|
|
402
|
+
updatedAt: now(),
|
|
403
|
+
};
|
|
404
|
+
await store.writePhase(phase);
|
|
405
|
+
if (args.activate || existing.length === 0) {
|
|
406
|
+
const cfg = await store.readConfig();
|
|
407
|
+
await store.writeConfig({ ...cfg, activePhase: id });
|
|
408
|
+
}
|
|
409
|
+
return json(phase);
|
|
410
|
+
});
|
|
411
|
+
tool("list_phases", { title: "List phases", description: "List phases in order, marking which one is active.", inputSchema: {} }, async (_args, store) => {
|
|
412
|
+
await ensureInit(store);
|
|
413
|
+
const [phases, cfg] = await Promise.all([store.listPhases(), store.readConfig()]);
|
|
414
|
+
return json({ activePhase: cfg.activePhase ?? null, phases });
|
|
415
|
+
});
|
|
416
|
+
tool("update_phase", {
|
|
417
|
+
title: "Update phase",
|
|
418
|
+
description: "Update a phase's name, order, status, or description.",
|
|
419
|
+
inputSchema: {
|
|
420
|
+
id: z.string().regex(/^PHASE-\d+$/),
|
|
421
|
+
name: z.string().optional(),
|
|
422
|
+
order: z.number().int().optional(),
|
|
423
|
+
status: PhaseStatus.optional(),
|
|
424
|
+
description: z.string().optional(),
|
|
425
|
+
},
|
|
426
|
+
}, async (args, store) => {
|
|
427
|
+
await ensureInit(store);
|
|
428
|
+
const phase = await store.getPhase(args.id);
|
|
429
|
+
if (!phase)
|
|
430
|
+
return fail(`Phase ${args.id} not found.`);
|
|
431
|
+
for (const k of ["name", "order", "status", "description"]) {
|
|
432
|
+
if (args[k] !== undefined)
|
|
433
|
+
phase[k] = args[k];
|
|
434
|
+
}
|
|
435
|
+
phase.updatedAt = now();
|
|
436
|
+
await store.writePhase(phase);
|
|
437
|
+
return json(phase);
|
|
438
|
+
});
|
|
439
|
+
tool("set_active_phase", {
|
|
440
|
+
title: "Set active phase",
|
|
441
|
+
description: "Set which phase new executions are recorded against by default.",
|
|
442
|
+
inputSchema: { id: z.string().regex(/^PHASE-\d+$/) },
|
|
443
|
+
}, async (args, store) => {
|
|
444
|
+
await ensureInit(store);
|
|
445
|
+
if (!(await store.getPhase(args.id)))
|
|
446
|
+
return fail(`Phase ${args.id} not found.`);
|
|
447
|
+
const cfg = await store.readConfig();
|
|
448
|
+
await store.writeConfig({ ...cfg, activePhase: args.id });
|
|
449
|
+
return json({ activePhase: args.id });
|
|
450
|
+
});
|
|
451
|
+
// ===========================================================================
|
|
452
|
+
// Links (derived from @US-xxx scenario tags) — discovery & validation
|
|
453
|
+
// ===========================================================================
|
|
454
|
+
tool("list_links", {
|
|
455
|
+
title: "List tag-derived links",
|
|
456
|
+
description: "Scan the Conductor feature files and report which scenarios are tagged to which user story, plus problems: stories with no tagged scenario, and @US-xxx tags pointing at unknown stories.",
|
|
457
|
+
inputSchema: {},
|
|
458
|
+
}, async (_args, store) => {
|
|
459
|
+
await ensureInit(store);
|
|
460
|
+
const [{ root, index }, stories] = await Promise.all([loadConductorIndex(store), store.listStories()]);
|
|
461
|
+
const knownIds = new Set(stories.map((s) => s.id));
|
|
462
|
+
const byStory = scenariosByStory(index);
|
|
463
|
+
const links = stories.map((s) => ({
|
|
464
|
+
story: s.id,
|
|
465
|
+
title: s.title,
|
|
466
|
+
scenarios: (byStory.get(s.id) ?? []).map((sc) => ({ feature: sc.feature, name: sc.name, file: sc.file })),
|
|
467
|
+
}));
|
|
468
|
+
return json({
|
|
469
|
+
conductorRoot: root,
|
|
470
|
+
scenariosIndexed: index.scenarios.length,
|
|
471
|
+
links,
|
|
472
|
+
storiesWithoutScenario: links.filter((l) => l.scenarios.length === 0).map((l) => l.story),
|
|
473
|
+
danglingTags: danglingStoryTags(index, knownIds),
|
|
474
|
+
});
|
|
475
|
+
});
|
|
476
|
+
// ===========================================================================
|
|
477
|
+
// Executions (test results per phase)
|
|
478
|
+
// ===========================================================================
|
|
479
|
+
tool("record_execution", {
|
|
480
|
+
title: "Record a test execution",
|
|
481
|
+
description: "Record one scenario result against a phase (default: active). Validated against the Conductor project. Use for ad-hoc results or corrections; use import_execution_report for whole cucumber runs.",
|
|
482
|
+
inputSchema: {
|
|
483
|
+
feature: z.string().describe("Feature name (the `Feature:` line)."),
|
|
484
|
+
name: z.string().describe("Scenario name (the `Scenario:` line)."),
|
|
485
|
+
status: TestStatus,
|
|
486
|
+
phase: z.string().regex(/^PHASE-\d+$/).optional().describe("Defaults to the active phase."),
|
|
487
|
+
runId: z.string().optional(),
|
|
488
|
+
note: z.string().optional(),
|
|
489
|
+
},
|
|
490
|
+
}, async (args, store) => {
|
|
491
|
+
await ensureInit(store);
|
|
492
|
+
const phaseId = await store.resolvePhaseId(args.phase);
|
|
493
|
+
if (!phaseId)
|
|
494
|
+
return fail("No phase to record against. Create one with create_phase.");
|
|
495
|
+
if (!(await store.getPhase(phaseId)))
|
|
496
|
+
return fail(`Phase ${phaseId} not found.`);
|
|
497
|
+
const { root, index } = await loadConductorIndex(store);
|
|
498
|
+
const result = validateTestRef({ feature: args.feature, name: args.name }, index);
|
|
499
|
+
if (!result.ok)
|
|
500
|
+
return fail(result.reason ?? "Test does not resolve.", { conductorRoot: root, suggestions: result.suggestions });
|
|
501
|
+
const exec = {
|
|
502
|
+
feature: args.feature,
|
|
503
|
+
name: args.name,
|
|
504
|
+
status: args.status,
|
|
505
|
+
ranAt: now(),
|
|
506
|
+
runId: args.runId,
|
|
507
|
+
source: "manual",
|
|
508
|
+
note: args.note,
|
|
509
|
+
};
|
|
510
|
+
await store.appendExecutions(phaseId, [exec]);
|
|
511
|
+
return json({ phase: phaseId, recorded: exec });
|
|
512
|
+
});
|
|
513
|
+
tool("import_execution_report", {
|
|
514
|
+
title: "Import Conductor cucumber-json report",
|
|
515
|
+
description: "Parse a Conductor cucumber-js JSON result file and record one execution per scenario into a phase (default: active). Reports how many scenarios are tagged to a story. Path defaults to config.conductorReportPath.",
|
|
516
|
+
inputSchema: {
|
|
517
|
+
filePath: z.string().optional().describe("Path to the cucumber-json file. Defaults to config.conductorReportPath."),
|
|
518
|
+
phase: z.string().regex(/^PHASE-\d+$/).optional(),
|
|
519
|
+
runId: z.string().optional().describe("Run identifier stamped on every imported execution."),
|
|
520
|
+
},
|
|
521
|
+
}, async (args, store) => {
|
|
522
|
+
await ensureInit(store);
|
|
523
|
+
const cfg = await store.readConfig();
|
|
524
|
+
const rel = args.filePath ?? cfg.conductorReportPath;
|
|
525
|
+
if (!rel)
|
|
526
|
+
return fail("No report path. Pass filePath or set conductorReportPath in init_project.");
|
|
527
|
+
const file = store.resolvePath(rel);
|
|
528
|
+
let content;
|
|
529
|
+
try {
|
|
530
|
+
content = await fs.readFile(file, "utf8");
|
|
531
|
+
}
|
|
532
|
+
catch {
|
|
533
|
+
return fail(`Cannot read report file: ${file}`);
|
|
534
|
+
}
|
|
535
|
+
let scenarios;
|
|
536
|
+
try {
|
|
537
|
+
scenarios = parseCucumberJson(content);
|
|
538
|
+
}
|
|
539
|
+
catch (e) {
|
|
540
|
+
return fail(`Failed to parse report: ${e.message}`);
|
|
541
|
+
}
|
|
542
|
+
const phaseId = await store.resolvePhaseId(args.phase);
|
|
543
|
+
if (!phaseId)
|
|
544
|
+
return fail("No phase to record against. Create one with create_phase.");
|
|
545
|
+
if (!(await store.getPhase(phaseId)))
|
|
546
|
+
return fail(`Phase ${phaseId} not found.`);
|
|
547
|
+
const ranAt = now();
|
|
548
|
+
const execs = scenarios.map((s) => ({
|
|
549
|
+
feature: s.feature,
|
|
550
|
+
name: s.name,
|
|
551
|
+
status: s.status,
|
|
552
|
+
ranAt,
|
|
553
|
+
runId: args.runId,
|
|
554
|
+
source: "cucumber-json",
|
|
555
|
+
}));
|
|
556
|
+
await store.appendExecutions(phaseId, execs);
|
|
557
|
+
const { index } = await loadConductorIndex(store);
|
|
558
|
+
const linked = linkedScenarioKeys(index);
|
|
559
|
+
const matched = execs.filter((e) => linked.has(testKey(e)));
|
|
560
|
+
const counts = {
|
|
561
|
+
pass: execs.filter((e) => e.status === "pass").length,
|
|
562
|
+
fail: execs.filter((e) => e.status === "fail").length,
|
|
563
|
+
pending: execs.filter((e) => e.status === "pending").length,
|
|
564
|
+
};
|
|
565
|
+
return json({
|
|
566
|
+
phase: phaseId,
|
|
567
|
+
file,
|
|
568
|
+
scenariosParsed: execs.length,
|
|
569
|
+
counts,
|
|
570
|
+
taggedToAStory: matched.length,
|
|
571
|
+
untaggedScenarios: execs
|
|
572
|
+
.filter((e) => !linked.has(testKey(e)))
|
|
573
|
+
.map((e) => ({ feature: e.feature, name: e.name, status: e.status })),
|
|
574
|
+
});
|
|
575
|
+
});
|
|
576
|
+
// ===========================================================================
|
|
577
|
+
// Reporting (phase- and mode-aware, story-level)
|
|
578
|
+
// ===========================================================================
|
|
579
|
+
async function resolveForReport(store, phase, mode) {
|
|
580
|
+
const [reqs, stories, phases, execByPhase, { index }] = await Promise.all([
|
|
581
|
+
store.listRequirements(),
|
|
582
|
+
store.listStories(),
|
|
583
|
+
store.listPhases(),
|
|
584
|
+
store.readAllExecutions(),
|
|
585
|
+
loadConductorIndex(store),
|
|
586
|
+
]);
|
|
587
|
+
const phaseId = await store.resolvePhaseId(phase);
|
|
588
|
+
const m = mode ?? "cumulative";
|
|
589
|
+
const status = resolveStatuses(execByPhase, phases, phaseId, m);
|
|
590
|
+
const byStory = scenariosByStory(index);
|
|
591
|
+
return { reqs, stories, phases, byStory, status, phaseId, mode: m };
|
|
592
|
+
}
|
|
593
|
+
tool("coverage_report", {
|
|
594
|
+
title: "Coverage report",
|
|
595
|
+
description: "Story-level coverage for a phase: requirement → story → tagged scenarios, per-component breakdown, summary %. mode='cumulative' (latest result as of the phase) or 'strict' (only this phase's runs). Defaults to active phase, cumulative.",
|
|
596
|
+
inputSchema: {
|
|
597
|
+
phase: z.string().regex(/^PHASE-\d+$/).optional(),
|
|
598
|
+
mode: CoverageMode.optional(),
|
|
599
|
+
format: z.enum(["json", "markdown"]).optional(),
|
|
600
|
+
},
|
|
601
|
+
}, async (args, store) => {
|
|
602
|
+
await ensureInit(store);
|
|
603
|
+
const { reqs, stories, byStory, status, phaseId, mode } = await resolveForReport(store, args.phase, args.mode);
|
|
604
|
+
const report = buildReport(reqs, stories, byStory, status, phaseId, mode);
|
|
605
|
+
if (args.format === "markdown") {
|
|
606
|
+
const phases = await store.listPhases();
|
|
607
|
+
const name = phases.find((p) => p.id === phaseId)?.name ?? "(none)";
|
|
608
|
+
return text(renderMarkdown(report, name));
|
|
609
|
+
}
|
|
610
|
+
return json(report);
|
|
611
|
+
});
|
|
612
|
+
tool("coverage_trend", {
|
|
613
|
+
title: "Coverage evolution by phase",
|
|
614
|
+
description: "The evolution view: coverage summary at each phase, in order. mode='cumulative' or 'strict'.",
|
|
615
|
+
inputSchema: { mode: CoverageMode.optional() },
|
|
616
|
+
}, async (args, store) => {
|
|
617
|
+
await ensureInit(store);
|
|
618
|
+
const [reqs, stories, phases, execByPhase, { index }] = await Promise.all([
|
|
619
|
+
store.listRequirements(),
|
|
620
|
+
store.listStories(),
|
|
621
|
+
store.listPhases(),
|
|
622
|
+
store.readAllExecutions(),
|
|
623
|
+
loadConductorIndex(store),
|
|
624
|
+
]);
|
|
625
|
+
const trend = buildTrend(reqs, stories, scenariosByStory(index), execByPhase, phases, args.mode ?? "cumulative");
|
|
626
|
+
return json({ mode: args.mode ?? "cumulative", points: trend });
|
|
627
|
+
});
|
|
628
|
+
tool("find_gaps", {
|
|
629
|
+
title: "Find coverage gaps",
|
|
630
|
+
description: "For a phase: active requirements with no story, stories with no tagged scenario, and stories not covered (with failing/not-run scenarios). Defaults to active phase, cumulative.",
|
|
631
|
+
inputSchema: { phase: z.string().regex(/^PHASE-\d+$/).optional(), mode: CoverageMode.optional() },
|
|
632
|
+
}, async (args, store) => {
|
|
633
|
+
await ensureInit(store);
|
|
634
|
+
const { reqs, stories, byStory, status, phaseId, mode } = await resolveForReport(store, args.phase, args.mode);
|
|
635
|
+
return json(findGaps(reqs, stories, byStory, status, phaseId, mode));
|
|
636
|
+
});
|
|
637
|
+
function renderMarkdown(report, phaseName) {
|
|
638
|
+
const s = report.summary;
|
|
639
|
+
const lines = [];
|
|
640
|
+
lines.push(`# Requirements Coverage — ${phaseName} (${report.mode})`, "");
|
|
641
|
+
lines.push("## Summary", "");
|
|
642
|
+
lines.push(`- Requirements (active): **${s.requirementsTotal}**`);
|
|
643
|
+
lines.push(`- With a story: **${s.requirementsWithStory}/${s.requirementsTotal}** (${s.storyCoveragePct}%)`);
|
|
644
|
+
lines.push(`- Verified (all stories covered): **${s.requirementsVerified}/${s.requirementsTotal}** (${s.verifiedPct}%)`);
|
|
645
|
+
lines.push(`- Stories covered: **${s.storiesCovered}/${s.storiesTotal}** (tested: ${s.storiesTested})`);
|
|
646
|
+
lines.push(`- Scenarios passing: **${s.scenariosPassing}/${s.scenariosLinked}**`, "");
|
|
647
|
+
if (report.byComponent.length) {
|
|
648
|
+
lines.push("## By component", "");
|
|
649
|
+
for (const c of report.byComponent)
|
|
650
|
+
lines.push(`- **${c.component}** — verified ${c.verified}/${c.requirements} (${c.verifiedPct}%)`);
|
|
651
|
+
lines.push("");
|
|
652
|
+
}
|
|
653
|
+
lines.push("## Requirements", "");
|
|
654
|
+
for (const r of report.requirements) {
|
|
655
|
+
const mark = r.verified ? "✅" : r.hasStory ? "🟡" : "❌";
|
|
656
|
+
const comp = r.components.length ? ` _[${r.components.join(", ")}]_` : "";
|
|
657
|
+
lines.push(`- ${mark} **${r.id}** ${r.title}${comp} — stories: ${r.storyIds.join(", ") || "none"}`);
|
|
658
|
+
}
|
|
659
|
+
lines.push("", "## Stories", "");
|
|
660
|
+
for (const st of report.stories) {
|
|
661
|
+
const mark = st.covered ? "✅" : !st.tested ? "❌" : "🟡";
|
|
662
|
+
lines.push(`- ${mark} **${st.id}** ${st.title} — ${st.passing}/${st.scenarios.length} scenarios pass (${st.status})`);
|
|
663
|
+
for (const sc of st.scenarios) {
|
|
664
|
+
const cm = sc.status === "pass" ? "✓" : sc.status === "fail" ? "✗" : "·";
|
|
665
|
+
lines.push(` - ${cm} ${sc.feature} :: ${sc.name} — ${sc.status}`);
|
|
666
|
+
}
|
|
667
|
+
if (!st.tested)
|
|
668
|
+
lines.push(` - _(no scenarios tagged @${st.id})_`);
|
|
669
|
+
}
|
|
670
|
+
return lines.join("\n");
|
|
671
|
+
}
|
|
672
|
+
// ===========================================================================
|
|
673
|
+
// Boot
|
|
674
|
+
// ===========================================================================
|
|
675
|
+
async function main() {
|
|
676
|
+
const transport = new StdioServerTransport();
|
|
677
|
+
await server.connect(transport);
|
|
678
|
+
console.error("requ-mcp running (per-call project resolution).");
|
|
679
|
+
}
|
|
680
|
+
main().catch((err) => {
|
|
681
|
+
console.error("requ-mcp fatal:", err);
|
|
682
|
+
process.exit(1);
|
|
683
|
+
});
|
|
684
|
+
//# sourceMappingURL=index.js.map
|