Skip to content

Commit 2f17508

Browse files
committed
feat(mcp): add ingest_coverage and query baseline parity
Close the last two agent-relevant transport gaps: MCP/HTTP can load coverage artifacts and diff queries against saved baselines in one call, matching CLI ingest-coverage and query --baseline.
1 parent 2e1e5e0 commit 2f17508

15 files changed

Lines changed: 873 additions & 178 deletions

docs/plans/ingest-coverage-mcp.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Ingest coverage — MCP/HTTP parity — plan
2+
3+
> **Status:** open · **Priority:** P1 (transport parity) · **Effort:** S (~1 day)
4+
>
5+
> **Motivator:** Coverage-aware recipes (`worst-covered-exports`, `files-by-coverage`, `untested-and-dead`) need `coverage` table rows. CLI has `codemap ingest-coverage`; MCP-only agents cannot load artifacts without shelling out.
6+
>
7+
> **Roadmap:** transport parity (agent-relevant core)
8+
9+
---
10+
11+
## Pre-locked decisions
12+
13+
| # | Decision | Source |
14+
| --- | ------------------------------------------------------------------------------------------------------ | -------------------------- |
15+
| I.1 | **Thin transport twin** — same `IngestResult` envelope as CLI `--json`; no new semantics. | Transport-agnostic engines |
16+
| I.2 | **Lift orchestration** to `application/ingest-coverage-run.ts`; CLI `cmd-ingest-coverage.ts` calls it. | `architecture.md` layering |
17+
| I.3 | **Tool name** `ingest_coverage` (snake_case MCP); HTTP `POST /tool/ingest_coverage`. | MCP convention |
18+
| I.4 | **Args:** `path` (required), `runtime` (optional, V8 dir), mirrors CLI flags. | CLI parity |
19+
| I.5 | **No human text mode** on MCP — JSON only (infra output format excluded). | Agent transport |
20+
21+
---
22+
23+
## Implementation steps
24+
25+
1. Extract `runIngestCoverage` + path resolvers from `cmd-ingest-coverage.ts``ingest-coverage-run.ts`
26+
2. `handleIngestCoverage` in `tool-handlers.ts`
27+
3. Register MCP tool + HTTP dispatch + `MCP_TOOL_NAMES` allowlist
28+
4. Tests: `ingest-coverage-run.test.ts` (minimal), `tool-handlers.test.ts` smoke
29+
5. Update `cmd-mcp.ts` help, `mcp-instructions.md`, `architecture.md` apply/coverage wiring
30+
31+
---
32+
33+
## Acceptance
34+
35+
- [x] MCP `ingest_coverage` ingests Istanbul fixture → `coverage` rows queryable
36+
- [x] CLI `ingest-coverage` unchanged behavior (refactor only)
37+
- [x] HTTP `POST /tool/ingest_coverage` returns same JSON as CLI `--json`
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Query baseline compare — MCP/HTTP parity — plan
2+
3+
> **Status:** open · **Priority:** P1 (transport parity) · **Effort:** S (~1 day)
4+
>
5+
> **Motivator:** CLI `codemap query --baseline=<name>` diffs current rows vs `query_baselines` in one call. MCP has `save_baseline` / `list_baselines` / `drop_baseline` but not inline compare on `query` / `query_recipe`.
6+
>
7+
> **Roadmap:** transport parity (agent-relevant core)
8+
9+
---
10+
11+
## Pre-locked decisions
12+
13+
| # | Decision | Source |
14+
| --- | ------------------------------------------------------------------------------------------------------------------------------ | -------------------------------- |
15+
| B.1 | **Extend existing tools** — add optional `baseline: string` to `query` and `query_recipe`; no new tool. | Moat A — recipes stay the API |
16+
| B.2 | **Same envelope** as CLI `--json --baseline`: `{baseline, current_row_count, added, removed}`; `summary: true` → count fields. | `cmd-query.ts` `runBaselineDiff` |
17+
| B.3 | **Recipe `actions` on `added` rows only** — same as CLI. | Apply discover loop |
18+
| B.4 | **Reject combos**`baseline` + `format` (sarif/annotations/mermaid/diff/diff-json) or `group_by`; mirrors CLI parser. | Output-shape contract |
19+
| B.5 | **Engine**`application/query-baseline.ts` (`compareQueryBaseline`); shared by MCP handlers (CLI refactor optional later). | Layering |
20+
21+
---
22+
23+
## Implementation steps
24+
25+
1. Add `compareQueryBaseline` in `application/query-baseline.ts` (uses `diffRows`, `getQueryBaseline`, `filterRowsByChangedFiles`)
26+
2. Wire `baseline?: string` into `queryArgsSchema` / `queryRecipeArgsSchema`
27+
3. Early branch in `handleQuery` / `handleQueryRecipe` before `executeQuery` / formatted paths
28+
4. Tests: `query-baseline.test.ts` + `tool-handlers.test.ts` baseline diff case
29+
5. Update MCP tool descriptions + `templates/agent-content/mcp-instructions.md`
30+
31+
---
32+
33+
## Acceptance
34+
35+
- [x] `query_recipe` + `baseline` + `summary` returns `{added: N, removed: N}` counts
36+
- [x] `query_recipe` + `baseline` returns full diff with `actions` on `added` when recipe declares them
37+
- [x] Missing baseline name → `{error}` envelope
38+
- [x] `baseline` + `format: sarif` → error (incompatible)

src/application/http-server.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import {
4141
handleAffected,
4242
handleContext,
4343
handleDropBaseline,
44+
handleIngestCoverage,
4445
handleExplore,
4546
handleImpact,
4647
handleNode,
@@ -54,6 +55,7 @@ import {
5455
handleSnippet,
5556
handleValidate,
5657
impactArgsSchema,
58+
ingestCoverageArgsSchema,
5759
nodeArgsSchema,
5860
traceArgsSchema,
5961
listBaselinesArgsSchema,
@@ -126,6 +128,7 @@ const TOOL_NAMES = [
126128
"save_baseline",
127129
"list_baselines",
128130
"drop_baseline",
131+
"ingest_coverage",
129132
] as const;
130133

131134
/**
@@ -609,6 +612,12 @@ async function dispatchTool(
609612
result = handleDropBaseline(r.value);
610613
break;
611614
}
615+
case "ingest_coverage": {
616+
const r = validate(ingestCoverageArgsSchema, args, "ingest_coverage");
617+
if (!r.ok) return writeJson(res, 400, { error: r.error }, opts.version);
618+
result = await handleIngestCoverage(r.value, opts.root);
619+
break;
620+
}
612621
default: {
613622
// Reachable only if TOOL_NAMES gains an entry without a switch arm —
614623
// the route guard above catches user-typed unknown names.
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
6+
import { resolveCodemapConfig } from "../config";
7+
import {
8+
closeDb,
9+
createIndexes,
10+
createTables,
11+
insertFile,
12+
insertSymbols,
13+
} from "../db";
14+
import { initCodemap } from "../runtime";
15+
import { openCodemapDatabase } from "../sqlite-db";
16+
import {
17+
resolveCoverageArtifact,
18+
runIngestCoverageOnDb,
19+
} from "./ingest-coverage-run";
20+
21+
let projectRoot: string;
22+
23+
beforeEach(() => {
24+
projectRoot = mkdtempSync(join(tmpdir(), "ingest-coverage-run-"));
25+
initCodemap(resolveCodemapConfig(projectRoot, undefined));
26+
});
27+
28+
afterEach(() => {
29+
rmSync(projectRoot, { recursive: true, force: true });
30+
});
31+
32+
describe("resolveCoverageArtifact", () => {
33+
it("resolves istanbul file by extension", () => {
34+
const file = join(projectRoot, "coverage-final.json");
35+
writeFileSync(file, "{}");
36+
expect(resolveCoverageArtifact(file, projectRoot)).toEqual({
37+
format: "istanbul",
38+
absPath: file,
39+
});
40+
});
41+
42+
it("errors when directory holds both istanbul and lcov", () => {
43+
const dir = join(projectRoot, "coverage");
44+
mkdirSync(dir);
45+
writeFileSync(join(dir, "coverage-final.json"), "{}");
46+
writeFileSync(join(dir, "lcov.info"), "TN:\n");
47+
expect(() => resolveCoverageArtifact(dir, projectRoot)).toThrow(
48+
/both coverage-final\.json and lcov\.info/,
49+
);
50+
});
51+
});
52+
53+
describe("runIngestCoverageOnDb", () => {
54+
it("ingests istanbul artifact into coverage table", async () => {
55+
const db = openCodemapDatabase(":memory:");
56+
try {
57+
createTables(db);
58+
createIndexes(db);
59+
insertFile(db, {
60+
path: "src/lib/cache.ts",
61+
content_hash: "h1",
62+
size: 1,
63+
line_count: 100,
64+
language: "typescript",
65+
last_modified: 0,
66+
indexed_at: 0,
67+
});
68+
insertSymbols(db, [
69+
{
70+
file_path: "src/lib/cache.ts",
71+
name: "get",
72+
kind: "function",
73+
line_start: 9,
74+
line_end: 15,
75+
signature: "get(): void",
76+
is_exported: 1,
77+
is_default_export: 0,
78+
members: null,
79+
doc_comment: null,
80+
value: null,
81+
parent_name: null,
82+
visibility: null,
83+
},
84+
]);
85+
86+
const coverageDir = join(projectRoot, "coverage");
87+
mkdirSync(coverageDir);
88+
const artifact = join(coverageDir, "coverage-final.json");
89+
writeFileSync(
90+
artifact,
91+
JSON.stringify({
92+
[`${projectRoot}/src/lib/cache.ts`]: {
93+
path: `${projectRoot}/src/lib/cache.ts`,
94+
statementMap: {
95+
"0": {
96+
start: { line: 10, column: 0 },
97+
end: { line: 10, column: 1 },
98+
},
99+
},
100+
s: { "0": 1 },
101+
},
102+
}),
103+
);
104+
105+
const outcome = await runIngestCoverageOnDb(db, {
106+
projectRoot,
107+
path: "coverage/coverage-final.json",
108+
});
109+
expect(outcome.ok).toBe(true);
110+
if (!outcome.ok) return;
111+
expect(outcome.result.format).toBe("istanbul");
112+
expect(outcome.result.ingested.symbols).toBe(1);
113+
114+
const rows = db.query("SELECT name FROM coverage").all() as Array<{
115+
name: string;
116+
}>;
117+
expect(rows.map((r) => r.name)).toEqual(["get"]);
118+
} finally {
119+
closeDb(db);
120+
}
121+
});
122+
});

0 commit comments

Comments
 (0)