From 2f175089c8cf64481a1df14dd48b72f4dc66406d Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Fri, 5 Jun 2026 09:43:13 +0300 Subject: [PATCH 1/5] 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. --- docs/plans/ingest-coverage-mcp.md | 37 ++++ docs/plans/query-baseline-mcp-parity.md | 38 +++++ src/application/http-server.ts | 9 + src/application/ingest-coverage-run.test.ts | 122 ++++++++++++++ src/application/ingest-coverage-run.ts | 176 ++++++++++++++++++++ src/application/mcp-server.ts | 23 ++- src/application/mcp-tool-allowlist.ts | 1 + src/application/query-baseline.test.ts | 119 +++++++++++++ src/application/query-baseline.ts | 138 +++++++++++++++ src/application/tool-handlers.test.ts | 95 +++++++++++ src/application/tool-handlers.ts | 73 ++++++++ src/cli/bootstrap.ts | 2 +- src/cli/cmd-ingest-coverage.ts | 172 +++---------------- src/cli/cmd-mcp.ts | 3 +- templates/agent-content/mcp-instructions.md | 43 ++--- 15 files changed, 873 insertions(+), 178 deletions(-) create mode 100644 docs/plans/ingest-coverage-mcp.md create mode 100644 docs/plans/query-baseline-mcp-parity.md create mode 100644 src/application/ingest-coverage-run.test.ts create mode 100644 src/application/ingest-coverage-run.ts create mode 100644 src/application/query-baseline.test.ts create mode 100644 src/application/query-baseline.ts diff --git a/docs/plans/ingest-coverage-mcp.md b/docs/plans/ingest-coverage-mcp.md new file mode 100644 index 00000000..1e3b9882 --- /dev/null +++ b/docs/plans/ingest-coverage-mcp.md @@ -0,0 +1,37 @@ +# Ingest coverage — MCP/HTTP parity — plan + +> **Status:** open · **Priority:** P1 (transport parity) · **Effort:** S (~1 day) +> +> **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. +> +> **Roadmap:** transport parity (agent-relevant core) + +--- + +## Pre-locked decisions + +| # | Decision | Source | +| --- | ------------------------------------------------------------------------------------------------------ | -------------------------- | +| I.1 | **Thin transport twin** — same `IngestResult` envelope as CLI `--json`; no new semantics. | Transport-agnostic engines | +| I.2 | **Lift orchestration** to `application/ingest-coverage-run.ts`; CLI `cmd-ingest-coverage.ts` calls it. | `architecture.md` layering | +| I.3 | **Tool name** `ingest_coverage` (snake_case MCP); HTTP `POST /tool/ingest_coverage`. | MCP convention | +| I.4 | **Args:** `path` (required), `runtime` (optional, V8 dir), mirrors CLI flags. | CLI parity | +| I.5 | **No human text mode** on MCP — JSON only (infra output format excluded). | Agent transport | + +--- + +## Implementation steps + +1. Extract `runIngestCoverage` + path resolvers from `cmd-ingest-coverage.ts` → `ingest-coverage-run.ts` +2. `handleIngestCoverage` in `tool-handlers.ts` +3. Register MCP tool + HTTP dispatch + `MCP_TOOL_NAMES` allowlist +4. Tests: `ingest-coverage-run.test.ts` (minimal), `tool-handlers.test.ts` smoke +5. Update `cmd-mcp.ts` help, `mcp-instructions.md`, `architecture.md` apply/coverage wiring + +--- + +## Acceptance + +- [x] MCP `ingest_coverage` ingests Istanbul fixture → `coverage` rows queryable +- [x] CLI `ingest-coverage` unchanged behavior (refactor only) +- [x] HTTP `POST /tool/ingest_coverage` returns same JSON as CLI `--json` diff --git a/docs/plans/query-baseline-mcp-parity.md b/docs/plans/query-baseline-mcp-parity.md new file mode 100644 index 00000000..7ced723a --- /dev/null +++ b/docs/plans/query-baseline-mcp-parity.md @@ -0,0 +1,38 @@ +# Query baseline compare — MCP/HTTP parity — plan + +> **Status:** open · **Priority:** P1 (transport parity) · **Effort:** S (~1 day) +> +> **Motivator:** CLI `codemap query --baseline=` 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`. +> +> **Roadmap:** transport parity (agent-relevant core) + +--- + +## Pre-locked decisions + +| # | Decision | Source | +| --- | ------------------------------------------------------------------------------------------------------------------------------ | -------------------------------- | +| B.1 | **Extend existing tools** — add optional `baseline: string` to `query` and `query_recipe`; no new tool. | Moat A — recipes stay the API | +| B.2 | **Same envelope** as CLI `--json --baseline`: `{baseline, current_row_count, added, removed}`; `summary: true` → count fields. | `cmd-query.ts` `runBaselineDiff` | +| B.3 | **Recipe `actions` on `added` rows only** — same as CLI. | Apply discover loop | +| B.4 | **Reject combos** — `baseline` + `format` (sarif/annotations/mermaid/diff/diff-json) or `group_by`; mirrors CLI parser. | Output-shape contract | +| B.5 | **Engine** — `application/query-baseline.ts` (`compareQueryBaseline`); shared by MCP handlers (CLI refactor optional later). | Layering | + +--- + +## Implementation steps + +1. Add `compareQueryBaseline` in `application/query-baseline.ts` (uses `diffRows`, `getQueryBaseline`, `filterRowsByChangedFiles`) +2. Wire `baseline?: string` into `queryArgsSchema` / `queryRecipeArgsSchema` +3. Early branch in `handleQuery` / `handleQueryRecipe` before `executeQuery` / formatted paths +4. Tests: `query-baseline.test.ts` + `tool-handlers.test.ts` baseline diff case +5. Update MCP tool descriptions + `templates/agent-content/mcp-instructions.md` + +--- + +## Acceptance + +- [x] `query_recipe` + `baseline` + `summary` returns `{added: N, removed: N}` counts +- [x] `query_recipe` + `baseline` returns full diff with `actions` on `added` when recipe declares them +- [x] Missing baseline name → `{error}` envelope +- [x] `baseline` + `format: sarif` → error (incompatible) diff --git a/src/application/http-server.ts b/src/application/http-server.ts index 00c02dbe..b6c10370 100644 --- a/src/application/http-server.ts +++ b/src/application/http-server.ts @@ -41,6 +41,7 @@ import { handleAffected, handleContext, handleDropBaseline, + handleIngestCoverage, handleExplore, handleImpact, handleNode, @@ -54,6 +55,7 @@ import { handleSnippet, handleValidate, impactArgsSchema, + ingestCoverageArgsSchema, nodeArgsSchema, traceArgsSchema, listBaselinesArgsSchema, @@ -126,6 +128,7 @@ const TOOL_NAMES = [ "save_baseline", "list_baselines", "drop_baseline", + "ingest_coverage", ] as const; /** @@ -609,6 +612,12 @@ async function dispatchTool( result = handleDropBaseline(r.value); break; } + case "ingest_coverage": { + const r = validate(ingestCoverageArgsSchema, args, "ingest_coverage"); + if (!r.ok) return writeJson(res, 400, { error: r.error }, opts.version); + result = await handleIngestCoverage(r.value, opts.root); + break; + } default: { // Reachable only if TOOL_NAMES gains an entry without a switch arm — // the route guard above catches user-typed unknown names. diff --git a/src/application/ingest-coverage-run.test.ts b/src/application/ingest-coverage-run.test.ts new file mode 100644 index 00000000..55744b10 --- /dev/null +++ b/src/application/ingest-coverage-run.test.ts @@ -0,0 +1,122 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { resolveCodemapConfig } from "../config"; +import { + closeDb, + createIndexes, + createTables, + insertFile, + insertSymbols, +} from "../db"; +import { initCodemap } from "../runtime"; +import { openCodemapDatabase } from "../sqlite-db"; +import { + resolveCoverageArtifact, + runIngestCoverageOnDb, +} from "./ingest-coverage-run"; + +let projectRoot: string; + +beforeEach(() => { + projectRoot = mkdtempSync(join(tmpdir(), "ingest-coverage-run-")); + initCodemap(resolveCodemapConfig(projectRoot, undefined)); +}); + +afterEach(() => { + rmSync(projectRoot, { recursive: true, force: true }); +}); + +describe("resolveCoverageArtifact", () => { + it("resolves istanbul file by extension", () => { + const file = join(projectRoot, "coverage-final.json"); + writeFileSync(file, "{}"); + expect(resolveCoverageArtifact(file, projectRoot)).toEqual({ + format: "istanbul", + absPath: file, + }); + }); + + it("errors when directory holds both istanbul and lcov", () => { + const dir = join(projectRoot, "coverage"); + mkdirSync(dir); + writeFileSync(join(dir, "coverage-final.json"), "{}"); + writeFileSync(join(dir, "lcov.info"), "TN:\n"); + expect(() => resolveCoverageArtifact(dir, projectRoot)).toThrow( + /both coverage-final\.json and lcov\.info/, + ); + }); +}); + +describe("runIngestCoverageOnDb", () => { + it("ingests istanbul artifact into coverage table", async () => { + const db = openCodemapDatabase(":memory:"); + try { + createTables(db); + createIndexes(db); + insertFile(db, { + path: "src/lib/cache.ts", + content_hash: "h1", + size: 1, + line_count: 100, + language: "typescript", + last_modified: 0, + indexed_at: 0, + }); + insertSymbols(db, [ + { + file_path: "src/lib/cache.ts", + name: "get", + kind: "function", + line_start: 9, + line_end: 15, + signature: "get(): void", + is_exported: 1, + is_default_export: 0, + members: null, + doc_comment: null, + value: null, + parent_name: null, + visibility: null, + }, + ]); + + const coverageDir = join(projectRoot, "coverage"); + mkdirSync(coverageDir); + const artifact = join(coverageDir, "coverage-final.json"); + writeFileSync( + artifact, + JSON.stringify({ + [`${projectRoot}/src/lib/cache.ts`]: { + path: `${projectRoot}/src/lib/cache.ts`, + statementMap: { + "0": { + start: { line: 10, column: 0 }, + end: { line: 10, column: 1 }, + }, + }, + s: { "0": 1 }, + }, + }), + ); + + const outcome = await runIngestCoverageOnDb(db, { + projectRoot, + path: "coverage/coverage-final.json", + }); + expect(outcome.ok).toBe(true); + if (!outcome.ok) return; + expect(outcome.result.format).toBe("istanbul"); + expect(outcome.result.ingested.symbols).toBe(1); + + const rows = db.query("SELECT name FROM coverage").all() as Array<{ + name: string; + }>; + expect(rows.map((r) => r.name)).toEqual(["get"]); + } finally { + closeDb(db); + } + }); +}); diff --git a/src/application/ingest-coverage-run.ts b/src/application/ingest-coverage-run.ts new file mode 100644 index 00000000..a70f6426 --- /dev/null +++ b/src/application/ingest-coverage-run.ts @@ -0,0 +1,176 @@ +import { existsSync, readdirSync, statSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { isAbsolute, join, resolve } from "node:path"; + +import type { CodemapDatabase } from "../sqlite-db"; +import { ingestIstanbul, ingestLcov, ingestV8 } from "./coverage-engine"; +import type { + CoverageFormat, + IngestResult, + IstanbulPayload, + V8CoveragePayload, + V8ScriptCoverage, +} from "./coverage-engine"; + +const ISTANBUL_FILENAME = "coverage-final.json"; +const LCOV_FILENAME = "lcov.info"; +const V8_FILENAME_RE = /^coverage-.*\.json$/; + +export interface IngestCoverageRunOpts { + projectRoot: string; + /** User path (relative to projectRoot or absolute). */ + path: string; + runtime?: boolean; +} + +export interface IngestCoverageRunOk { + ok: true; + result: IngestResult; + sourcePath: string; +} + +export interface IngestCoverageRunError { + ok: false; + error: string; +} + +/** + * Resolve the user-supplied path to a concrete (artifact, format) pair. + * Directory inputs probe for `coverage-final.json` and `lcov.info`; + * presence of both is an explicit error (no precedence guessing). + */ +export function resolveCoverageArtifact( + inputPath: string, + cwd: string, +): { format: CoverageFormat; absPath: string } { + const abs = isAbsolute(inputPath) ? inputPath : resolve(cwd, inputPath); + if (!existsSync(abs)) { + throw new Error(`codemap ingest-coverage: path not found: ${abs}`); + } + const stat = statSync(abs); + if (stat.isDirectory()) { + const istanbul = join(abs, ISTANBUL_FILENAME); + const lcov = join(abs, LCOV_FILENAME); + const hasIstanbul = existsSync(istanbul); + const hasLcov = existsSync(lcov); + if (hasIstanbul && hasLcov) { + throw new Error( + `codemap ingest-coverage: directory ${abs} contains both ${ISTANBUL_FILENAME} and ${LCOV_FILENAME}. Pass the file path explicitly.`, + ); + } + if (hasIstanbul) return { format: "istanbul", absPath: istanbul }; + if (hasLcov) return { format: "lcov", absPath: lcov }; + throw new Error( + `codemap ingest-coverage: directory ${abs} contains neither ${ISTANBUL_FILENAME} nor ${LCOV_FILENAME}.`, + ); + } + if (abs.endsWith(".json")) return { format: "istanbul", absPath: abs }; + if (abs.endsWith(".info")) return { format: "lcov", absPath: abs }; + throw new Error( + `codemap ingest-coverage: cannot auto-detect format from "${abs}". Expected a .json (Istanbul) or .info (LCOV) file, or a directory containing one.`, + ); +} + +export function resolveV8CoverageDirectory( + inputPath: string, + cwd: string, +): { absDir: string; jsonFiles: string[] } { + const abs = isAbsolute(inputPath) ? inputPath : resolve(cwd, inputPath); + if (!existsSync(abs)) { + throw new Error(`codemap ingest-coverage: path not found: ${abs}`); + } + const stat = statSync(abs); + if (!stat.isDirectory()) { + throw new Error( + `codemap ingest-coverage --runtime: expected a directory (NODE_V8_COVERAGE-style), got file ${abs}`, + ); + } + const jsonFiles = readdirSync(abs) + .filter((f) => V8_FILENAME_RE.test(f)) + .map((f) => join(abs, f)); + if (jsonFiles.length === 0) { + throw new Error( + `codemap ingest-coverage --runtime: directory ${abs} contains no coverage-*.json files. NODE_V8_COVERAGE writes coverage---.json — point --runtime at the directory the test runner wrote to.`, + ); + } + return { absDir: abs, jsonFiles }; +} + +async function readJsonFile(filePath: string): Promise { + if (typeof Bun !== "undefined") { + return Bun.file(filePath).json(); + } + const text = await readFile(filePath, "utf-8"); + return JSON.parse(text) as unknown; +} + +async function readTextFile(filePath: string): Promise { + if (typeof Bun !== "undefined") { + return Bun.file(filePath).text(); + } + return readFile(filePath, "utf-8"); +} + +/** Transport-agnostic ingest — caller owns `openDb` / bootstrap. */ +export async function runIngestCoverageOnDb( + db: CodemapDatabase, + opts: IngestCoverageRunOpts, +): Promise { + try { + let result: IngestResult; + let sourcePath: string; + if (opts.runtime) { + const { absDir, jsonFiles } = resolveV8CoverageDirectory( + opts.path, + opts.projectRoot, + ); + sourcePath = absDir; + const scripts: V8ScriptCoverage[] = []; + for (const file of jsonFiles) { + const payload = (await readJsonFile(file)) as V8CoveragePayload; + if (Array.isArray(payload?.result)) scripts.push(...payload.result); + } + if (scripts.length === 0) { + return { + ok: false, + error: `codemap ingest-coverage --runtime: ${jsonFiles.length} coverage-*.json file(s) under ${absDir} contained no V8 \`result\` arrays. Confirm the directory is the one NODE_V8_COVERAGE wrote to.`, + }; + } + result = ingestV8({ + db, + projectRoot: opts.projectRoot, + scripts, + sourcePath: absDir, + }); + } else { + const { format, absPath } = resolveCoverageArtifact( + opts.path, + opts.projectRoot, + ); + sourcePath = absPath; + if (format === "istanbul") { + const payload = (await readJsonFile(absPath)) as IstanbulPayload; + result = ingestIstanbul({ + db, + projectRoot: opts.projectRoot, + payload, + sourcePath: absPath, + }); + } else { + const payload = await readTextFile(absPath); + result = ingestLcov({ + db, + projectRoot: opts.projectRoot, + payload, + sourcePath: absPath, + }); + } + } + return { ok: true, result, sourcePath }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + }; + } +} diff --git a/src/application/mcp-server.ts b/src/application/mcp-server.ts index 77ebffaf..ab4412d4 100644 --- a/src/application/mcp-server.ts +++ b/src/application/mcp-server.ts @@ -49,6 +49,7 @@ import { handleAffected, handleContext, handleDropBaseline, + handleIngestCoverage, exploreArgsSchema, handleExplore, handleImpact, @@ -65,6 +66,7 @@ import { handleSnippet, handleValidate, impactArgsSchema, + ingestCoverageArgsSchema, listBaselinesArgsSchema, queryArgsSchema, queryBatchArgsSchema, @@ -86,7 +88,7 @@ import { * MCP server engine — owns the tool / resource registry. CLI shell * (`src/cli/cmd-mcp.ts`) handles argv + lifecycle only; this module is * the thin wrapper around `@modelcontextprotocol/sdk` that registers - * 19 JSON-RPC tools (CLI mirrors plus MCP/HTTP resource URIs) and MCP resources + * 20 JSON-RPC tools (CLI mirrors plus MCP/HTTP resource URIs) and MCP resources * (static + templates). Tool bodies are pure handlers in * `application/tool-handlers.ts` — same handlers `codemap serve` (HTTP) * dispatches. See [`docs/architecture.md` § MCP wiring]. @@ -175,6 +177,9 @@ export function createMcpServer(opts: ServerOpts): McpServer { maybeRegister("save_baseline", () => registerSaveBaselineTool(server, opts)); maybeRegister("list_baselines", () => registerListBaselinesTool(server)); maybeRegister("drop_baseline", () => registerDropBaselineTool(server)); + maybeRegister("ingest_coverage", () => + registerIngestCoverageTool(server, opts), + ); maybeRegister("show", () => registerShowTool(server, opts)); maybeRegister("snippet", () => registerSnippetTool(server, opts)); maybeRegister("impact", () => registerImpactTool(server)); @@ -198,7 +203,7 @@ function registerQueryTool(server: McpServer, opts: ServerOpts): void { "query", { description: - 'Run one read-only SQL statement against the codemap index (default `.codemap/index.db`). Returns the JSON envelope `codemap query --json` would print: row array by default, {count} under `summary`, {group_by, groups} under `group_by`. Pass `format: "sarif"` / `"annotations"` / `"mermaid"` / `"diff"` / `"diff-json"` to receive a formatted payload (incompatible with `summary` / `group_by`). Mermaid requires `{from, to, label?, kind?}` rows; diff requires `{file_path, line_start, before_pattern, after_pattern}` rows.', + 'Run one read-only SQL statement against the codemap index (default `.codemap/index.db`). Returns the JSON envelope `codemap query --json` would print: row array by default, {count} under `summary`, {group_by, groups} under `group_by`, baseline diff under `baseline` (incompatible with non-json `format` / `group_by`). Pass `format: "sarif"` / `"annotations"` / `"mermaid"` / `"diff"` / `"diff-json"` to receive a formatted payload (incompatible with `summary` / `group_by` / `baseline`). Mermaid requires `{from, to, label?, kind?}` rows; diff requires `{file_path, line_start, before_pattern, after_pattern}` rows.', inputSchema: queryArgsSchema, }, (args) => wrapToolResult(handleQuery(args, opts.root)), @@ -210,7 +215,7 @@ function registerQueryRecipeTool(server: McpServer, opts: ServerOpts): void { "query_recipe", { description: - 'Run a recipe by id (bundled or project-local). Output rows carry per-row `actions` hints (recipe-only — `query` never adds them). Parametrised recipes accept `params: {key: value}` validated against recipe frontmatter. Compose with `summary` / `changed_since` / `group_by` exactly like `query`. Pass `format: "sarif"` / `"annotations"` / `"mermaid"` / `"diff"` / `"diff-json"` to receive a formatted payload (incompatible with `summary` / `group_by`); SARIF rule id derives from the recipe id (`codemap.`). List available recipes via the `codemap://recipes` resource.', + 'Run a recipe by id (bundled or project-local). Output rows carry per-row `actions` hints (recipe-only — `query` never adds them). Parametrised recipes accept `params: {key: value}` validated against recipe frontmatter. Compose with `summary` / `changed_since` / `group_by` / `baseline` exactly like `query` (`baseline` adds `actions` on `added` rows only). Pass `format: "sarif"` / `"annotations"` / `"mermaid"` / `"diff"` / `"diff-json"` to receive a formatted payload (incompatible with `summary` / `group_by` / `baseline`); SARIF rule id derives from the recipe id (`codemap.`). List available recipes via the `codemap://recipes` resource.', inputSchema: queryRecipeArgsSchema, }, (args) => wrapToolResult(handleQueryRecipe(args, opts.root)), @@ -289,6 +294,18 @@ function registerListBaselinesTool(server: McpServer): void { ); } +function registerIngestCoverageTool(server: McpServer, opts: ServerOpts): void { + server.registerTool( + "ingest_coverage", + { + description: + "Ingest a coverage artifact (Istanbul JSON, LCOV, or NODE_V8_COVERAGE directory with `runtime: true`) into the index `coverage` table. Same JSON envelope as `codemap ingest-coverage --json`. Enables coverage-aware recipes (`worst-covered-exports`, `files-by-coverage`, `untested-and-dead`). Args: `path` (required), `runtime` (optional).", + inputSchema: ingestCoverageArgsSchema, + }, + async (args) => wrapToolResult(await handleIngestCoverage(args, opts.root)), + ); +} + function registerDropBaselineTool(server: McpServer): void { server.registerTool( "drop_baseline", diff --git a/src/application/mcp-tool-allowlist.ts b/src/application/mcp-tool-allowlist.ts index bbc22d85..17d6a9cb 100644 --- a/src/application/mcp-tool-allowlist.ts +++ b/src/application/mcp-tool-allowlist.ts @@ -23,6 +23,7 @@ export const MCP_TOOL_NAMES = [ "apply", "apply_rows", "apply_diff_input", + "ingest_coverage", ] as const; export type McpToolName = (typeof MCP_TOOL_NAMES)[number]; diff --git a/src/application/query-baseline.test.ts b/src/application/query-baseline.test.ts new file mode 100644 index 00000000..99ee0eae --- /dev/null +++ b/src/application/query-baseline.test.ts @@ -0,0 +1,119 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { resolveCodemapConfig } from "../config"; +import { closeDb, createTables, openDb, upsertQueryBaseline } from "../db"; +import { initCodemap } from "../runtime"; +import { + baselineQueryIncompatibility, + compareQueryBaseline, +} from "./query-baseline"; + +let projectRoot: string; + +beforeEach(() => { + projectRoot = mkdtempSync(join(tmpdir(), "query-baseline-")); + initCodemap(resolveCodemapConfig(projectRoot, undefined)); + const db = openDb(); + try { + createTables(db); + db.run( + "INSERT INTO files (path, content_hash, size, line_count, language, last_modified, indexed_at) VALUES ('src/a.ts', 'h1', 10, 1, 'typescript', 1, 1)", + ); + db.run( + "INSERT INTO symbols (file_path, name, kind, line_start, line_end, signature, is_exported, is_default_export, members, doc_comment, value, parent_name, visibility, complexity) VALUES ('src/a.ts', 'foo', 'function', 1, 1, 'foo()', 1, 0, NULL, NULL, NULL, NULL, NULL, 1)", + ); + upsertQueryBaseline(db, { + name: "symbols", + recipe_id: null, + sql: "SELECT name FROM symbols ORDER BY name", + rows_json: JSON.stringify([{ name: "bar" }]), + row_count: 1, + git_ref: null, + created_at: 1, + }); + } finally { + closeDb(db); + } +}); + +afterEach(() => { + rmSync(projectRoot, { recursive: true, force: true }); +}); + +describe("compareQueryBaseline", () => { + it("returns full diff with added and removed rows", () => { + const payload = compareQueryBaseline({ + baselineName: "symbols", + sql: "SELECT name FROM symbols ORDER BY name", + }); + expect("error" in payload).toBe(false); + if ("error" in payload) return; + expect(payload.baseline.name).toBe("symbols"); + expect(payload.current_row_count).toBe(1); + expect(payload.added).toEqual([{ name: "foo" }]); + expect(payload.removed).toEqual([{ name: "bar" }]); + }); + + it("summary mode returns counts only", () => { + const payload = compareQueryBaseline({ + baselineName: "symbols", + sql: "SELECT name FROM symbols ORDER BY name", + summary: true, + }); + expect("error" in payload).toBe(false); + if ("error" in payload) return; + expect(payload.added).toBe(1); + expect(payload.removed).toBe(1); + }); + + it("errors on missing baseline", () => { + const payload = compareQueryBaseline({ + baselineName: "missing", + sql: "SELECT 1", + }); + expect(payload).toMatchObject({ + error: expect.stringContaining('no baseline named "missing"'), + }); + }); + + it("attaches recipe actions on added rows only", () => { + const actions = [{ type: "inspect", description: "review" }]; + const payload = compareQueryBaseline({ + baselineName: "symbols", + sql: "SELECT name FROM symbols ORDER BY name", + recipeActions: actions, + }); + expect("error" in payload).toBe(false); + if ("error" in payload) return; + if (!Array.isArray(payload.added) || !Array.isArray(payload.removed)) + return; + expect(payload.added[0]).toMatchObject({ + name: "foo", + actions, + }); + expect(payload.removed[0]).not.toHaveProperty("actions"); + }); +}); + +describe("baselineQueryIncompatibility", () => { + it("allows baseline with json format", () => { + expect( + baselineQueryIncompatibility({ baseline: "x", format: "json" }), + ).toBeUndefined(); + }); + + it("rejects baseline + sarif", () => { + expect( + baselineQueryIncompatibility({ baseline: "x", format: "sarif" }), + ).toMatch(/cannot be combined with format=sarif/); + }); + + it("rejects baseline + group_by", () => { + expect( + baselineQueryIncompatibility({ baseline: "x", group_by: "file_path" }), + ).toMatch(/cannot be combined with group_by/); + }); +}); diff --git a/src/application/query-baseline.ts b/src/application/query-baseline.ts new file mode 100644 index 00000000..d502ca6d --- /dev/null +++ b/src/application/query-baseline.ts @@ -0,0 +1,138 @@ +import { closeDb, getQueryBaseline, openDb } from "../db"; +import { diffRows } from "../diff-rows"; +import { filterRowsByChangedFiles } from "../git-changed"; +import type { QueryBindValue } from "./query-engine"; + +export interface QueryBaselineMeta { + name: string; + recipe_id: string | null; + row_count: number; + git_ref: string | null; + created_at: number; +} + +export interface QueryBaselineDiffPayload { + baseline: QueryBaselineMeta; + current_row_count: number; + added: unknown[]; + removed: unknown[]; +} + +export interface QueryBaselineDiffSummaryPayload { + baseline: QueryBaselineMeta; + current_row_count: number; + added: number; + removed: number; +} + +export interface QueryBaselineError { + error: string; +} + +function attachActions(row: unknown, actions: ReadonlyArray): unknown { + if (typeof row !== "object" || row === null) return row; + const obj = row as Record; + if ("actions" in obj) return obj; + return { ...obj, actions }; +} + +/** + * Diff current query rows against a saved `query_baselines` snapshot. + * Mirrors CLI `codemap query --baseline=`. + */ +export function compareQueryBaseline(opts: { + baselineName: string; + sql: string; + bindValues?: QueryBindValue[]; + changedFiles?: Set; + summary?: boolean; + recipeActions?: ReadonlyArray; +}): + | QueryBaselineDiffPayload + | QueryBaselineDiffSummaryPayload + | QueryBaselineError { + const db = openDb(); + let baselineRow: ReturnType; + try { + db.run("PRAGMA query_only = 1"); + baselineRow = getQueryBaseline(db, opts.baselineName); + if (baselineRow === undefined) { + return { + error: `codemap: no baseline named "${opts.baselineName}". Use list_baselines for the catalog.`, + }; + } + + let baselineRows: unknown[]; + try { + baselineRows = JSON.parse(baselineRow.rows_json) as unknown[]; + } catch { + return { + error: `codemap: baseline "${opts.baselineName}" has corrupt rows_json — drop and re-save.`, + }; + } + + let currentRows: unknown[]; + try { + currentRows = db + .query(opts.sql) + .all(...(opts.bindValues ?? [])) as unknown[]; + } catch (err) { + return { + error: err instanceof Error ? err.message : String(err), + }; + } + + if (opts.changedFiles !== undefined) { + currentRows = filterRowsByChangedFiles(currentRows, opts.changedFiles); + } + + const { added, removed } = diffRows(baselineRows, currentRows); + const enrichedAdded = + opts.recipeActions !== undefined && opts.recipeActions.length > 0 + ? added.map((row) => attachActions(row, opts.recipeActions!)) + : added; + + const meta: QueryBaselineMeta = { + name: baselineRow.name, + recipe_id: baselineRow.recipe_id, + row_count: baselineRow.row_count, + git_ref: baselineRow.git_ref, + created_at: baselineRow.created_at, + }; + + if (opts.summary) { + return { + baseline: meta, + current_row_count: currentRows.length, + added: added.length, + removed: removed.length, + }; + } + + return { + baseline: meta, + current_row_count: currentRows.length, + added: enrichedAdded, + removed, + }; + } finally { + closeDb(db, { readonly: true }); + } +} + +/** Reject baseline + formatted output or group_by (CLI parser parity). */ +export function baselineQueryIncompatibility(args: { + baseline?: string; + format?: string; + group_by?: string; + summary?: boolean; +}): string | undefined { + if (args.baseline === undefined) return undefined; + const offenders: string[] = []; + if (args.format !== undefined && args.format !== "json") { + offenders.push(`format=${args.format}`); + } + if (args.group_by !== undefined) offenders.push("group_by"); + if (offenders.length === 0) return undefined; + return `codemap: baseline cannot be combined with ${offenders.join(", ")} (different output shapes).`; +} diff --git a/src/application/tool-handlers.test.ts b/src/application/tool-handlers.test.ts index 6d36a160..9a2417ba 100644 --- a/src/application/tool-handlers.test.ts +++ b/src/application/tool-handlers.test.ts @@ -13,12 +13,15 @@ import { join } from "node:path"; import { resolveCodemapConfig } from "../config"; import { closeDb, createTables, insertFile, openDb } from "../db"; +import { upsertQueryBaseline } from "../db"; import { initCodemap } from "../runtime"; import { handleApply, handleApplyDiffInput, handleApplyRows, handleContext, + handleIngestCoverage, + handleQuery, handleQueryRecipe, handleShow, handleSnippet, @@ -48,6 +51,98 @@ afterEach(() => { rmSync(projectRoot, { recursive: true, force: true }); }); +describe("handleQuery baseline", () => { + it("diffs against a saved baseline", () => { + const db = openDb(); + try { + upsertQueryBaseline(db, { + name: "pre", + recipe_id: null, + sql: "SELECT name FROM symbols", + rows_json: JSON.stringify([]), + row_count: 0, + git_ref: null, + created_at: 1, + }); + } finally { + closeDb(db); + } + const result = handleQuery( + { + sql: "SELECT name FROM symbols", + baseline: "pre", + summary: true, + }, + projectRoot, + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.payload).toMatchObject({ + baseline: { name: "pre" }, + added: 1, + removed: 0, + }); + }); + + it("rejects baseline + format=sarif", () => { + const result = handleQuery( + { sql: "SELECT 1", baseline: "pre", format: "sarif" }, + projectRoot, + ); + expect(result).toMatchObject({ + ok: false, + error: expect.stringContaining("cannot be combined with format=sarif"), + }); + }); +}); + +describe("handleQueryRecipe baseline", () => { + it("diffs recipe rows with actions on added", () => { + const db = openDb(); + try { + upsertQueryBaseline(db, { + name: "funcs", + recipe_id: "find-symbol-by-kind", + sql: "SELECT name FROM symbols WHERE kind = 'function'", + rows_json: JSON.stringify([]), + row_count: 0, + git_ref: null, + created_at: 1, + }); + } finally { + closeDb(db); + } + const result = handleQueryRecipe( + { + recipe: "find-symbol-by-kind", + params: { kind: "function", name_pattern: "%Query%" }, + baseline: "funcs", + }, + projectRoot, + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + const payload = result.payload as { + added: Array<{ name: string; actions?: unknown[] }>; + }; + expect(payload.added).toHaveLength(1); + expect(payload.added[0]?.actions).toBeDefined(); + }); +}); + +describe("handleIngestCoverage", () => { + it("returns error when path is missing on disk", async () => { + const result = await handleIngestCoverage( + { path: "no-such/coverage-final.json" }, + projectRoot, + ); + expect(result).toMatchObject({ + ok: false, + error: expect.stringContaining("path not found"), + }); + }); +}); + describe("handleQueryRecipe params", () => { it("binds nested params object for query_recipe", () => { const result = handleQueryRecipe( diff --git a/src/application/tool-handlers.ts b/src/application/tool-handlers.ts index 0137e08d..5d3238a6 100644 --- a/src/application/tool-handlers.ts +++ b/src/application/tool-handlers.ts @@ -52,6 +52,7 @@ import { buildContextEnvelope } from "./context-engine"; import { findImpact } from "./impact-engine"; import type { ImpactBackend, ImpactDirection } from "./impact-engine"; import { getCurrentCommit } from "./index-engine"; +import { runIngestCoverageOnDb } from "./ingest-coverage-run"; import { formatAnnotations, formatDiff, @@ -59,6 +60,10 @@ import { formatMermaid, formatSarif, } from "./output-formatters"; +import { + baselineQueryIncompatibility, + compareQueryBaseline, +} from "./query-baseline"; import { executeQuery } from "./query-engine"; import { getQueryRecipeActionsRendered, @@ -194,6 +199,7 @@ export const queryArgsSchema = { changed_since: z.string().optional(), group_by: groupByEnum.optional(), format: formatEnum.optional(), + baseline: z.string().min(1).optional(), }; export interface QueryArgs { @@ -202,15 +208,29 @@ export interface QueryArgs { changed_since?: string; group_by?: GroupByMode; format?: "json" | "sarif" | "annotations" | "mermaid" | "diff" | "diff-json"; + baseline?: string; } export function handleQuery(args: QueryArgs, root: string): ToolResult { try { + const baselineIncompat = baselineQueryIncompatibility(args); + if (baselineIncompat !== undefined) return err(baselineIncompat); + const resolveChanged = makeChangedFilesResolver(root); const changed = resolveChanged(args.changed_since); if (changed && typeof changed === "object" && "error" in changed) { return err(changed.error); } + if (args.baseline !== undefined) { + const payload = compareQueryBaseline({ + baselineName: args.baseline, + sql: args.sql, + changedFiles: changed as Set | undefined, + summary: args.summary, + }); + if ("error" in payload) return err(payload.error); + return ok(payload); + } if ( args.format === "sarif" || args.format === "annotations" || @@ -251,6 +271,7 @@ export const queryRecipeArgsSchema = { changed_since: z.string().optional(), group_by: groupByEnum.optional(), format: formatEnum.optional(), + baseline: z.string().min(1).optional(), params: z .record(z.string(), z.union([z.string(), z.number(), z.boolean()])) .optional(), @@ -262,6 +283,7 @@ export interface QueryRecipeArgs { changed_since?: string; group_by?: GroupByMode; format?: "json" | "sarif" | "annotations" | "mermaid" | "diff" | "diff-json"; + baseline?: string; params?: RecipeParamValues; } @@ -270,6 +292,9 @@ export function handleQueryRecipe( root: string, ): ToolResult { try { + const baselineIncompat = baselineQueryIncompatibility(args); + if (baselineIncompat !== undefined) return err(baselineIncompat); + const sql = getQueryRecipeSql(args.recipe); if (sql === undefined) { return err( @@ -292,6 +317,19 @@ export function handleQueryRecipe( if (changed && typeof changed === "object" && "error" in changed) { return err(changed.error); } + if (args.baseline !== undefined) { + const payload = compareQueryBaseline({ + baselineName: args.baseline, + sql, + bindValues: resolvedParams.values, + changedFiles: changed as Set | undefined, + summary: args.summary, + recipeActions, + }); + if ("error" in payload) return err(payload.error); + tryRecordRecipeRun(args.recipe); + return ok(payload); + } if ( args.format === "sarif" || args.format === "annotations" || @@ -1168,6 +1206,41 @@ export function handleApplyRows(args: ApplyRowsArgs, root: string): ToolResult { } } +// === ingest_coverage ======================================================== + +export const ingestCoverageArgsSchema = { + path: z.string().min(1, "path must be a non-empty string"), + runtime: z.boolean().optional(), +}; + +export interface IngestCoverageArgs { + path: string; + runtime?: boolean; +} + +export async function handleIngestCoverage( + args: IngestCoverageArgs, + root: string, +): Promise { + try { + const db = openDb(); + let outcome: Awaited>; + try { + outcome = await runIngestCoverageOnDb(db, { + projectRoot: root, + path: args.path, + runtime: args.runtime, + }); + } finally { + closeDb(db); + } + if (!outcome.ok) return err(outcome.error); + return ok(outcome.result); + } catch (e) { + return err(e instanceof Error ? e.message : String(e), 500); + } +} + // === shared format helpers =================================================== /** diff --git a/src/cli/bootstrap.ts b/src/cli/bootstrap.ts index 995a448b..4c05fcd6 100644 --- a/src/cli/bootstrap.ts +++ b/src/cli/bootstrap.ts @@ -41,7 +41,7 @@ PR comment renderer (audit/SARIF → markdown summary): codemap pr-comment [--shape audit|sarif] [--json] # - for stdin MCP server (Model Context Protocol — for agent hosts): - codemap mcp # stdio JSON-RPC (19 tools; watcher default-ON) + codemap mcp # stdio JSON-RPC (20 tools; watcher default-ON) # CLI parity: query batch, trace, explore, node, file, schema, symbols, context --include-snippets HTTP server (for non-MCP consumers — CI scripts, curl, IDE plugins): diff --git a/src/cli/cmd-ingest-coverage.ts b/src/cli/cmd-ingest-coverage.ts index 3853f0a3..21319be7 100644 --- a/src/cli/cmd-ingest-coverage.ts +++ b/src/cli/cmd-ingest-coverage.ts @@ -1,19 +1,5 @@ -import { existsSync, readdirSync, statSync } from "node:fs"; -import { readFile } from "node:fs/promises"; -import { isAbsolute, join, resolve } from "node:path"; - -import { - ingestIstanbul, - ingestLcov, - ingestV8, -} from "../application/coverage-engine"; -import type { - CoverageFormat, - IngestResult, - IstanbulPayload, - V8CoveragePayload, - V8ScriptCoverage, -} from "../application/coverage-engine"; +import type { IngestResult } from "../application/coverage-engine"; +import { runIngestCoverageOnDb } from "../application/ingest-coverage-run"; import { closeDb, openDb } from "../db"; import { bootstrapCodemap } from "./bootstrap-codemap"; @@ -21,16 +7,11 @@ interface IngestCoverageOpts { root: string; configFile: string | undefined; stateDir?: string | undefined; - /** Resolved absolute path to coverage-final.json, lcov.info, or a directory. */ path: string; json: boolean; - /** Treat as a NODE_V8_COVERAGE directory (one or more `coverage-*.json` files). */ runtime: boolean; } -const ISTANBUL_FILENAME = "coverage-final.json"; -const LCOV_FILENAME = "lcov.info"; - export function printIngestCoverageCmdHelp(): void { console.log(`Usage: codemap ingest-coverage [--runtime] [--json] @@ -118,152 +99,39 @@ export function parseIngestCoverageRest( return { kind: "run", path, json, runtime }; } -/** - * Resolve the user-supplied path to a concrete (artifact, format) pair. - * Directory inputs probe for `coverage-final.json` and `lcov.info`; - * presence of both is an explicit error per the plan ("no precedence - * guessing — explicit is better than implicit"). - */ -function resolveArtifact( - inputPath: string, - cwd: string, -): { format: CoverageFormat; absPath: string } { - const abs = isAbsolute(inputPath) ? inputPath : resolve(cwd, inputPath); - if (!existsSync(abs)) { - throw new Error(`codemap ingest-coverage: path not found: ${abs}`); - } - const stat = statSync(abs); - if (stat.isDirectory()) { - const istanbul = join(abs, ISTANBUL_FILENAME); - const lcov = join(abs, LCOV_FILENAME); - const hasIstanbul = existsSync(istanbul); - const hasLcov = existsSync(lcov); - if (hasIstanbul && hasLcov) { - throw new Error( - `codemap ingest-coverage: directory ${abs} contains both ${ISTANBUL_FILENAME} and ${LCOV_FILENAME}. Pass the file path explicitly.`, - ); - } - if (hasIstanbul) return { format: "istanbul", absPath: istanbul }; - if (hasLcov) return { format: "lcov", absPath: lcov }; - throw new Error( - `codemap ingest-coverage: directory ${abs} contains neither ${ISTANBUL_FILENAME} nor ${LCOV_FILENAME}.`, - ); - } - if (abs.endsWith(".json")) return { format: "istanbul", absPath: abs }; - if (abs.endsWith(".info")) return { format: "lcov", absPath: abs }; - throw new Error( - `codemap ingest-coverage: cannot auto-detect format from "${abs}". Expected a .json (Istanbul) or .info (LCOV) file, or a directory containing one.`, - ); -} - -/** Filters to NODE_V8_COVERAGE's `coverage---.json` shape so a wrong dir errors loudly instead of producing a zero-row "successful" ingest. */ -const V8_FILENAME_RE = /^coverage-.*\.json$/; - -function resolveV8Directory( - inputPath: string, - cwd: string, -): { absDir: string; jsonFiles: string[] } { - const abs = isAbsolute(inputPath) ? inputPath : resolve(cwd, inputPath); - if (!existsSync(abs)) { - throw new Error(`codemap ingest-coverage: path not found: ${abs}`); - } - const stat = statSync(abs); - if (!stat.isDirectory()) { - throw new Error( - `codemap ingest-coverage --runtime: expected a directory (NODE_V8_COVERAGE-style), got file ${abs}`, - ); - } - const jsonFiles = readdirSync(abs) - .filter((f) => V8_FILENAME_RE.test(f)) - .map((f) => join(abs, f)); - if (jsonFiles.length === 0) { - throw new Error( - `codemap ingest-coverage --runtime: directory ${abs} contains no coverage-*.json files. NODE_V8_COVERAGE writes coverage---.json — point --runtime at the directory the test runner wrote to.`, - ); - } - return { absDir: abs, jsonFiles }; -} - -/** - * Read a JSON file via the canonical Node-vs-Bun split — Bun.file().json() - * uses Bun's native parser (materially faster on multi-MB Istanbul payloads); - * Node falls through to readFile + JSON.parse. Mirrors `config.ts`. - * See docs/packaging.md § Node vs Bun. - */ -async function readJsonFile(filePath: string): Promise { - if (typeof Bun !== "undefined") { - return Bun.file(filePath).json(); - } - const text = await readFile(filePath, "utf-8"); - return JSON.parse(text) as unknown; -} - -async function readTextFile(filePath: string): Promise { - if (typeof Bun !== "undefined") { - return Bun.file(filePath).text(); - } - return readFile(filePath, "utf-8"); -} - export async function runIngestCoverageCmd( opts: IngestCoverageOpts, ): Promise { try { await bootstrapCodemap(opts); - let result: IngestResult; - let displayPath: string; const db = openDb(); + let outcome: Awaited>; try { - if (opts.runtime) { - const { absDir, jsonFiles } = resolveV8Directory(opts.path, opts.root); - displayPath = absDir; - const scripts: V8ScriptCoverage[] = []; - for (const file of jsonFiles) { - const payload = (await readJsonFile(file)) as V8CoveragePayload; - if (Array.isArray(payload?.result)) scripts.push(...payload.result); - } - if (scripts.length === 0) { - throw new Error( - `codemap ingest-coverage --runtime: ${jsonFiles.length} coverage-*.json file(s) under ${absDir} contained no V8 \`result\` arrays. Confirm the directory is the one NODE_V8_COVERAGE wrote to.`, - ); - } - result = ingestV8({ - db, - projectRoot: opts.root, - scripts, - sourcePath: absDir, - }); - } else { - const { format, absPath } = resolveArtifact(opts.path, opts.root); - displayPath = absPath; - if (format === "istanbul") { - const payload = (await readJsonFile(absPath)) as IstanbulPayload; - result = ingestIstanbul({ - db, - projectRoot: opts.root, - payload, - sourcePath: absPath, - }); - } else { - const payload = await readTextFile(absPath); - result = ingestLcov({ - db, - projectRoot: opts.root, - payload, - sourcePath: absPath, - }); - } - } + outcome = await runIngestCoverageOnDb(db, { + projectRoot: opts.root, + path: opts.path, + runtime: opts.runtime, + }); } finally { closeDb(db); } + if (!outcome.ok) { + if (opts.json) { + console.log(JSON.stringify({ error: outcome.error })); + } else { + console.error(outcome.error); + } + process.exitCode = 1; + return; + } + if (opts.json) { - console.log(JSON.stringify(result)); + console.log(JSON.stringify(outcome.result)); return; } - renderTerminal(result, displayPath); + renderTerminal(outcome.result, outcome.sourcePath); } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (opts.json) { diff --git a/src/cli/cmd-mcp.ts b/src/cli/cmd-mcp.ts index a2512254..9a8b6e06 100644 --- a/src/cli/cmd-mcp.ts +++ b/src/cli/cmd-mcp.ts @@ -85,7 +85,7 @@ Spawns an MCP (Model Context Protocol) server on stdio. Designed to be launched by an agent host (Claude Code, Cursor, Codex, generic MCP clients) — JSON-RPC on stdin/stdout, logs on stderr. -Tools (19; snake_case — mirrors CLI verbs where a shell twin exists): +Tools (20; snake_case — mirrors CLI verbs where a shell twin exists): query One read-only SQL statement. query_batch N statements in one round-trip (CLI: codemap query batch). query_recipe Recipe by id (bundled or project-local); per-row \`actions\` hints. @@ -93,6 +93,7 @@ Tools (19; snake_case — mirrors CLI verbs where a shell twin exists): save_baseline Snapshot rows under a name (sql or recipe). list_baselines Catalog of saved baselines. drop_baseline Delete a baseline. + ingest_coverage Load Istanbul/LCOV/V8 coverage into the index. context Project bootstrap envelope. validate On-disk hash vs indexed hash. show Symbol metadata: file:line + signature. diff --git a/templates/agent-content/mcp-instructions.md b/templates/agent-content/mcp-instructions.md index b7f7bdc1..baa983a9 100644 --- a/templates/agent-content/mcp-instructions.md +++ b/templates/agent-content/mcp-instructions.md @@ -29,27 +29,28 @@ Key fields: `pending_sync` (watcher debounce queue or in-flight reindex), `commi ## Common tasks -| Goal | MCP tool | Recipe twin (`query_recipe`) | -| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Exact symbol lookup | **`show`** (`name`, optional `in`) | `find-symbol-definitions` | -| Field-qualified symbol discovery | **`show`** or **`snippet`** (`query` with `kind:` / `name:` / `path:` / `in:` + free text) | `find-symbol-by-kind` for kind-heavy patterns; CLI `codemap show --query '…' --print-sql` to inspect generated SQL (no MCP `print_sql` arg) | -| Kind / pattern lookup | **`query_recipe`** | `find-symbol-by-kind` | -| Source at symbol | **`snippet`** | same rows as `show` + disk text | -| Blast radius | **`impact`** (`target`, `direction`, `via`, `depth`) | `fan-in` for file hubs; symbol call graph via SQL or `impact` | -| Call path + snippets | **`trace`** (`from`, `to`, `via?`, `max_depth?`, `budget_chars?`) — adaptive snippet caps 15k/10k/6k when omitted | `call-path` | -| Type extends / implements chain | **`query_recipe`** | `type-ancestors`, `type-descendants` (`file_path` when homonyms; on `type-descendants` also scopes output to that file) | -| Multi-symbol survey | **`explore`** (`names`, `depth?`, `kind?`, `budget_chars?`) — row cap always adaptive (500/250/125); snippets 15k/10k/6k when `budget_chars` omitted | `symbol-neighborhood` (once per name) | -| One-hop symbol card | **`node`** (`name`, `kind?`, `in?`, `include_snippets?`, `budget_chars?`) — adaptive snippet caps when snippets enabled | `show` + `symbol-neighborhood` with `depth=1` | -| Affected tests | **`affected`** (`paths?`, `changed_since?`, `test_glob?`, `max_depth?`) | `affected-tests` (RS-delimit multiple paths in `query_recipe` params) | -| CI / SARIF | **`query_recipe`** + `format: "sarif"` | `deprecated-symbols`, `boundary-violations`, … | -| Ad-hoc SQL | **`query`** | — | -| N statements / one round-trip | **`query_batch`** | **`codemap query batch`** | -| Index freshness (index-level) | **`context`** (`index_freshness`) + tool metadata above | — | -| Per-file staleness | **`validate`** | — | -| Drift vs baseline | **`audit`** (`baseline_prefix` and/or per-delta `baselines`) | save via **`save_baseline`**; CLI-only diff via `codemap query --baseline` | -| Apply recipe diff rows | **`apply`** (`recipe`, `params?`, `dry_run?`, `yes?`, `force?`, `until_empty?`, `max_passes?`, `commit_message?`) | recipe must emit `{file_path, line_start, before_pattern, after_pattern}` rows; `yes: true` required for writes; non-`auto_fixable` recipes need `force: true` | -| Apply agent/codemod rows | **`apply_rows`** (`rows`, `dry_run?`, `yes?`) | same row contract; bypasses recipe `auto_fixable` / allowlist gates | -| Apply unified diff text | **`apply_diff_input`** (`diff_text`, `dry_run?`, `yes?`, `commit_message?`) | parses git-style hunks; same executor as `apply_rows` | +| Goal | MCP tool | Recipe twin (`query_recipe`) | +| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Exact symbol lookup | **`show`** (`name`, optional `in`) | `find-symbol-definitions` | +| Field-qualified symbol discovery | **`show`** or **`snippet`** (`query` with `kind:` / `name:` / `path:` / `in:` + free text) | `find-symbol-by-kind` for kind-heavy patterns; CLI `codemap show --query '…' --print-sql` to inspect generated SQL (no MCP `print_sql` arg) | +| Kind / pattern lookup | **`query_recipe`** | `find-symbol-by-kind` | +| Source at symbol | **`snippet`** | same rows as `show` + disk text | +| Blast radius | **`impact`** (`target`, `direction`, `via`, `depth`) | `fan-in` for file hubs; symbol call graph via SQL or `impact` | +| Call path + snippets | **`trace`** (`from`, `to`, `via?`, `max_depth?`, `budget_chars?`) — adaptive snippet caps 15k/10k/6k when omitted | `call-path` | +| Type extends / implements chain | **`query_recipe`** | `type-ancestors`, `type-descendants` (`file_path` when homonyms; on `type-descendants` also scopes output to that file) | +| Multi-symbol survey | **`explore`** (`names`, `depth?`, `kind?`, `budget_chars?`) — row cap always adaptive (500/250/125); snippets 15k/10k/6k when `budget_chars` omitted | `symbol-neighborhood` (once per name) | +| One-hop symbol card | **`node`** (`name`, `kind?`, `in?`, `include_snippets?`, `budget_chars?`) — adaptive snippet caps when snippets enabled | `show` + `symbol-neighborhood` with `depth=1` | +| Affected tests | **`affected`** (`paths?`, `changed_since?`, `test_glob?`, `max_depth?`) | `affected-tests` (RS-delimit multiple paths in `query_recipe` params) | +| CI / SARIF | **`query_recipe`** + `format: "sarif"` | `deprecated-symbols`, `boundary-violations`, … | +| Ad-hoc SQL | **`query`** | — | +| N statements / one round-trip | **`query_batch`** | **`codemap query batch`** | +| Index freshness (index-level) | **`context`** (`index_freshness`) + tool metadata above | — | +| Per-file staleness | **`validate`** | — | +| Drift vs baseline | **`audit`** (`baseline_prefix` and/or per-delta `baselines`) or **`query`** / **`query_recipe`** + `baseline` (one-shot row diff vs `query_baselines`) | save via **`save_baseline`**; `summary: true` → count-only diff | +| Load coverage data | **`ingest_coverage`** (`path`, optional `runtime` for V8 dirs) | enables `worst-covered-exports`, `files-by-coverage`, `untested-and-dead` | +| Apply recipe diff rows | **`apply`** (`recipe`, `params?`, `dry_run?`, `yes?`, `force?`, `until_empty?`, `max_passes?`, `commit_message?`) | recipe must emit `{file_path, line_start, before_pattern, after_pattern}` rows; `yes: true` required for writes; non-`auto_fixable` recipes need `force: true` | +| Apply agent/codemod rows | **`apply_rows`** (`rows`, `dry_run?`, `yes?`) | same row contract; bypasses recipe `auto_fixable` / allowlist gates | +| Apply unified diff text | **`apply_diff_input`** (`diff_text`, `dry_run?`, `yes?`, `commit_message?`) | parses git-style hunks; same executor as `apply_rows` | ## Chains From c25fa2386f838b0c02beec990269c628d0b662ca Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Fri, 5 Jun 2026 10:13:46 +0300 Subject: [PATCH 2/5] =?UTF-8?q?fix(mcp):=20address=20PR=20#167=20review=20?= =?UTF-8?q?cycles=201=E2=80=932?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lift CLI baseline diff onto compareQueryBaseline, sync consumer docs to 20 tools, transport-neutral baseline error hints, and expand ingest/baseline test coverage. --- .changeset/transport-parity-mcp.md | 5 + README.md | 9 +- docs/architecture.md | 62 ++++----- docs/glossary.md | 4 +- src/application/ingest-coverage-run.test.ts | 93 ++++++++++++++ src/application/mcp-server.ts | 6 +- src/application/query-baseline.test.ts | 79 ++++++++++++ src/application/query-baseline.ts | 18 +-- src/application/query-engine.ts | 6 +- src/application/tool-handlers.test.ts | 107 +++++++++++++++- src/application/tool-handlers.ts | 8 +- src/cli/cmd-mcp.ts | 14 +-- src/cli/cmd-query.ts | 118 ++++-------------- templates/agent-content/mcp-instructions.md | 44 +++---- .../agent-content/skill/10-recipes-context.md | 7 +- 15 files changed, 400 insertions(+), 180 deletions(-) create mode 100644 .changeset/transport-parity-mcp.md diff --git a/.changeset/transport-parity-mcp.md b/.changeset/transport-parity-mcp.md new file mode 100644 index 00000000..777b609e --- /dev/null +++ b/.changeset/transport-parity-mcp.md @@ -0,0 +1,5 @@ +--- +"@stainless-code/codemap": patch +--- + +Add MCP/HTTP transport parity for coverage ingest and query baselines: new `ingest_coverage` tool (CLI twin `codemap ingest-coverage --json`) and optional `baseline` param on `query` / `query_recipe` (same diff envelope as `codemap query --baseline`). Tool count 19 → 20. diff --git a/README.md b/README.md index 0f7b570b..7a919657 100644 --- a/README.md +++ b/README.md @@ -231,11 +231,12 @@ codemap skill # full codemap S codemap rule # full codemap rule markdown to stdout # MCP server (Model Context Protocol) — for agent hosts (Claude Code, Cursor, Codex, generic MCP clients) -codemap mcp # JSON-RPC on stdio (19 tools; watcher default-ON) -# Tools (19): query, query_batch, query_recipe, audit, save_baseline, +codemap mcp # JSON-RPC on stdio (20 tools; watcher default-ON) +# Tools (20): query, query_batch, query_recipe, audit, save_baseline, # list_baselines, drop_baseline, context, validate, show, snippet, impact, -# affected, trace, explore, node, apply, apply_rows, apply_diff_input -# CLI twins: query batch, trace, explore, node, file, schema, symbols, context --include-snippets (same JSON as MCP/HTTP). +# affected, trace, explore, node, apply, apply_rows, apply_diff_input, +# ingest_coverage +# CLI twins: query batch, trace, explore, node, file, schema, symbols, context --include-snippets, ingest-coverage (same JSON as MCP/HTTP). # Resources: codemap://schema, codemap://skill, codemap://rule, codemap://mcp-instructions (lazy-cached); # codemap://recipes, codemap://recipes/{id} (live read-per-call — recency fields stay fresh); # codemap://files/{path}, codemap://symbols/{name} (live read-per-call) diff --git a/docs/architecture.md b/docs/architecture.md index ce073741..835df0e0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -16,13 +16,13 @@ A local SQLite database (`.codemap/index.db`) indexes the project tree and store ## Layering -| Layer | Role | -| -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **`cli/`** (`bootstrap`, `main`, `cmd-*`) | Parses argv; **dynamic `import()`** loads only the command chunk (`cmd-index`, `cmd-query`, `cmd-agents`) so `--help` / `version` / `agents init` avoid the indexer. | -| **`api.ts`** | Public programmatic surface: `createCodemap()`, `Codemap` (`query`, `index`), re-exports `runCodemapIndex` for advanced use. | -| **`application/`** | Pure transport-agnostic engines + handlers: `run-index.ts` / `index-engine.ts` (orchestration + indexing); `query-engine.ts` (`executeQuery` / `executeQueryBatch`); `audit-engine.ts` (`runAudit` + `resolveAuditBaselines` + `runAuditFromRef` + `makeWorktreeReindex`); `audit-worktree.ts` (sha-keyed cache + atomic populate); `context-engine.ts` (`buildContextEnvelope` + **`resolveContextBudget`**); `validate-engine.ts` (`computeValidateRows` + `toProjectRelative`); `show-engine.ts` (exact lookup + envelope builders); `search-query-parser.ts` + `search-engine.ts` + `show-search-mode.ts` (field-qualified `--query` search); `impact-engine.ts` (`findImpact` — graph blast-radius walker); `affected-engine.ts` (`resolveAffectedChangedPaths` + `executeAffectedTests` — `affected-tests` recipe composer); `trace-engine.ts` + `output-budget.ts` (**`resolveOutputBudget`** — adaptive snippet caps for trace/explore/node; explore row cap); `apply-engine.ts` (`applyDiffPayload` — substrate-shaped fix executor over the diff-json row contract); `coverage-engine.ts` (`upsertCoverageRows` core + `ingestIstanbul` / `ingestLcov` / `ingestV8` parsers; schema in [§ Schema → coverage](#schema)); `query-recipes.ts` + `recipes-loader.ts` (recipe registry); `output-formatters.ts` (SARIF + GH annotations + Mermaid `flowchart LR` with bounded-input contract); `watcher.ts` (chokidar-backed debounced reindex; pure helpers + injectable backend); `tool-handlers.ts` + `resource-handlers.ts` (transport-agnostic tool / resource handlers shared by MCP + HTTP); `mcp-server.ts` (MCP transport — stdio); `http-server.ts` (HTTP transport — `node:http`). Engines depend on `db.ts` / `runtime.ts`; **never** on `cli/`. | -| **`adapters/`** | `LanguageAdapter` registry; built-ins call `parser.ts` / `css-parser.ts` / `markers.ts` from `parse-worker-core`. | -| **`runtime.ts` / `config.ts` / `db.ts` / …** | Config, SQLite, resolver, workers. | +| Layer | Role | +| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **`cli/`** (`bootstrap`, `main`, `cmd-*`) | Parses argv; **dynamic `import()`** loads only the command chunk (`cmd-index`, `cmd-query`, `cmd-agents`) so `--help` / `version` / `agents init` avoid the indexer. | +| **`api.ts`** | Public programmatic surface: `createCodemap()`, `Codemap` (`query`, `index`), re-exports `runCodemapIndex` for advanced use. | +| **`application/`** | Pure transport-agnostic engines + handlers: `run-index.ts` / `index-engine.ts` (orchestration + indexing); `query-engine.ts` (`executeQuery` / `executeQueryBatch`); `query-baseline.ts` (`compareQueryBaseline` — shared baseline diff for CLI / MCP / HTTP); `ingest-coverage-run.ts` (`runIngestCoverageOnDb` — shared coverage ingest for CLI / MCP / HTTP); `audit-engine.ts` (`runAudit` + `resolveAuditBaselines` + `runAuditFromRef` + `makeWorktreeReindex`); `audit-worktree.ts` (sha-keyed cache + atomic populate); `context-engine.ts` (`buildContextEnvelope` + **`resolveContextBudget`**); `validate-engine.ts` (`computeValidateRows` + `toProjectRelative`); `show-engine.ts` (exact lookup + envelope builders); `search-query-parser.ts` + `search-engine.ts` + `show-search-mode.ts` (field-qualified `--query` search); `impact-engine.ts` (`findImpact` — graph blast-radius walker); `affected-engine.ts` (`resolveAffectedChangedPaths` + `executeAffectedTests` — `affected-tests` recipe composer); `trace-engine.ts` + `output-budget.ts` (**`resolveOutputBudget`** — adaptive snippet caps for trace/explore/node; explore row cap); `apply-engine.ts` (`applyDiffPayload` — substrate-shaped fix executor over the diff-json row contract); `coverage-engine.ts` (`upsertCoverageRows` core + `ingestIstanbul` / `ingestLcov` / `ingestV8` parsers; schema in [§ Schema → coverage](#schema)); `query-recipes.ts` + `recipes-loader.ts` (recipe registry); `output-formatters.ts` (SARIF + GH annotations + Mermaid `flowchart LR` with bounded-input contract); `watcher.ts` (chokidar-backed debounced reindex; pure helpers + injectable backend); `tool-handlers.ts` + `resource-handlers.ts` (transport-agnostic tool / resource handlers shared by MCP + HTTP); `mcp-server.ts` (MCP transport — stdio); `http-server.ts` (HTTP transport — `node:http`). Engines depend on `db.ts` / `runtime.ts`; **never** on `cli/`. | +| **`adapters/`** | `LanguageAdapter` registry; built-ins call `parser.ts` / `css-parser.ts` / `markers.ts` from `parse-worker-core`. | +| **`runtime.ts` / `config.ts` / `db.ts` / …** | Config, SQLite, resolver, workers. | `index.ts` is the package entry: re-exports the public API and runs `cli/main` only when executed as the main module (Node/Bun `codemap` binary). @@ -92,29 +92,29 @@ A local SQLite database (`.codemap/index.db`) indexes the project tree and store ## Key Files -| File | Purpose | -| ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `index.ts` | Package entry — re-exports `api` / `config`, runs CLI when main | -| `cli/` | CLI — bootstrap argv, lazy command modules, `query` / `validate` / `context` / `agents init` / index modes | -| `api.ts` | Programmatic API — `createCodemap`, `Codemap`, `runCodemapIndex` | -| `application/` | Pure transport-agnostic engines (`run-index`, `index-engine`, `query-engine`, `audit-engine`, `context-engine`, `validate-engine`, `show-engine`, `search-query-parser`, `search-engine`, `show-search-mode`, `impact-engine`, `affected-engine`, `trace-engine`, `output-budget`, `apply-engine`, `coverage-engine`, `query-recipes`, `recipes-loader`, `mcp-server`, `http-server`, `watcher`) | -| `worker-pool.ts` | Parallel parse workers (Bun / Node) | -| `db.ts` | SQLite adapter — schema DDL, typed CRUD, connection management | -| `parser.ts` | TS/TSX/JS/JSX extraction via `oxc-parser` — symbols (with JSDoc + generics + return types), type members, imports, exports, components, markers | -| `css-parser.ts` | CSS extraction via `lightningcss` — custom properties, classes, keyframes, `@theme` blocks | -| `resolver.ts` | Import path resolution via `oxc-resolver` — respects `tsconfig` aliases, builds dependency graph | -| `constants.ts` | Shared constants — e.g. `LANG_MAP` | -| `glob-sync.ts` | Include globs — **`tinyglobby`** on both runtimes ([packaging § Node vs Bun](./packaging.md#node-vs-bun)) | -| `markers.ts` | Shared marker extraction (`TODO`/`FIXME`/`HACK`/`NOTE`) + `extractSuppressions` for opt-in `// codemap-ignore-{next-line,file} ` directives — used by all parsers | -| `parse-worker.ts` | Worker thread entry point — reads, parses, and extracts file data in parallel | -| `adapters/` | `LanguageAdapter` types and built-in TS/CSS/text implementations | -| `parsed-types.ts` | Shared `ParsedFile` shape for workers and adapters | -| `agents-init.ts` / `agents-init-interactive.ts` / `agents-init-mcp.ts` / `agents-init-mcp-registry.ts` / `agents-init-targets.ts` / `agents-template-path.ts` | `codemap agents init` — see [agents.md](./agents.md) (granular template + IDE writes, pointer upsert, **`--interactive`**, **`--mcp`** registry-driven JSON merge + verify-after-write, **`/.gitignore`** reconciler). **`agents-template-path.ts`** is the leaf bundled-template resolver (used by init + `application/agent-content` / `query-recipes` without import cycles). | -| `codemap-invocation.ts` / `scripts/codemap-invocation.mjs` | PM-aware codemap CLI spawn resolution (`resolveCodemapCliInvocation`, `buildCodemapMcpSpawn`); TS for **`agents init --mcp`**, `.mjs` mirror for Action **`detect-pm`** — keep in sync (`scripts/codemap-invocation-sync.test.mjs`). | -| `cli/cmd-skill.ts` | `codemap skill` / `codemap rule` verbs — thin wrappers over `assembleAgentContent(kind)` that print the bundled markdown to stdout. See [agents.md § Live fetch surface](./agents.md#live-fetch-surface-cli--mcp--http). | -| `application/agent-content.ts` | `assembleAgentContent(kind)`, `RENDERERS` map (`*.gen.md` dispatch), `renderRecipesSection` (live recipe catalog), `renderSchemaSection` (in-memory SQLite + `createTables()` DDL), `checkConsumerPointers` / `maybeWarnStalePointers`, `EXPECTED_POINTER_VERSION`. See [agents.md § Section assembler](./agents.md#section-assembler-and-genmd) and [§ Pointer protocol](./agents.md#pointer-protocol-and-staleness-detection). | -| `benchmark.ts` (+ `benchmark-default-scenarios.ts`, `benchmark-config.ts`, `benchmark-common.ts`) | SQL vs traditional timing; optional **`CODEMAP_BENCHMARK_CONFIG`** JSON — [benchmark.md § Custom scenarios](./benchmark.md#custom-scenarios-codemap_benchmark_config) | -| `config.ts` | `/config.{ts,js,json}` load path, **Zod** user schema (`codemapUserConfigSchema`), `resolveCodemapConfig` | +| File | Purpose | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `index.ts` | Package entry — re-exports `api` / `config`, runs CLI when main | +| `cli/` | CLI — bootstrap argv, lazy command modules, `query` / `validate` / `context` / `agents init` / index modes | +| `api.ts` | Programmatic API — `createCodemap`, `Codemap`, `runCodemapIndex` | +| `application/` | Pure transport-agnostic engines (`run-index`, `index-engine`, `query-engine`, `query-baseline`, `ingest-coverage-run`, `audit-engine`, `context-engine`, `validate-engine`, `show-engine`, `search-query-parser`, `search-engine`, `show-search-mode`, `impact-engine`, `affected-engine`, `trace-engine`, `output-budget`, `apply-engine`, `coverage-engine`, `query-recipes`, `recipes-loader`, `mcp-server`, `http-server`, `watcher`) | +| `worker-pool.ts` | Parallel parse workers (Bun / Node) | +| `db.ts` | SQLite adapter — schema DDL, typed CRUD, connection management | +| `parser.ts` | TS/TSX/JS/JSX extraction via `oxc-parser` — symbols (with JSDoc + generics + return types), type members, imports, exports, components, markers | +| `css-parser.ts` | CSS extraction via `lightningcss` — custom properties, classes, keyframes, `@theme` blocks | +| `resolver.ts` | Import path resolution via `oxc-resolver` — respects `tsconfig` aliases, builds dependency graph | +| `constants.ts` | Shared constants — e.g. `LANG_MAP` | +| `glob-sync.ts` | Include globs — **`tinyglobby`** on both runtimes ([packaging § Node vs Bun](./packaging.md#node-vs-bun)) | +| `markers.ts` | Shared marker extraction (`TODO`/`FIXME`/`HACK`/`NOTE`) + `extractSuppressions` for opt-in `// codemap-ignore-{next-line,file} ` directives — used by all parsers | +| `parse-worker.ts` | Worker thread entry point — reads, parses, and extracts file data in parallel | +| `adapters/` | `LanguageAdapter` types and built-in TS/CSS/text implementations | +| `parsed-types.ts` | Shared `ParsedFile` shape for workers and adapters | +| `agents-init.ts` / `agents-init-interactive.ts` / `agents-init-mcp.ts` / `agents-init-mcp-registry.ts` / `agents-init-targets.ts` / `agents-template-path.ts` | `codemap agents init` — see [agents.md](./agents.md) (granular template + IDE writes, pointer upsert, **`--interactive`**, **`--mcp`** registry-driven JSON merge + verify-after-write, **`/.gitignore`** reconciler). **`agents-template-path.ts`** is the leaf bundled-template resolver (used by init + `application/agent-content` / `query-recipes` without import cycles). | +| `codemap-invocation.ts` / `scripts/codemap-invocation.mjs` | PM-aware codemap CLI spawn resolution (`resolveCodemapCliInvocation`, `buildCodemapMcpSpawn`); TS for **`agents init --mcp`**, `.mjs` mirror for Action **`detect-pm`** — keep in sync (`scripts/codemap-invocation-sync.test.mjs`). | +| `cli/cmd-skill.ts` | `codemap skill` / `codemap rule` verbs — thin wrappers over `assembleAgentContent(kind)` that print the bundled markdown to stdout. See [agents.md § Live fetch surface](./agents.md#live-fetch-surface-cli--mcp--http). | +| `application/agent-content.ts` | `assembleAgentContent(kind)`, `RENDERERS` map (`*.gen.md` dispatch), `renderRecipesSection` (live recipe catalog), `renderSchemaSection` (in-memory SQLite + `createTables()` DDL), `checkConsumerPointers` / `maybeWarnStalePointers`, `EXPECTED_POINTER_VERSION`. See [agents.md § Section assembler](./agents.md#section-assembler-and-genmd) and [§ Pointer protocol](./agents.md#pointer-protocol-and-staleness-detection). | +| `benchmark.ts` (+ `benchmark-default-scenarios.ts`, `benchmark-config.ts`, `benchmark-common.ts`) | SQL vs traditional timing; optional **`CODEMAP_BENCHMARK_CONFIG`** JSON — [benchmark.md § Custom scenarios](./benchmark.md#custom-scenarios-codemap_benchmark_config) | +| `config.ts` | `/config.{ts,js,json}` load path, **Zod** user schema (`codemapUserConfigSchema`), `resolveCodemapConfig` | ## CLI usage @@ -176,7 +176,7 @@ Three **mutually exclusive** CLI entry shapes; all converge on `applyDiffPayload **MCP wiring:** **`src/cli/cmd-mcp.ts`** (argv — `--watch` / `--no-watch` / `--debounce` + `--help`; bootstrap absorbs `--root`/`--config`) + **`src/application/mcp-server.ts`** (transport — tool / resource registry, SDK glue). Mirrors the `cmd-audit.ts ↔ audit-engine.ts` seam — CLI parses + lifecycle; engine owns the SDK. **`runMcpServer`** bootstraps codemap once at server boot (config + resolver + DB access become module-level state), instantiates `McpServer` from **`@modelcontextprotocol/sdk`**, attaches a **`StdioServerTransport`**, and resolves on client disconnect via **`src/application/session-lifecycle.ts`** (`createStdioDisconnectMonitor` — stdin EOF, stdout EPIPE, parent-PID poll — plus SDK `transport.onclose` and SIGINT/SIGTERM). With `--watch`, **`createManagedWatchSession`** holds one client for the stdio session and **`forceStop`** drains the watcher on exit. Tool handlers reuse the existing engine entry-points: **`query`** + **`query_recipe`** call **`executeQuery`** in **`src/application/query-engine.ts`** (a pure transport-agnostic engine extracted from `printQueryResult`'s JSON branch — same `[...rows]` / `{count}` / `{group_by, groups}` envelope `--json` would print); **`query_batch`** loops per statement via **`handleQueryBatch`** → **`executeQuery`** (batch-wide defaults + per-item overrides; items are `string | {sql, summary?, changed_since?, group_by?}`); **`audit`** runs `resolveAuditBaselines` + `runAudit` from PR #33 unchanged; **`context`** / **`validate`** call `buildContextEnvelope` / `computeValidateRows` from **`src/application/context-engine.ts`** + **`src/application/validate-engine.ts`** (lifted out of `src/cli/cmd-*.ts` in PR #41 — see § Tool / resource handlers above). **`save_baseline`** is one polymorphic tool (`{name, sql? | recipe?}`) with a runtime exclusivity check — mirrors the CLI's single `--save-baseline=` verb. **Tool naming**: snake_case throughout — Codemap convention matching the patterns in MCP spec examples and reference servers (GitHub MCP, Cursor built-ins); the spec itself doesn't mandate it. CLI stays kebab — translation lives at the MCP-arg layer. **Resources** split by freshness contract: `codemap://schema`, `codemap://skill`, `codemap://rule`, and `codemap://mcp-instructions` use **lazy memoisation** — first `read_resource` populates a per-server-instance cache; constant for the server-process lifetime so eager-vs-lazy produce identical observable behavior. `codemap://recipes`, `codemap://recipes/{id}`, `codemap://files/{+path}`, and `codemap://symbols/{name}` are **live read-per-call** (no cache) so inline recency fields and index mutations under `--watch` don't freeze at first-read. `codemap://schema` queries `sqlite_schema` live (on first read, then cached); `codemap://skill` / `codemap://rule` / `codemap://mcp-instructions` call `assembleAgentContent(kind)` from `application/agent-content.ts`, which concatenates section files under `templates/agent-content//` and dispatches `*.gen.md` files through `RENDERERS` (live recipe catalog, live `createTables()` DDL) — see [agents.md § Section assembler](./agents.md#section-assembler-and-genmd). Output shape: each tool returns the JSON payload its CLI counterpart would print (`query batch`, `trace`, `explore`, `node`, `file`, `schema`, `context --include-snippets`); MCP wraps via `content: [{type: "text", text: JSON.stringify(payload)}]`. `--changed-since` git lookups are memoised per `(root, ref)` pair across batch items so a `query_batch` of N items sharing the same ref does one git invocation, not N. Per-statement errors in `query_batch` are isolated — failed statements return `{error}` in their slot while siblings still execute. -**HTTP wiring:** **`src/cli/cmd-serve.ts`** (argv — `--host` / `--port` / `--token`; bootstrap absorbs `--root`/`--config`) + **`src/application/http-server.ts`** (transport — bare `node:http`; routes `POST /tool/{name}` to `tool-handlers`, `GET /resources/{encoded-uri}` to `resource-handlers`, plus `GET /health` / `GET /tools` / `GET /resources`). Default bind **`127.0.0.1:7878`** (loopback only — refuse `0.0.0.0` unless explicitly opted in via `--host 0.0.0.0`). Optional **`--token `** requires `Authorization: Bearer ` on every request; `GET /health` is auth-exempt so liveness probes work without leaking the token. **CSRF + DNS-rebinding guard** (`csrfCheck`) runs before every route — rejects `Sec-Fetch-Site: cross-site` / `same-site` (modern-browser CSRF), any present `Origin` header (including the opaque string `null`; older-browser CSRF fallback), and `Host` header mismatch on loopback bind (DNS rebinding). Non-browser clients (curl, fetch from Node, MCP hosts, CI scripts) don't send those headers and pass through. The guard runs even on `/health` so a malicious local webpage can't probe for liveness. Output shape: HTTP returns each tool's native JSON payload directly (NOT MCP's `{content: [...]}` wrapper — HTTP doesn't need that transport artifact); `query` / `query_recipe` match `codemap query --json` row arrays (or `{count}` / `{group_by,groups}` when `summary` / `group_by` is set — baseline save/compare is separate tools, not MCP/HTTP `query`); other tools match their CLI `--json` envelopes; `format: "sarif"` payloads ship as `application/sarif+json`, `format: "annotations"` / `"mermaid"` / `"diff"` as `text/plain; charset=utf-8`, `format: "diff-json"` as `application/json; charset=utf-8`, JSON otherwise. Per-request DB lifecycle: open / `PRAGMA query_only = 1` / close per call (SQLite reader concurrency); 1 MiB request-body cap rejects trivial DoS. SIGINT / SIGTERM → graceful drain via `server.close()`. Every response carries **`X-Codemap-Version: `** so consumers can pin / detect upgrades. +**HTTP wiring:** **`src/cli/cmd-serve.ts`** (argv — `--host` / `--port` / `--token`; bootstrap absorbs `--root`/`--config`) + **`src/application/http-server.ts`** (transport — bare `node:http`; routes `POST /tool/{name}` to `tool-handlers`, `GET /resources/{encoded-uri}` to `resource-handlers`, plus `GET /health` / `GET /tools` / `GET /resources`). Default bind **`127.0.0.1:7878`** (loopback only — refuse `0.0.0.0` unless explicitly opted in via `--host 0.0.0.0`). Optional **`--token `** requires `Authorization: Bearer ` on every request; `GET /health` is auth-exempt so liveness probes work without leaking the token. **CSRF + DNS-rebinding guard** (`csrfCheck`) runs before every route — rejects `Sec-Fetch-Site: cross-site` / `same-site` (modern-browser CSRF), any present `Origin` header (including the opaque string `null`; older-browser CSRF fallback), and `Host` header mismatch on loopback bind (DNS rebinding). Non-browser clients (curl, fetch from Node, MCP hosts, CI scripts) don't send those headers and pass through. The guard runs even on `/health` so a malicious local webpage can't probe for liveness. Output shape: HTTP returns each tool's native JSON payload directly (NOT MCP's `{content: [...]}` wrapper — HTTP doesn't need that transport artifact); `query` / `query_recipe` match `codemap query --json` row arrays (or `{count}` / `{group_by,groups}` when `summary` / `group_by` is set, or baseline diff when `baseline` is set — incompatible with non-`json` `format` / `group_by`; save/list/drop remain separate tools); other tools match their CLI `--json` envelopes; `format: "sarif"` payloads ship as `application/sarif+json`, `format: "annotations"` / `"mermaid"` / `"diff"` as `text/plain; charset=utf-8`, `format: "diff-json"` as `application/json; charset=utf-8`, JSON otherwise. Per-request DB lifecycle: open / `PRAGMA query_only = 1` / close per call (SQLite reader concurrency); 1 MiB request-body cap rejects trivial DoS. SIGINT / SIGTERM → graceful drain via `server.close()`. Every response carries **`X-Codemap-Version: `** so consumers can pin / detect upgrades. **Watch wiring:** **`src/cli/cmd-watch.ts`** (argv — `--debounce ` / `--quiet`; bootstrap absorbs `--root`/`--config`) + **`src/application/watcher.ts`** (engine — pure debouncer + glob filter + injectable backend; production wires [chokidar v5](https://github.com/paulmillr/chokidar) selected via the 6-watcher audit in PR #46 — pure JS, runs identically on Bun + Node, ~30M repos use it). On every change/add/unlink event chokidar emits, the engine filters via `shouldIndexPath` (same indexed extensions as the indexer + project-local recipes; skips `node_modules` / `.git` / `dist`), debounces with a sliding window (default 250 ms), then calls `createReindexOnChange` which opens a DB, runs `runCodemapIndex({mode: 'files', files: [...changed]})`, closes the DB, and logs `reindex N file(s) in Mms` to stderr unless `--quiet`. SIGINT / SIGTERM drains pending edits via `flushNow()` before the watcher closes. **Default-ON for `mcp` / `serve` since 2026-05:** both transports embed the watcher via **`createManagedWatchSession`** in **`session-lifecycle.ts`** — MCP holds one client for the stdio session; HTTP acquires per request (excluding `/health`) and stops the watcher after the last client plus a 5s release grace (not an MCP idle shutdown). Opt out with `--no-watch`, `CODEMAP_WATCH=0`, or `CODEMAP_NO_WATCH=1`. **`src/application/watch-policy.ts`** disables the watcher on WSL2 Windows drive mounts (`/mnt/*`) unless `CODEMAP_FORCE_WATCH=1`; stderr points at `codemap agents init --git-hooks` for git-triggered freshness. Standalone `codemap watch` runs the watcher decoupled from a transport for users wiring it next to a separate MCP / HTTP process. **Audit prelude optimization:** module-level `watchActive` flag; `handleAudit` skips its incremental-index prelude when active (and marks the close as readonly to avoid a wasted checkpoint). Explicit `no_index: false` still forces the prelude. diff --git a/docs/glossary.md b/docs/glossary.md index dc457506..f50939b5 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -361,7 +361,7 @@ Rust-based CSS parser (NAPI bindings). Codemap's `src/css-parser.ts` uses its vi ### `codemap mcp` / MCP server -Stdio MCP (Model Context Protocol) server exposing codemap's structural-query surface to agent hosts (Claude Code, Cursor, Codex, generic MCP clients) as JSON-RPC tools — eliminates the bash round-trip on every agent invocation. **19 tools:** `query`, `query_batch`, `query_recipe`, `audit`, `save_baseline`, `list_baselines`, `drop_baseline`, `context`, `validate`, `show`, `snippet`, `impact`, `affected`, `trace`, `explore`, `node`, `apply`, `apply_rows`, `apply_diff_input`. Each has a CLI twin with the same JSON payload except transport-only MCP resources (`codemap://mcp-instructions`, initialize `instructions`). Subset via **`CODEMAP_MCP_TOOLS`** ([agents.md § MCP tool allowlist](./agents.md#mcp-tool-allowlist)). **Resources:** `codemap://schema`, `codemap://skill`, `codemap://rule`, `codemap://mcp-instructions`, `codemap://recipes`, `codemap://recipes/{id}`, `codemap://files/{path}`, `codemap://symbols/{name}`. Resource freshness is split by contract: schema / skill / rule / mcp-instructions are lazy-cached per server process; recipes, files, and symbols are live read-per-call so inline recency fields and index mutations under `--watch` don't freeze at first read. HTTP's `GET /resources/{encoded-uri}` uses the same resource handler. **Baseline tools** (`save_baseline`, `list_baselines`, `drop_baseline`) mirror `query --save-baseline` / `--baselines` / `--drop-baseline`. **CLI twins:** `query batch`, `trace`, `explore`, `node`, `file`, `schema`, `symbols`, `context --include-snippets`. Tool input/output keys are snake_case on MCP/HTTP — Codemap's convention; CLI stays kebab. Output shape matches each tool's CLI JSON payload; MCP wraps payloads in `{content: [{type: "text", text: …}]}`. Bootstrap once at server boot; tool handlers (in `application/tool-handlers.ts`) and resource handlers (in `application/resource-handlers.ts`) are pure transport-agnostic — the same handlers serve `codemap serve` (HTTP) via `POST /tool/{name}` and `GET /resources/{encoded-uri}`. **Session lifecycle:** exits on client disconnect (stdin EOF, stdout broken pipe, parent process exit, SIGINT/SIGTERM) via `session-lifecycle.ts`; **no idle timeout** — the process stays up while the pipe is open even without tool calls (see [§ Session lifecycle](./architecture.md#cli-usage)). With `--watch`, the watcher starts before connect and drains on exit. Implementation: `src/cli/cmd-mcp.ts` (CLI shell) + `src/application/mcp-server.ts` (engine). See [`architecture.md` § MCP wiring](./architecture.md#cli-usage). +Stdio MCP (Model Context Protocol) server exposing codemap's structural-query surface to agent hosts (Claude Code, Cursor, Codex, generic MCP clients) as JSON-RPC tools — eliminates the bash round-trip on every agent invocation. **20 tools:** `query`, `query_batch`, `query_recipe`, `audit`, `save_baseline`, `list_baselines`, `drop_baseline`, `context`, `validate`, `show`, `snippet`, `impact`, `affected`, `trace`, `explore`, `node`, `apply`, `apply_rows`, `apply_diff_input`, `ingest_coverage`. Each has a CLI twin with the same JSON payload except transport-only MCP resources (`codemap://mcp-instructions`, initialize `instructions`). Subset via **`CODEMAP_MCP_TOOLS`** ([agents.md § MCP tool allowlist](./agents.md#mcp-tool-allowlist)). **Resources:** `codemap://schema`, `codemap://skill`, `codemap://rule`, `codemap://mcp-instructions`, `codemap://recipes`, `codemap://recipes/{id}`, `codemap://files/{path}`, `codemap://symbols/{name}`. Resource freshness is split by contract: schema / skill / rule / mcp-instructions are lazy-cached per server process; recipes, files, and symbols are live read-per-call so inline recency fields and index mutations under `--watch` don't freeze at first read. HTTP's `GET /resources/{encoded-uri}` uses the same resource handler. **Baseline tools** (`save_baseline`, `list_baselines`, `drop_baseline`) mirror `query --save-baseline` / `--baselines` / `--drop-baseline`; **`query` / `query_recipe`** also accept optional `baseline` for one-shot row diff vs saved snapshots (same envelope as CLI `query --baseline`). **CLI twins:** `query batch`, `trace`, `explore`, `node`, `file`, `schema`, `symbols`, `context --include-snippets`, `ingest-coverage`. Tool input/output keys are snake_case on MCP/HTTP — Codemap's convention; CLI stays kebab. Output shape matches each tool's CLI JSON payload; MCP wraps payloads in `{content: [{type: "text", text: …}]}`. Bootstrap once at server boot; tool handlers (in `application/tool-handlers.ts`) and resource handlers (in `application/resource-handlers.ts`) are pure transport-agnostic — the same handlers serve `codemap serve` (HTTP) via `POST /tool/{name}` and `GET /resources/{encoded-uri}`. **Session lifecycle:** exits on client disconnect (stdin EOF, stdout broken pipe, parent process exit, SIGINT/SIGTERM) via `session-lifecycle.ts`; **no idle timeout** — the process stays up while the pipe is open even without tool calls (see [§ Session lifecycle](./architecture.md#cli-usage)). With `--watch`, the watcher starts before connect and drains on exit. Implementation: `src/cli/cmd-mcp.ts` (CLI shell) + `src/application/mcp-server.ts` (engine). See [`architecture.md` § MCP wiring](./architecture.md#cli-usage). ### `query_batch` @@ -544,7 +544,7 @@ Long-running process that subscribes to filesystem changes via [chokidar v5](htt ### `codemap serve` / HTTP server -Long-running HTTP server exposing the same tool taxonomy as `codemap mcp` over `POST /tool/{name}` for non-MCP consumers (CI scripts, simple `curl`, IDE plugins that don't speak MCP). Default bind **`127.0.0.1:7878`** (loopback only — refuse `0.0.0.0` unless explicitly opted in via `--host 0.0.0.0`); optional `--token ` requires `Authorization: Bearer ` on every request. HTTP returns each tool's native JSON payload directly (NOT MCP's `{content: [...]}` wrapper); `query` / `query_recipe` match `codemap query --json` row arrays unless `summary` / `group_by` reshape the envelope (baseline save/compare is separate tools — not on MCP/HTTP `query` / `query_recipe`); parity twins (`query batch`, `trace`, `explore`, `node`, `file`, `schema`, `symbols`, `context`) always emit JSON on CLI without `--json`; other tools match their CLI `--json` payloads when that flag is set; `format: "sarif"` payloads ship as `application/sarif+json`, `format: "annotations"` / `"mermaid"` / `"diff"` as `text/plain; charset=utf-8`, `format: "diff-json"` as `application/json; charset=utf-8`. Routes: `POST /tool/{name}` (every MCP tool), `GET /resources/{encoded-uri}` (resource handler for `codemap://recipes`, `codemap://recipes/{id}`, `codemap://schema`, `codemap://skill`, `codemap://rule`, `codemap://mcp-instructions`, `codemap://files/{path}`, and `codemap://symbols/{name}`), `GET /health` (auth-exempt liveness probe — does not start the watcher), `GET /tools` / `GET /resources` (catalogs). With `--watch`, chokidar is refcount-gated per request and stops 5s after the last client (`HTTP_WATCH_RELEASE_GRACE_MS`) — distinct from MCP idle shutdown; the HTTP process keeps listening. Pure transport — same `tool-handlers.ts` / `resource-handlers.ts` MCP uses; no engine duplication. Errors → `{"error": "..."}` with HTTP status 400 / 401 / 403 / 404 / 500. SIGINT / SIGTERM → graceful drain. Every response carries `X-Codemap-Version: `. **CSRF + DNS-rebinding guard:** every request (including auth-exempt `/health`) is evaluated against `Sec-Fetch-Site` / `Origin` / `Host` when present — modern browsers send `Sec-Fetch-Site` and `Origin` on cross-origin fetches (header presence varies by request type, browser, and privacy settings), so the guard rejects browser-driven cross-origin requests like a malicious local webpage `fetch`-ing `http://127.0.0.1:7878/tool/save_baseline` to mutate `.codemap/index.db`. `Host` mismatch on a loopback bind blocks DNS rebinding (an attacker resolving `evil.com` to `127.0.0.1` post-load). Non-browser clients (curl, fetch from Node, MCP hosts, CI scripts) typically omit these headers and pass through. Implementation: `src/cli/cmd-serve.ts` (CLI shell) + `src/application/http-server.ts` (transport). See [`architecture.md` § HTTP wiring](./architecture.md#cli-usage). +Long-running HTTP server exposing the same tool taxonomy as `codemap mcp` over `POST /tool/{name}` for non-MCP consumers (CI scripts, simple `curl`, IDE plugins that don't speak MCP). Default bind **`127.0.0.1:7878`** (loopback only — refuse `0.0.0.0` unless explicitly opted in via `--host 0.0.0.0`); optional `--token ` requires `Authorization: Bearer ` on every request. HTTP returns each tool's native JSON payload directly (NOT MCP's `{content: [...]}` wrapper); `query` / `query_recipe` match `codemap query --json` row arrays unless `summary` / `group_by` reshape the envelope, or `baseline` returns a diff envelope (incompatible with non-`json` `format` / `group_by`; save/list/drop remain separate tools); parity twins (`query batch`, `trace`, `explore`, `node`, `file`, `schema`, `symbols`, `context`, `ingest-coverage`) always emit JSON on CLI without `--json`; other tools match their CLI `--json` payloads when that flag is set; `format: "sarif"` payloads ship as `application/sarif+json`, `format: "annotations"` / `"mermaid"` / `"diff"` as `text/plain; charset=utf-8`, `format: "diff-json"` as `application/json; charset=utf-8`. Routes: `POST /tool/{name}` (every MCP tool), `GET /resources/{encoded-uri}` (resource handler for `codemap://recipes`, `codemap://recipes/{id}`, `codemap://schema`, `codemap://skill`, `codemap://rule`, `codemap://mcp-instructions`, `codemap://files/{path}`, and `codemap://symbols/{name}`), `GET /health` (auth-exempt liveness probe — does not start the watcher), `GET /tools` / `GET /resources` (catalogs). With `--watch`, chokidar is refcount-gated per request and stops 5s after the last client (`HTTP_WATCH_RELEASE_GRACE_MS`) — distinct from MCP idle shutdown; the HTTP process keeps listening. Pure transport — same `tool-handlers.ts` / `resource-handlers.ts` MCP uses; no engine duplication. Errors → `{"error": "..."}` with HTTP status 400 / 401 / 403 / 404 / 500. SIGINT / SIGTERM → graceful drain. Every response carries `X-Codemap-Version: `. **CSRF + DNS-rebinding guard:** every request (including auth-exempt `/health`) is evaluated against `Sec-Fetch-Site` / `Origin` / `Host` when present — modern browsers send `Sec-Fetch-Site` and `Origin` on cross-origin fetches (header presence varies by request type, browser, and privacy settings), so the guard rejects browser-driven cross-origin requests like a malicious local webpage `fetch`-ing `http://127.0.0.1:7878/tool/save_baseline` to mutate `.codemap/index.db`. `Host` mismatch on a loopback bind blocks DNS rebinding (an attacker resolving `evil.com` to `127.0.0.1` post-load). Non-browser clients (curl, fetch from Node, MCP hosts, CI scripts) typically omit these headers and pass through. Implementation: `src/cli/cmd-serve.ts` (CLI shell) + `src/application/http-server.ts` (transport). See [`architecture.md` § HTTP wiring](./architecture.md#cli-usage). ### SARIF diff --git a/src/application/ingest-coverage-run.test.ts b/src/application/ingest-coverage-run.test.ts index 55744b10..5c676f68 100644 --- a/src/application/ingest-coverage-run.test.ts +++ b/src/application/ingest-coverage-run.test.ts @@ -15,6 +15,7 @@ import { initCodemap } from "../runtime"; import { openCodemapDatabase } from "../sqlite-db"; import { resolveCoverageArtifact, + resolveV8CoverageDirectory, runIngestCoverageOnDb, } from "./ingest-coverage-run"; @@ -48,6 +49,74 @@ describe("resolveCoverageArtifact", () => { /both coverage-final\.json and lcov\.info/, ); }); + + it("errors when path not found", () => { + expect(() => + resolveCoverageArtifact("missing/coverage-final.json", projectRoot), + ).toThrow(/path not found/); + }); + + it("resolves lcov file by extension", () => { + const file = join(projectRoot, "lcov.info"); + writeFileSync(file, "TN:\n"); + expect(resolveCoverageArtifact(file, projectRoot)).toEqual({ + format: "lcov", + absPath: file, + }); + }); + + it("resolves directory with lcov only", () => { + const dir = join(projectRoot, "cov"); + mkdirSync(dir); + writeFileSync(join(dir, "lcov.info"), "TN:\n"); + expect(resolveCoverageArtifact(dir, projectRoot)).toEqual({ + format: "lcov", + absPath: join(dir, "lcov.info"), + }); + }); + + it("errors when directory has neither artifact", () => { + const dir = join(projectRoot, "empty"); + mkdirSync(dir); + expect(() => resolveCoverageArtifact(dir, projectRoot)).toThrow( + /contains neither/, + ); + }); +}); + +describe("resolveV8CoverageDirectory", () => { + it("errors when path not found", () => { + expect(() => + resolveV8CoverageDirectory("missing-dir", projectRoot), + ).toThrow(/path not found/); + }); + + it("errors when path is a file", () => { + const file = join(projectRoot, "coverage-1.json"); + writeFileSync(file, "{}"); + expect(() => resolveV8CoverageDirectory(file, projectRoot)).toThrow( + /expected a directory/, + ); + }); + + it("errors when directory has no coverage-*.json files", () => { + const dir = join(projectRoot, "v8-empty"); + mkdirSync(dir); + expect(() => resolveV8CoverageDirectory(dir, projectRoot)).toThrow( + /no coverage-\*\.json/, + ); + }); + + it("returns json files from a v8 directory", () => { + const dir = join(projectRoot, "v8"); + mkdirSync(dir); + const file = join(dir, "coverage-123.json"); + writeFileSync(file, "{}"); + expect(resolveV8CoverageDirectory(dir, projectRoot)).toEqual({ + absDir: dir, + jsonFiles: [file], + }); + }); }); describe("runIngestCoverageOnDb", () => { @@ -119,4 +188,28 @@ describe("runIngestCoverageOnDb", () => { closeDb(db); } }); + + it("returns ok:false when runtime json files have empty result", async () => { + const db = openCodemapDatabase(":memory:"); + try { + createTables(db); + const dir = join(projectRoot, "v8-runtime"); + mkdirSync(dir); + writeFileSync( + join(dir, "coverage-1.json"), + JSON.stringify({ result: [] }), + ); + + const outcome = await runIngestCoverageOnDb(db, { + projectRoot, + path: "v8-runtime", + runtime: true, + }); + expect(outcome.ok).toBe(false); + if (outcome.ok) return; + expect(outcome.error).toMatch(/contained no V8/); + } finally { + closeDb(db); + } + }); }); diff --git a/src/application/mcp-server.ts b/src/application/mcp-server.ts index ab4412d4..3e828add 100644 --- a/src/application/mcp-server.ts +++ b/src/application/mcp-server.ts @@ -419,7 +419,7 @@ function registerApplyDiffInputTool(server: McpServer, opts: ServerOpts): void { "apply_diff_input", { description: - "Apply a unified diff (git-style `-`/`+` hunks) to disk — same row contract and executor as `apply_rows`, but `diff_text` is parsed via parseUnifiedDiffToRows (CLI twin: codemap apply --diff-input). Args: diff_text, dry_run, yes (required for writes), commit_message (optional git commit after clean apply). No recipe policy gates.", + "Apply a unified diff (git-style `-`/`+` hunks) to disk — same row contract and executor as `apply_rows`, but `diff_text` is parsed into diff rows (CLI twin: codemap apply --diff-input). Args: diff_text, dry_run, yes (required for writes), commit_message (optional git commit after clean apply). No recipe policy gates.", inputSchema: applyDiffInputArgsSchema, }, async (args) => wrapToolResult(await handleApplyDiffInput(args, opts.root)), @@ -463,13 +463,13 @@ function registerResources(server: McpServer): void { server, "skill", "codemap://skill", - "Full text of the assembled codemap skill (`templates/agent-content/skill/`). Agents that don't preload the skill at session start can fetch it here.", + "Full text of the assembled codemap skill. Agents that don't preload the skill at session start can fetch it here.", ); registerStaticResource( server, "rule", "codemap://rule", - "Full text of the assembled codemap rule (`templates/agent-content/rule/`; always-on priming for agents in the indexed project).", + "Full text of the assembled codemap rule (always-on priming for agents in the indexed project).", ); registerStaticResource( server, diff --git a/src/application/query-baseline.test.ts b/src/application/query-baseline.test.ts index 99ee0eae..0fb8e25a 100644 --- a/src/application/query-baseline.test.ts +++ b/src/application/query-baseline.test.ts @@ -10,6 +10,7 @@ import { baselineQueryIncompatibility, compareQueryBaseline, } from "./query-baseline"; +import { attachActions } from "./query-engine"; let projectRoot: string; @@ -79,6 +80,77 @@ describe("compareQueryBaseline", () => { }); }); + it("errors on corrupt rows_json", () => { + const db = openDb(); + try { + upsertQueryBaseline(db, { + name: "bad-json", + recipe_id: null, + sql: "SELECT 1", + rows_json: "not-json", + row_count: 0, + git_ref: null, + created_at: 1, + }); + } finally { + closeDb(db); + } + const payload = compareQueryBaseline({ + baselineName: "bad-json", + sql: "SELECT 1", + }); + expect(payload).toMatchObject({ + error: expect.stringContaining("corrupt rows_json"), + }); + }); + + it("errors on non-array rows_json", () => { + const db = openDb(); + try { + upsertQueryBaseline(db, { + name: "object-json", + recipe_id: null, + sql: "SELECT 1", + rows_json: "{}", + row_count: 0, + git_ref: null, + created_at: 1, + }); + } finally { + closeDb(db); + } + const payload = compareQueryBaseline({ + baselineName: "object-json", + sql: "SELECT 1", + }); + expect(payload).toMatchObject({ + error: expect.stringContaining("corrupt rows_json"), + }); + }); + + it("errors on invalid SQL", () => { + const payload = compareQueryBaseline({ + baselineName: "symbols", + sql: "SELECT FROM bad", + }); + expect("error" in payload).toBe(true); + if (!("error" in payload)) return; + expect(payload.error.length).toBeGreaterThan(0); + }); + + it("filters current rows by changedFiles", () => { + const payload = compareQueryBaseline({ + baselineName: "symbols", + sql: "SELECT file_path, name FROM symbols ORDER BY name", + changedFiles: new Set(["src/other.ts"]), + }); + expect("error" in payload).toBe(false); + if ("error" in payload) return; + expect(payload.current_row_count).toBe(0); + expect(payload.added).toEqual([]); + expect(payload.removed).toEqual([{ name: "bar" }]); + }); + it("attaches recipe actions on added rows only", () => { const actions = [{ type: "inspect", description: "review" }]; const payload = compareQueryBaseline({ @@ -98,6 +170,13 @@ describe("compareQueryBaseline", () => { }); }); +describe("attachActions", () => { + it("preserves existing actions on a row", () => { + const row = { name: "foo", actions: [{ type: "keep" }] }; + expect(attachActions(row, [{ type: "replace" }])).toEqual(row); + }); +}); + describe("baselineQueryIncompatibility", () => { it("allows baseline with json format", () => { expect( diff --git a/src/application/query-baseline.ts b/src/application/query-baseline.ts index d502ca6d..2c58c9c5 100644 --- a/src/application/query-baseline.ts +++ b/src/application/query-baseline.ts @@ -1,6 +1,7 @@ import { closeDb, getQueryBaseline, openDb } from "../db"; import { diffRows } from "../diff-rows"; import { filterRowsByChangedFiles } from "../git-changed"; +import { attachActions } from "./query-engine"; import type { QueryBindValue } from "./query-engine"; export interface QueryBaselineMeta { @@ -29,13 +30,6 @@ export interface QueryBaselineError { error: string; } -function attachActions(row: unknown, actions: ReadonlyArray): unknown { - if (typeof row !== "object" || row === null) return row; - const obj = row as Record; - if ("actions" in obj) return obj; - return { ...obj, actions }; -} - /** * Diff current query rows against a saved `query_baselines` snapshot. * Mirrors CLI `codemap query --baseline=`. @@ -58,13 +52,19 @@ export function compareQueryBaseline(opts: { baselineRow = getQueryBaseline(db, opts.baselineName); if (baselineRow === undefined) { return { - error: `codemap: no baseline named "${opts.baselineName}". Use list_baselines for the catalog.`, + error: `codemap: no baseline named "${opts.baselineName}". List saved baselines via \`codemap query --baselines\` or the \`list_baselines\` tool.`, }; } let baselineRows: unknown[]; try { - baselineRows = JSON.parse(baselineRow.rows_json) as unknown[]; + const parsed: unknown = JSON.parse(baselineRow.rows_json); + if (!Array.isArray(parsed)) { + return { + error: `codemap: baseline "${opts.baselineName}" has corrupt rows_json — drop and re-save.`, + }; + } + baselineRows = parsed; } catch { return { error: `codemap: baseline "${opts.baselineName}" has corrupt rows_json — drop and re-save.`, diff --git a/src/application/query-engine.ts b/src/application/query-engine.ts index 4631af4c..b1272563 100644 --- a/src/application/query-engine.ts +++ b/src/application/query-engine.ts @@ -199,7 +199,11 @@ function resolveBucketizer( return { fn: (path: string) => firstDirectory(path) }; } -function attachActions(row: unknown, actions: ReadonlyArray): unknown { +/** Attach recipe `actions` when absent — shared by query, baseline diff, grouped output. */ +export function attachActions( + row: unknown, + actions: ReadonlyArray, +): unknown { if (typeof row !== "object" || row === null) return row; const obj = row as Record; if ("actions" in obj) return obj; diff --git a/src/application/tool-handlers.test.ts b/src/application/tool-handlers.test.ts index 9a2417ba..24ce5746 100644 --- a/src/application/tool-handlers.test.ts +++ b/src/application/tool-handlers.test.ts @@ -94,6 +94,29 @@ describe("handleQuery baseline", () => { error: expect.stringContaining("cannot be combined with format=sarif"), }); }); + + it("rejects baseline + group_by", () => { + const result = handleQuery( + { sql: "SELECT 1", baseline: "pre", group_by: "directory" }, + projectRoot, + ); + expect(result).toMatchObject({ + ok: false, + error: expect.stringContaining("cannot be combined with group_by"), + }); + }); + + it("returns 404 for missing baseline", () => { + const result = handleQuery( + { sql: "SELECT 1", baseline: "missing-baseline" }, + projectRoot, + ); + expect(result).toMatchObject({ + ok: false, + status: 404, + error: expect.stringContaining('no baseline named "missing-baseline"'), + }); + }); }); describe("handleQueryRecipe baseline", () => { @@ -126,7 +149,40 @@ describe("handleQueryRecipe baseline", () => { added: Array<{ name: string; actions?: unknown[] }>; }; expect(payload.added).toHaveLength(1); - expect(payload.added[0]?.actions).toBeDefined(); + expect(payload.added[0]?.actions?.[0]).toMatchObject({ + type: "inspect-symbols", + }); + }); + + it("returns 404 for missing baseline", () => { + const result = handleQueryRecipe( + { + recipe: "find-symbol-by-kind", + params: { kind: "function", name_pattern: "%Query%" }, + baseline: "missing-baseline", + }, + projectRoot, + ); + expect(result).toMatchObject({ + ok: false, + status: 404, + error: expect.stringContaining('no baseline named "missing-baseline"'), + }); + }); + + it("rejects baseline + group_by", () => { + const result = handleQueryRecipe( + { + recipe: "find-symbol-by-kind", + baseline: "funcs", + group_by: "directory", + }, + projectRoot, + ); + expect(result).toMatchObject({ + ok: false, + error: expect.stringContaining("cannot be combined with group_by"), + }); }); }); @@ -141,6 +197,55 @@ describe("handleIngestCoverage", () => { error: expect.stringContaining("path not found"), }); }); + + it("ingests istanbul artifact successfully", async () => { + const db = openDb(); + try { + insertFile(db, { + path: "src/lib/cache.ts", + content_hash: "h2", + size: 1, + line_count: 100, + language: "typescript", + last_modified: 0, + indexed_at: 0, + }); + db.run( + "INSERT INTO symbols (file_path, name, kind, line_start, line_end, signature, is_exported, is_default_export, members, doc_comment, value, parent_name, visibility, complexity) VALUES ('src/lib/cache.ts', 'get', 'function', 9, 15, 'get(): void', 1, 0, NULL, NULL, NULL, NULL, NULL, 1)", + ); + } finally { + closeDb(db); + } + + const coverageDir = join(projectRoot, "coverage"); + mkdirSync(coverageDir); + writeFileSync( + join(coverageDir, "coverage-final.json"), + JSON.stringify({ + [`${projectRoot}/src/lib/cache.ts`]: { + path: `${projectRoot}/src/lib/cache.ts`, + statementMap: { + "0": { + start: { line: 10, column: 0 }, + end: { line: 10, column: 1 }, + }, + }, + s: { "0": 1 }, + }, + }), + ); + + const result = await handleIngestCoverage( + { path: "coverage/coverage-final.json" }, + projectRoot, + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.payload).toMatchObject({ + format: "istanbul", + ingested: { symbols: 1 }, + }); + }); }); describe("handleQueryRecipe params", () => { diff --git a/src/application/tool-handlers.ts b/src/application/tool-handlers.ts index 5d3238a6..d87a25ae 100644 --- a/src/application/tool-handlers.ts +++ b/src/application/tool-handlers.ts @@ -119,6 +119,10 @@ const err = (error: string, status: 400 | 404 | 500 = 400): ToolResult => ({ status, }); +function baselineCompareErr(error: string): ToolResult { + return err(error, error.includes("no baseline named") ? 404 : 400); +} + /** * Resolve `changed_since: ` to a Set of project-relative paths. * Memoised per (root, ref) pair so a batch with N items sharing the same @@ -228,7 +232,7 @@ export function handleQuery(args: QueryArgs, root: string): ToolResult { changedFiles: changed as Set | undefined, summary: args.summary, }); - if ("error" in payload) return err(payload.error); + if ("error" in payload) return baselineCompareErr(payload.error); return ok(payload); } if ( @@ -326,7 +330,7 @@ export function handleQueryRecipe( summary: args.summary, recipeActions, }); - if ("error" in payload) return err(payload.error); + if ("error" in payload) return baselineCompareErr(payload.error); tryRecordRecipeRun(args.recipe); return ok(payload); } diff --git a/src/cli/cmd-mcp.ts b/src/cli/cmd-mcp.ts index 9a8b6e06..82050383 100644 --- a/src/cli/cmd-mcp.ts +++ b/src/cli/cmd-mcp.ts @@ -86,9 +86,10 @@ launched by an agent host (Claude Code, Cursor, Codex, generic MCP clients) — JSON-RPC on stdin/stdout, logs on stderr. Tools (20; snake_case — mirrors CLI verbs where a shell twin exists): - query One read-only SQL statement. + query One read-only SQL statement (optional \`baseline\` for row diff). query_batch N statements in one round-trip (CLI: codemap query batch). - query_recipe Recipe by id (bundled or project-local); per-row \`actions\` hints. + query_recipe Recipe by id (bundled or project-local); per-row \`actions\` hints; + optional \`baseline\` for row diff (\`actions\` on \`added\` only). audit Structural-drift audit ({head, deltas} envelope). save_baseline Snapshot rows under a name (sql or recipe). list_baselines Catalog of saved baselines. @@ -126,11 +127,10 @@ Resources: codemap symbols . Output shape matches each tool's CLI JSON payload (always JSON for -query batch, trace, explore, node, file, schema, symbols, context; optional -\`--json\` on query/show/snippet/impact/affected/validate). MCP wraps payloads -in \`{content: [{type: "text", text: …}]}\`; HTTP returns raw JSON. See -docs/architecture.md § MCP wiring for the engine seam and the agent rule -+ skill for query examples. +query batch, trace, explore, node, file, schema, symbols, context, ingest_coverage; +optional \`--json\` on query/show/snippet/impact/affected/validate). MCP wraps payloads +in \`{content: [{type: "text", text: …}]}\`; HTTP returns raw JSON. Run +\`codemap skill\` or fetch \`codemap://skill\` for query examples. Flags: --watch [default ON] Boot an in-process file watcher so diff --git a/src/cli/cmd-query.ts b/src/cli/cmd-query.ts index ef04f411..28b8ce48 100644 --- a/src/cli/cmd-query.ts +++ b/src/cli/cmd-query.ts @@ -13,6 +13,8 @@ import { formatSarif, hasLocatableRows, } from "../application/output-formatters"; +import { compareQueryBaseline } from "../application/query-baseline"; +import { attachActions } from "../application/query-engine"; import { getQueryRecipeActionsRendered, getQueryRecipeCatalogEntry, @@ -39,12 +41,10 @@ import { import { closeDb, deleteQueryBaseline, - getQueryBaseline, listQueryBaselines, openDb, upsertQueryBaseline, } from "../db"; -import { diffRows } from "../diff-rows"; import { filterRowsByChangedFiles, getFilesChangedSince } from "../git-changed"; import type { Bucketizer, GroupByMode } from "../group-by"; import { @@ -1187,7 +1187,7 @@ function runGroupedQuery(opts: { const enriched = opts.recipeActions !== undefined && opts.recipeActions.length > 0 - ? rows.map((row) => attachActionsForGrouped(row, opts.recipeActions!)) + ? rows.map((row) => attachActions(row, opts.recipeActions!)) : rows; const noBucketLabel = opts.groupBy === "owner" ? "" : ""; @@ -1225,16 +1225,6 @@ function runGroupedQuery(opts: { console.table(grouped.map((g) => ({ key: g.key, count: g.count }))); } -function attachActionsForGrouped( - row: unknown, - actions: ReadonlyArray, -): unknown { - if (typeof row !== "object" || row === null) return row; - const obj = row as Record; - if ("actions" in obj) return obj; - return { ...obj, actions }; -} - // `git rev-parse HEAD` may legitimately fail (no git, detached worktree, etc.). // Baselines just record git_ref = NULL in that case — no fatal error. function tryGetGitRef(): string | null { @@ -1305,19 +1295,6 @@ function runSaveBaseline(opts: { } } -interface BaselineDiff { - baseline: { - name: string; - recipe_id: string | null; - row_count: number; - git_ref: string | null; - created_at: number; - }; - current_row_count: number; - added: unknown[]; - removed: unknown[]; -} - function runBaselineDiff(opts: { sql: string; json: boolean; @@ -1327,96 +1304,47 @@ function runBaselineDiff(opts: { recipeActions: ReadonlyArray | undefined; bindValues: RecipeParamValue[] | undefined; }) { - const db = openDb(); - let baseline: ReturnType; - try { - baseline = getQueryBaseline(db, opts.baselineName); - } finally { - closeDb(db, { readonly: true }); - } - - if (baseline === undefined) { - emitErrorMaybeJson( - `codemap: no baseline named "${opts.baselineName}". Use --baselines to list saved baselines.`, - opts.json, - ); - return; - } - - let baselineRows: unknown[]; - try { - baselineRows = JSON.parse(baseline.rows_json) as unknown[]; - } catch { - emitErrorMaybeJson( - `codemap: baseline "${opts.baselineName}" has corrupt rows_json — drop and re-save.`, - opts.json, - ); - return; - } + const result = compareQueryBaseline({ + baselineName: opts.baselineName, + sql: opts.sql, + bindValues: opts.bindValues, + changedFiles: opts.changedFiles, + summary: opts.summary, + recipeActions: opts.recipeActions, + }); - let currentRows: unknown[]; - try { - currentRows = queryRows(opts.sql, opts.bindValues); - } catch (err) { - emitErrorMaybeJson( - err instanceof Error ? err.message : String(err), - opts.json, - ); + if ("error" in result) { + emitErrorMaybeJson(result.error, opts.json); return; } - if (opts.changedFiles !== undefined) { - currentRows = filterRowsByChangedFiles(currentRows, opts.changedFiles); - } - - const { added, removed } = diffRows(baselineRows, currentRows); - - // Recipe actions enrich `added` only — they're the rows the agent should act on. - const enrichedAdded = - opts.recipeActions !== undefined && opts.recipeActions.length > 0 - ? added.map((row) => attachActionsForGrouped(row, opts.recipeActions!)) - : added; - - const diff: BaselineDiff = { - baseline: { - name: baseline.name, - recipe_id: baseline.recipe_id, - row_count: baseline.row_count, - git_ref: baseline.git_ref, - created_at: baseline.created_at, - }, - current_row_count: currentRows.length, - added: enrichedAdded, - removed, - }; if (opts.summary) { - const payload = { - baseline: diff.baseline, - current_row_count: diff.current_row_count, - added: added.length, - removed: removed.length, - }; + const added = result.added as number; + const removed = result.removed as number; if (opts.json) { - console.log(JSON.stringify(payload)); + console.log(JSON.stringify(result)); } else { console.log( - `baseline "${diff.baseline.name}": ${diff.baseline.row_count} rows → ${diff.current_row_count} rows (+${added.length} / -${removed.length})`, + `baseline "${result.baseline.name}": ${result.baseline.row_count} rows → ${result.current_row_count} rows (+${added} / -${removed})`, ); } return; } + const added = result.added as unknown[]; + const removed = result.removed as unknown[]; + if (opts.json) { - console.log(JSON.stringify(diff)); + console.log(JSON.stringify(result)); return; } console.log( - `baseline "${diff.baseline.name}": ${diff.baseline.row_count} rows → ${diff.current_row_count} rows (+${added.length} / -${removed.length})`, + `baseline "${result.baseline.name}": ${result.baseline.row_count} rows → ${result.current_row_count} rows (+${added.length} / -${removed.length})`, ); if (added.length > 0) { console.log(`\n added (+${added.length}):`); - console.table(enrichedAdded); + console.table(added); } if (removed.length > 0) { console.log(`\n removed (-${removed.length}):`); diff --git a/templates/agent-content/mcp-instructions.md b/templates/agent-content/mcp-instructions.md index baa983a9..ab65788e 100644 --- a/templates/agent-content/mcp-instructions.md +++ b/templates/agent-content/mcp-instructions.md @@ -29,28 +29,28 @@ Key fields: `pending_sync` (watcher debounce queue or in-flight reindex), `commi ## Common tasks -| Goal | MCP tool | Recipe twin (`query_recipe`) | -| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Exact symbol lookup | **`show`** (`name`, optional `in`) | `find-symbol-definitions` | -| Field-qualified symbol discovery | **`show`** or **`snippet`** (`query` with `kind:` / `name:` / `path:` / `in:` + free text) | `find-symbol-by-kind` for kind-heavy patterns; CLI `codemap show --query '…' --print-sql` to inspect generated SQL (no MCP `print_sql` arg) | -| Kind / pattern lookup | **`query_recipe`** | `find-symbol-by-kind` | -| Source at symbol | **`snippet`** | same rows as `show` + disk text | -| Blast radius | **`impact`** (`target`, `direction`, `via`, `depth`) | `fan-in` for file hubs; symbol call graph via SQL or `impact` | -| Call path + snippets | **`trace`** (`from`, `to`, `via?`, `max_depth?`, `budget_chars?`) — adaptive snippet caps 15k/10k/6k when omitted | `call-path` | -| Type extends / implements chain | **`query_recipe`** | `type-ancestors`, `type-descendants` (`file_path` when homonyms; on `type-descendants` also scopes output to that file) | -| Multi-symbol survey | **`explore`** (`names`, `depth?`, `kind?`, `budget_chars?`) — row cap always adaptive (500/250/125); snippets 15k/10k/6k when `budget_chars` omitted | `symbol-neighborhood` (once per name) | -| One-hop symbol card | **`node`** (`name`, `kind?`, `in?`, `include_snippets?`, `budget_chars?`) — adaptive snippet caps when snippets enabled | `show` + `symbol-neighborhood` with `depth=1` | -| Affected tests | **`affected`** (`paths?`, `changed_since?`, `test_glob?`, `max_depth?`) | `affected-tests` (RS-delimit multiple paths in `query_recipe` params) | -| CI / SARIF | **`query_recipe`** + `format: "sarif"` | `deprecated-symbols`, `boundary-violations`, … | -| Ad-hoc SQL | **`query`** | — | -| N statements / one round-trip | **`query_batch`** | **`codemap query batch`** | -| Index freshness (index-level) | **`context`** (`index_freshness`) + tool metadata above | — | -| Per-file staleness | **`validate`** | — | -| Drift vs baseline | **`audit`** (`baseline_prefix` and/or per-delta `baselines`) or **`query`** / **`query_recipe`** + `baseline` (one-shot row diff vs `query_baselines`) | save via **`save_baseline`**; `summary: true` → count-only diff | -| Load coverage data | **`ingest_coverage`** (`path`, optional `runtime` for V8 dirs) | enables `worst-covered-exports`, `files-by-coverage`, `untested-and-dead` | -| Apply recipe diff rows | **`apply`** (`recipe`, `params?`, `dry_run?`, `yes?`, `force?`, `until_empty?`, `max_passes?`, `commit_message?`) | recipe must emit `{file_path, line_start, before_pattern, after_pattern}` rows; `yes: true` required for writes; non-`auto_fixable` recipes need `force: true` | -| Apply agent/codemod rows | **`apply_rows`** (`rows`, `dry_run?`, `yes?`) | same row contract; bypasses recipe `auto_fixable` / allowlist gates | -| Apply unified diff text | **`apply_diff_input`** (`diff_text`, `dry_run?`, `yes?`, `commit_message?`) | parses git-style hunks; same executor as `apply_rows` | +| Goal | MCP tool | Recipe twin (`query_recipe`) | +| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Exact symbol lookup | **`show`** (`name`, optional `in`) | `find-symbol-definitions` | +| Field-qualified symbol discovery | **`show`** or **`snippet`** (`query` with `kind:` / `name:` / `path:` / `in:` + free text) | `find-symbol-by-kind` for kind-heavy patterns; CLI `codemap show --query '…' --print-sql` to inspect generated SQL (no MCP `print_sql` arg) | +| Kind / pattern lookup | **`query_recipe`** | `find-symbol-by-kind` | +| Source at symbol | **`snippet`** | same rows as `show` + disk text | +| Blast radius | **`impact`** (`target`, `direction`, `via`, `depth`) | `fan-in` for file hubs; symbol call graph via SQL or `impact` | +| Call path + snippets | **`trace`** (`from`, `to`, `via?`, `max_depth?`, `budget_chars?`) — adaptive snippet caps 15k/10k/6k when omitted | `call-path` | +| Type extends / implements chain | **`query_recipe`** | `type-ancestors`, `type-descendants` (`file_path` when homonyms; on `type-descendants` also scopes output to that file) | +| Multi-symbol survey | **`explore`** (`names`, `depth?`, `kind?`, `budget_chars?`) — row cap always adaptive (500/250/125); snippets 15k/10k/6k when `budget_chars` omitted | `symbol-neighborhood` (once per name) | +| One-hop symbol card | **`node`** (`name`, `kind?`, `in?`, `include_snippets?`, `budget_chars?`) — adaptive snippet caps when snippets enabled | `show` + `symbol-neighborhood` with `depth=1` | +| Affected tests | **`affected`** (`paths?`, `changed_since?`, `test_glob?`, `max_depth?`) | `affected-tests` (RS-delimit multiple paths in `query_recipe` params) | +| CI / SARIF | **`query_recipe`** + `format: "sarif"` | `deprecated-symbols`, `boundary-violations`, … | +| Ad-hoc SQL | **`query`** | — | +| N statements / one round-trip | **`query_batch`** | **`codemap query batch`** | +| Index freshness (index-level) | **`context`** (`index_freshness`) + tool metadata above | — | +| Per-file staleness | **`validate`** | — | +| Drift vs baseline | **`audit`** (`baseline_prefix` and/or per-delta `baselines`) or **`query`** / **`query_recipe`** + `baseline` (one-shot row diff vs `query_baselines`; incompatible with non-`json` `format` / `group_by`) | save via **`save_baseline`**; `summary: true` → count-only diff | +| Load coverage data | **`ingest_coverage`** (`path`, optional `runtime` for V8 dirs; auto-detects Istanbul `.json` / LCOV `.info`) | enables `worst-covered-exports`, `files-by-coverage`, `untested-and-dead` | +| Apply recipe diff rows | **`apply`** (`recipe`, `params?`, `dry_run?`, `yes?`, `force?`, `until_empty?`, `max_passes?`, `commit_message?`) | recipe must emit `{file_path, line_start, before_pattern, after_pattern}` rows; `yes: true` required for writes; non-`auto_fixable` recipes need `force: true` | +| Apply agent/codemod rows | **`apply_rows`** (`rows`, `dry_run?`, `yes?`) | same row contract; bypasses recipe `auto_fixable` / allowlist gates | +| Apply unified diff text | **`apply_diff_input`** (`diff_text`, `dry_run?`, `yes?`, `commit_message?`) | parses git-style hunks; same executor as `apply_rows` | ## Chains diff --git a/templates/agent-content/skill/10-recipes-context.md b/templates/agent-content/skill/10-recipes-context.md index 653fbb55..d1da404e 100644 --- a/templates/agent-content/skill/10-recipes-context.md +++ b/templates/agent-content/skill/10-recipes-context.md @@ -27,7 +27,7 @@ Replace placeholders (`'...'`) with your module path, file glob, or symbol name. Each emitted delta carries its own `base` metadata so mixed-baseline audits are first-class. **`--base `** materialises any git committish via `git archive | tar -x` + reindex (mutually exclusive with `--baseline`). **`--format sarif`** emits SARIF 2.1.0 for Code Scanning; **`--ci`** aliases `--format sarif` + non-zero exit on additions (mutually exclusive with `--json`). `--summary` collapses each delta to `{added: N, removed: N}`. `--no-index` skips the auto-incremental-index prelude (default is to re-index first so `head` reflects current source). v1 ships no `verdict` / threshold config — `codemap audit --json | jq -e '.deltas.dependencies.added | length <= 50'` is the CI exit-code idiom until v1.x ships native thresholds. Each delta pins a canonical SQL projection and validates baseline column-set membership before diffing — schema-bump-resilient (extras dropped, missing columns surface a clean re-save command). -**MCP server (`codemap mcp [--no-watch] [--debounce ]`)** — separate top-level command exposing the structural-query surface (19 JSON-RPC tools — list below) to agent hosts (Claude Code, Cursor, Codex, generic MCP clients) over stdio. Eliminates the bash round-trip on every agent call. Bootstrap once at server boot; tool handlers reuse the existing engine entry-points — each tool returns the same JSON payload its CLI `--json` would print (including `query batch`, `trace`, `explore`, `node`, `file`, `schema`, `symbols`, and `context --include-snippets`). MCP wraps payloads in `{content: [{type: "text", text: …}]}`. **`initialize` instructions** + resource `codemap://mcp-instructions` carry the tool-selection playbook. **Watcher default-ON since 2026-05** — every tool reads a live index, `audit`'s incremental-index prelude becomes a no-op. Pass `--no-watch` (or `CODEMAP_WATCH=0`) for one-shot fire-and-forget calls without the in-process chokidar loop. +**MCP server (`codemap mcp [--no-watch] [--debounce ]`)** — separate top-level command exposing the structural-query surface (20 JSON-RPC tools — list below) to agent hosts (Claude Code, Cursor, Codex, generic MCP clients) over stdio. Eliminates the bash round-trip on every agent call. Bootstrap once at server boot; each tool returns the same JSON payload its CLI `--json` would print (including `query batch`, `trace`, `explore`, `node`, `file`, `schema`, `symbols`, and `context --include-snippets`). MCP wraps payloads in `{content: [{type: "text", text: …}]}`. **`initialize` instructions** + resource `codemap://mcp-instructions` carry the tool-selection playbook. **Watcher default-ON since 2026-05** — every tool reads a live index, `audit`'s incremental-index prelude becomes a no-op. Pass `--no-watch` (or `CODEMAP_WATCH=0`) for one-shot fire-and-forget calls without the in-process chokidar loop. **HTTP server (`codemap serve [--host 127.0.0.1] [--port 7878] [--token ] [--no-watch] [--debounce ]`)** — same tool taxonomy as MCP, exposed over `POST /tool/{name}` for non-MCP consumers (CI scripts, simple `curl`, IDE plugins that don't speak MCP). Loopback-default; optional Bearer-token auth. HTTP returns each tool's native JSON payload directly (NOT MCP's `{content: [...]}` wrapper); SARIF / annotations / mermaid / diff payloads ship with `application/sarif+json` or `text/plain` Content-Type; `format: "diff-json"` uses `application/json`. Resources mirrored at `GET /resources/{encoded-uri}`. `GET /health` is auth-exempt; `GET /tools` / `GET /resources` are catalogs. **Watcher default-ON since 2026-05** — same `--no-watch` / `CODEMAP_WATCH=0` opt-out as `mcp`. @@ -35,9 +35,9 @@ Each emitted delta carries its own `base` metadata so mixed-baseline audits are **Tools** — snake_case keys (Codemap convention; CLI stays kebab — translation at the MCP arg layer). Each tool returns the same JSON payload its CLI twin would print. Run `codemap --help` (or `codemap query --recipes-json` / the CLI verb's docs) for the authoritative parameter list and result shape; the entries below are existence + transport notes only. -- **`query`** — `{sql, summary?, changed_since?, group_by?, format?}`. One read-only SQL. `format` accepts `sarif | annotations | mermaid | diff | diff-json` (incompatible with `summary` / `group_by`). +- **`query`** — `{sql, summary?, changed_since?, group_by?, format?, baseline?}`. One read-only SQL. `format` accepts `sarif | annotations | mermaid | diff | diff-json` (incompatible with `summary` / `group_by` / `baseline`). - **`query_batch`** — `{statements: (string | {sql, summary?, changed_since?, group_by?})[]}`. CLI: `codemap query batch [--stdin | --file ]`. N statements / one round-trip; per-statement errors isolated. -- **`query_recipe`** — `{recipe, params?, summary?, changed_since?, group_by?, format?}`. Resolves a recipe id to SQL + params + per-row actions, then executes. Unknown id → structured `{error}` pointing at `codemap://recipes`. +- **`query_recipe`** — `{recipe, params?, summary?, changed_since?, group_by?, format?, baseline?}`. Resolves a recipe id to SQL + params + per-row actions, then executes. Unknown id → structured `{error}` pointing at `codemap://recipes`. - **`audit`** — `{base?, baseline_prefix?, baselines?, summary?, no_index?}`. Composes snapshot sources into `{head, deltas}`. `base` (git committish, sha-keyed cache) and `baseline_prefix` are mutually exclusive; per-delta `baselines` overrides compose with either. - **`save_baseline`** — polymorphic `{name, sql? | recipe?}` (exactly one of `sql` / `recipe`). - **`list_baselines`** — no args; returns the array `codemap query --baselines --json` would print. @@ -54,6 +54,7 @@ Each emitted delta carries its own `base` metadata so mixed-baseline audits are - **`apply`** — `{recipe, params?, dry_run?, yes?, force?, until_empty?, max_passes?, commit_message?}`. Executes diff hunks from recipe SQL (`{file_path, line_start, before_pattern, after_pattern}`). Writes require `yes: true`; recipes without `auto_fixable: true` need `force: true` unless config allowlist covers them. **All-or-nothing** on conflicts. `dry_run` and `yes` are mutually exclusive. Fixpoint: `until_empty` (+ optional `max_passes`, default 10) → `passes` / `terminated_by` on the envelope; optional `commit_message` runs git commit on touched files after a clean apply. - **`apply_rows`** — `{rows, dry_run?, yes?}`. Same executor with caller-supplied rows (no recipe policy gates). CLI twin: `codemap apply --rows -|`. - **`apply_diff_input`** — `{diff_text, dry_run?, yes?, commit_message?}`. Unified diff → row contract (git-style `-`/`+` hunks). CLI twin: `codemap apply --diff-input `. +- **`ingest_coverage`** — `{path, runtime?}`. Load Istanbul / LCOV / V8 coverage into the index (CLI twin: `codemap ingest-coverage --json`). Enables coverage recipes (`worst-covered-exports`, …). **Apply workflow (discover → preview → apply):** From ba7610a0cd633e6d1df44b3beedabe5ecce65b45 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Fri, 5 Jun 2026 10:34:25 +0300 Subject: [PATCH 3/5] fix(mcp): address PR #167 review cycle 3 Expand ingest-coverage and HTTP transport tests, document baseline/ingest_coverage on MCP/HTTP surfaces, and fix V8 runtime test fixtures. --- README.md | 1 + docs/architecture.md | 2 +- docs/glossary.md | 2 +- src/application/http-server.test.ts | 19 +++ src/application/ingest-coverage-run.test.ts | 149 ++++++++++++++++++ src/application/tool-handlers.test.ts | 26 +++ .../agent-content/skill/10-recipes-context.md | 2 +- 7 files changed, 198 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 7a919657..ab63630b 100644 --- a/README.md +++ b/README.md @@ -237,6 +237,7 @@ codemap mcp # JSON-RPC on st # affected, trace, explore, node, apply, apply_rows, apply_diff_input, # ingest_coverage # CLI twins: query batch, trace, explore, node, file, schema, symbols, context --include-snippets, ingest-coverage (same JSON as MCP/HTTP). +# query / query_recipe also accept baseline (same diff envelope as codemap query --baseline). # Resources: codemap://schema, codemap://skill, codemap://rule, codemap://mcp-instructions (lazy-cached); # codemap://recipes, codemap://recipes/{id} (live read-per-call — recency fields stay fresh); # codemap://files/{path}, codemap://symbols/{name} (live read-per-call) diff --git a/docs/architecture.md b/docs/architecture.md index 835df0e0..a17d9f0f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -174,7 +174,7 @@ Three **mutually exclusive** CLI entry shapes; all converge on `applyDiffPayload **Tool / resource handlers (transport-agnostic):** **`src/application/tool-handlers.ts`** + **`src/application/resource-handlers.ts`** — pure functions that take the args object an MCP tool / resource URI accepts and return a discriminated **`ToolResult`** (`{ok: true, format: 'json'|'sarif'|'annotations'|'mermaid'|'diff'|'diff-json', payload}` / `{ok: false, error}`) or a **`ResourcePayload`** (`{mimeType, text}`). MCP and HTTP both wrap the same handlers — MCP translates to `{content: [{type: "text", text}]}`, HTTP translates to `(status, body)` with the right `Content-Type`. Engine layer untouched; transport changes don't ripple into the SQL. -**MCP wiring:** **`src/cli/cmd-mcp.ts`** (argv — `--watch` / `--no-watch` / `--debounce` + `--help`; bootstrap absorbs `--root`/`--config`) + **`src/application/mcp-server.ts`** (transport — tool / resource registry, SDK glue). Mirrors the `cmd-audit.ts ↔ audit-engine.ts` seam — CLI parses + lifecycle; engine owns the SDK. **`runMcpServer`** bootstraps codemap once at server boot (config + resolver + DB access become module-level state), instantiates `McpServer` from **`@modelcontextprotocol/sdk`**, attaches a **`StdioServerTransport`**, and resolves on client disconnect via **`src/application/session-lifecycle.ts`** (`createStdioDisconnectMonitor` — stdin EOF, stdout EPIPE, parent-PID poll — plus SDK `transport.onclose` and SIGINT/SIGTERM). With `--watch`, **`createManagedWatchSession`** holds one client for the stdio session and **`forceStop`** drains the watcher on exit. Tool handlers reuse the existing engine entry-points: **`query`** + **`query_recipe`** call **`executeQuery`** in **`src/application/query-engine.ts`** (a pure transport-agnostic engine extracted from `printQueryResult`'s JSON branch — same `[...rows]` / `{count}` / `{group_by, groups}` envelope `--json` would print); **`query_batch`** loops per statement via **`handleQueryBatch`** → **`executeQuery`** (batch-wide defaults + per-item overrides; items are `string | {sql, summary?, changed_since?, group_by?}`); **`audit`** runs `resolveAuditBaselines` + `runAudit` from PR #33 unchanged; **`context`** / **`validate`** call `buildContextEnvelope` / `computeValidateRows` from **`src/application/context-engine.ts`** + **`src/application/validate-engine.ts`** (lifted out of `src/cli/cmd-*.ts` in PR #41 — see § Tool / resource handlers above). **`save_baseline`** is one polymorphic tool (`{name, sql? | recipe?}`) with a runtime exclusivity check — mirrors the CLI's single `--save-baseline=` verb. **Tool naming**: snake_case throughout — Codemap convention matching the patterns in MCP spec examples and reference servers (GitHub MCP, Cursor built-ins); the spec itself doesn't mandate it. CLI stays kebab — translation lives at the MCP-arg layer. **Resources** split by freshness contract: `codemap://schema`, `codemap://skill`, `codemap://rule`, and `codemap://mcp-instructions` use **lazy memoisation** — first `read_resource` populates a per-server-instance cache; constant for the server-process lifetime so eager-vs-lazy produce identical observable behavior. `codemap://recipes`, `codemap://recipes/{id}`, `codemap://files/{+path}`, and `codemap://symbols/{name}` are **live read-per-call** (no cache) so inline recency fields and index mutations under `--watch` don't freeze at first-read. `codemap://schema` queries `sqlite_schema` live (on first read, then cached); `codemap://skill` / `codemap://rule` / `codemap://mcp-instructions` call `assembleAgentContent(kind)` from `application/agent-content.ts`, which concatenates section files under `templates/agent-content//` and dispatches `*.gen.md` files through `RENDERERS` (live recipe catalog, live `createTables()` DDL) — see [agents.md § Section assembler](./agents.md#section-assembler-and-genmd). Output shape: each tool returns the JSON payload its CLI counterpart would print (`query batch`, `trace`, `explore`, `node`, `file`, `schema`, `context --include-snippets`); MCP wraps via `content: [{type: "text", text: JSON.stringify(payload)}]`. `--changed-since` git lookups are memoised per `(root, ref)` pair across batch items so a `query_batch` of N items sharing the same ref does one git invocation, not N. Per-statement errors in `query_batch` are isolated — failed statements return `{error}` in their slot while siblings still execute. +**MCP wiring:** **`src/cli/cmd-mcp.ts`** (argv — `--watch` / `--no-watch` / `--debounce` + `--help`; bootstrap absorbs `--root`/`--config`) + **`src/application/mcp-server.ts`** (transport — tool / resource registry, SDK glue). Mirrors the `cmd-audit.ts ↔ audit-engine.ts` seam — CLI parses + lifecycle; engine owns the SDK. **`runMcpServer`** bootstraps codemap once at server boot (config + resolver + DB access become module-level state), instantiates `McpServer` from **`@modelcontextprotocol/sdk`**, attaches a **`StdioServerTransport`**, and resolves on client disconnect via **`src/application/session-lifecycle.ts`** (`createStdioDisconnectMonitor` — stdin EOF, stdout EPIPE, parent-PID poll — plus SDK `transport.onclose` and SIGINT/SIGTERM). With `--watch`, **`createManagedWatchSession`** holds one client for the stdio session and **`forceStop`** drains the watcher on exit. Tool handlers reuse the existing engine entry-points: **`query`** / **`query_recipe`** call **`executeQuery`** in **`src/application/query-engine.ts`** (same `[...rows]` / `{count}` / `{group_by, groups}` envelope `--json` would print) unless **`baseline`** is set — then **`compareQueryBaseline`** in **`src/application/query-baseline.ts`** (incompatible with non-`json` **`format`** / **`group_by`**); **`ingest_coverage`** calls **`runIngestCoverageOnDb`** in **`src/application/ingest-coverage-run.ts`** (CLI twin: `codemap ingest-coverage --json`); **`query_batch`** loops per statement via **`handleQueryBatch`** → **`executeQuery`** (batch-wide defaults + per-item overrides; items are `string | {sql, summary?, changed_since?, group_by?}`); **`audit`** runs `resolveAuditBaselines` + `runAudit` from PR #33 unchanged; **`context`** / **`validate`** call `buildContextEnvelope` / `computeValidateRows` from **`src/application/context-engine.ts`** + **`src/application/validate-engine.ts`** (lifted out of `src/cli/cmd-*.ts` in PR #41 — see § Tool / resource handlers above). **`save_baseline`** is one polymorphic tool (`{name, sql? | recipe?}`) with a runtime exclusivity check — mirrors the CLI's single `--save-baseline=` verb. **Tool naming**: snake_case throughout — Codemap convention matching the patterns in MCP spec examples and reference servers (GitHub MCP, Cursor built-ins); the spec itself doesn't mandate it. CLI stays kebab — translation lives at the MCP-arg layer. **Resources** split by freshness contract: `codemap://schema`, `codemap://skill`, `codemap://rule`, and `codemap://mcp-instructions` use **lazy memoisation** — first `read_resource` populates a per-server-instance cache; constant for the server-process lifetime so eager-vs-lazy produce identical observable behavior. `codemap://recipes`, `codemap://recipes/{id}`, `codemap://files/{+path}`, and `codemap://symbols/{name}` are **live read-per-call** (no cache) so inline recency fields and index mutations under `--watch` don't freeze at first-read. `codemap://schema` queries `sqlite_schema` live (on first read, then cached); `codemap://skill` / `codemap://rule` / `codemap://mcp-instructions` call `assembleAgentContent(kind)` from `application/agent-content.ts`, which concatenates section files under `templates/agent-content//` and dispatches `*.gen.md` files through `RENDERERS` (live recipe catalog, live `createTables()` DDL) — see [agents.md § Section assembler](./agents.md#section-assembler-and-genmd). Output shape: each tool returns the JSON payload its CLI counterpart would print (`query batch`, `trace`, `explore`, `node`, `file`, `schema`, `context --include-snippets`, `ingest-coverage`); MCP wraps via `content: [{type: "text", text: JSON.stringify(payload)}]`. `--changed-since` git lookups are memoised per `(root, ref)` pair across batch items so a `query_batch` of N items sharing the same ref does one git invocation, not N. Per-statement errors in `query_batch` are isolated — failed statements return `{error}` in their slot while siblings still execute. **HTTP wiring:** **`src/cli/cmd-serve.ts`** (argv — `--host` / `--port` / `--token`; bootstrap absorbs `--root`/`--config`) + **`src/application/http-server.ts`** (transport — bare `node:http`; routes `POST /tool/{name}` to `tool-handlers`, `GET /resources/{encoded-uri}` to `resource-handlers`, plus `GET /health` / `GET /tools` / `GET /resources`). Default bind **`127.0.0.1:7878`** (loopback only — refuse `0.0.0.0` unless explicitly opted in via `--host 0.0.0.0`). Optional **`--token `** requires `Authorization: Bearer ` on every request; `GET /health` is auth-exempt so liveness probes work without leaking the token. **CSRF + DNS-rebinding guard** (`csrfCheck`) runs before every route — rejects `Sec-Fetch-Site: cross-site` / `same-site` (modern-browser CSRF), any present `Origin` header (including the opaque string `null`; older-browser CSRF fallback), and `Host` header mismatch on loopback bind (DNS rebinding). Non-browser clients (curl, fetch from Node, MCP hosts, CI scripts) don't send those headers and pass through. The guard runs even on `/health` so a malicious local webpage can't probe for liveness. Output shape: HTTP returns each tool's native JSON payload directly (NOT MCP's `{content: [...]}` wrapper — HTTP doesn't need that transport artifact); `query` / `query_recipe` match `codemap query --json` row arrays (or `{count}` / `{group_by,groups}` when `summary` / `group_by` is set, or baseline diff when `baseline` is set — incompatible with non-`json` `format` / `group_by`; save/list/drop remain separate tools); other tools match their CLI `--json` envelopes; `format: "sarif"` payloads ship as `application/sarif+json`, `format: "annotations"` / `"mermaid"` / `"diff"` as `text/plain; charset=utf-8`, `format: "diff-json"` as `application/json; charset=utf-8`, JSON otherwise. Per-request DB lifecycle: open / `PRAGMA query_only = 1` / close per call (SQLite reader concurrency); 1 MiB request-body cap rejects trivial DoS. SIGINT / SIGTERM → graceful drain via `server.close()`. Every response carries **`X-Codemap-Version: `** so consumers can pin / detect upgrades. diff --git a/docs/glossary.md b/docs/glossary.md index f50939b5..559df2b2 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -153,7 +153,7 @@ Statement coverage ingested from Istanbul JSON, LCOV, or V8 runtime (`NODE_V8_CO ### `codemap ingest-coverage` / Istanbul JSON / LCOV / V8 runtime / static coverage ingestion -`codemap ingest-coverage [--runtime] [--json]` reads a coverage artifact and writes statement-level rows into the `coverage` table. Three formats: +`codemap ingest-coverage [--runtime] [--json]` reads a coverage artifact and writes statement-level rows into the `coverage` table. MCP/HTTP twin: **`ingest_coverage`** (`{path, runtime?}`). Three formats: - **Istanbul JSON** (`coverage-final.json`) — emitted natively by `c8`, `nyc`, `vitest --coverage --coverage.reporter=json`, `jest --coverage --coverageReporters=json`. Parser reads `statementMap` + `s` (per-statement hit counts). - **LCOV** (`lcov.info`) — emitted by `bun test --coverage`, `c8 --reporter=lcov`, every legacy stack. Parser tokenises `SF:` / `DA:,` / `end_of_record` records; ignores `TN:` / `FN:` / `BRDA:` / `LF:` / `LH:` (statement coverage only). diff --git a/src/application/http-server.test.ts b/src/application/http-server.test.ts index ff088dc9..4f2f63f3 100644 --- a/src/application/http-server.test.ts +++ b/src/application/http-server.test.ts @@ -731,6 +731,25 @@ describe("http-server — POST /tool/{other tools}", () => { expect(r.json.error).toContain("does-not-exist"); }); + it("ingest_coverage returns 400 when path is missing", async () => { + serverHandle = await startServer(); + const r = await postTool(serverHandle.port, "ingest_coverage", { + path: "no-such/coverage-final.json", + }); + expect(r.status).toBe(400); + expect(r.json.error).toContain("path not found"); + }); + + it("query with missing baseline returns 404", async () => { + serverHandle = await startServer(); + const r = await postTool(serverHandle.port, "query", { + sql: "SELECT 1", + baseline: "does-not-exist", + }); + expect(r.status).toBe(404); + expect(r.json.error).toContain("does-not-exist"); + }); + it("save_baseline returns 404 for unknown recipe", async () => { serverHandle = await startServer(); const r = await postTool(serverHandle.port, "save_baseline", { diff --git a/src/application/ingest-coverage-run.test.ts b/src/application/ingest-coverage-run.test.ts index 5c676f68..8af6c841 100644 --- a/src/application/ingest-coverage-run.test.ts +++ b/src/application/ingest-coverage-run.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { pathToFileURL } from "node:url"; import { resolveCodemapConfig } from "../config"; import { @@ -82,6 +83,25 @@ describe("resolveCoverageArtifact", () => { /contains neither/, ); }); + + it("resolves directory with istanbul only", () => { + const dir = join(projectRoot, "cov-istanbul"); + mkdirSync(dir); + const file = join(dir, "coverage-final.json"); + writeFileSync(file, "{}"); + expect(resolveCoverageArtifact(dir, projectRoot)).toEqual({ + format: "istanbul", + absPath: file, + }); + }); + + it("errors on undetectable file extension", () => { + const file = join(projectRoot, "coverage.txt"); + writeFileSync(file, "data"); + expect(() => resolveCoverageArtifact(file, projectRoot)).toThrow( + /cannot auto-detect format/, + ); + }); }); describe("resolveV8CoverageDirectory", () => { @@ -189,6 +209,135 @@ describe("runIngestCoverageOnDb", () => { } }); + it("ingests lcov artifact into coverage table", async () => { + const db = openCodemapDatabase(":memory:"); + try { + createTables(db); + createIndexes(db); + insertFile(db, { + path: "src/lib/cache.ts", + content_hash: "h1", + size: 1, + line_count: 100, + language: "typescript", + last_modified: 0, + indexed_at: 0, + }); + insertSymbols(db, [ + { + file_path: "src/lib/cache.ts", + name: "get", + kind: "function", + line_start: 9, + line_end: 15, + signature: "get(): void", + is_exported: 1, + is_default_export: 0, + members: null, + doc_comment: null, + value: null, + parent_name: null, + visibility: null, + }, + ]); + + const lcov = [ + "TN:", + `SF:${projectRoot}/src/lib/cache.ts`, + "DA:10,1", + "end_of_record", + "", + ].join("\n"); + writeFileSync(join(projectRoot, "lcov.info"), lcov); + + const outcome = await runIngestCoverageOnDb(db, { + projectRoot, + path: "lcov.info", + }); + expect(outcome.ok).toBe(true); + if (!outcome.ok) return; + expect(outcome.result.format).toBe("lcov"); + expect(outcome.result.ingested.symbols).toBe(1); + } finally { + closeDb(db); + } + }); + + it("ingests v8 runtime directory into coverage table", async () => { + const db = openCodemapDatabase(":memory:"); + try { + createTables(db); + createIndexes(db); + insertFile(db, { + path: "src/lib/cache.ts", + content_hash: "h1", + size: 1, + line_count: 100, + language: "typescript", + last_modified: 0, + indexed_at: 0, + }); + insertSymbols(db, [ + { + file_path: "src/lib/cache.ts", + name: "get", + kind: "function", + line_start: 1, + line_end: 3, + signature: "get(): void", + is_exported: 1, + is_default_export: 0, + members: null, + doc_comment: null, + value: null, + parent_name: null, + visibility: null, + }, + ]); + + const source = "export function get() {\n return 1;\n}\n"; + mkdirSync(join(projectRoot, "src/lib"), { recursive: true }); + writeFileSync(join(projectRoot, "src/lib/cache.ts"), source); + const url = pathToFileURL( + join(projectRoot, "src/lib/cache.ts"), + ).toString(); + const dir = join(projectRoot, "v8-runtime"); + mkdirSync(dir); + writeFileSync( + join(dir, "coverage-1.json"), + JSON.stringify({ + result: [ + { + scriptId: "1", + url, + functions: [ + { + functionName: "get", + isBlockCoverage: true, + ranges: [ + { startOffset: 0, endOffset: source.length, count: 1 }, + ], + }, + ], + }, + ], + }), + ); + + const outcome = await runIngestCoverageOnDb(db, { + projectRoot, + path: "v8-runtime", + runtime: true, + }); + expect(outcome.ok).toBe(true); + if (!outcome.ok) return; + expect(outcome.result.format).toBe("v8"); + expect(outcome.result.ingested.symbols).toBeGreaterThan(0); + } finally { + closeDb(db); + } + }); + it("returns ok:false when runtime json files have empty result", async () => { const db = openCodemapDatabase(":memory:"); try { diff --git a/src/application/tool-handlers.test.ts b/src/application/tool-handlers.test.ts index 24ce5746..398bc49d 100644 --- a/src/application/tool-handlers.test.ts +++ b/src/application/tool-handlers.test.ts @@ -117,6 +117,32 @@ describe("handleQuery baseline", () => { error: expect.stringContaining('no baseline named "missing-baseline"'), }); }); + + it("returns 400 for corrupt baseline rows_json", () => { + const db = openDb(); + try { + upsertQueryBaseline(db, { + name: "bad", + recipe_id: null, + sql: "SELECT 1", + rows_json: "not-json", + row_count: 0, + git_ref: null, + created_at: 1, + }); + } finally { + closeDb(db); + } + const result = handleQuery( + { sql: "SELECT 1", baseline: "bad" }, + projectRoot, + ); + expect(result).toMatchObject({ + ok: false, + status: 400, + error: expect.stringContaining("corrupt rows_json"), + }); + }); }); describe("handleQueryRecipe baseline", () => { diff --git a/templates/agent-content/skill/10-recipes-context.md b/templates/agent-content/skill/10-recipes-context.md index d1da404e..db2d8837 100644 --- a/templates/agent-content/skill/10-recipes-context.md +++ b/templates/agent-content/skill/10-recipes-context.md @@ -27,7 +27,7 @@ Replace placeholders (`'...'`) with your module path, file glob, or symbol name. Each emitted delta carries its own `base` metadata so mixed-baseline audits are first-class. **`--base `** materialises any git committish via `git archive | tar -x` + reindex (mutually exclusive with `--baseline`). **`--format sarif`** emits SARIF 2.1.0 for Code Scanning; **`--ci`** aliases `--format sarif` + non-zero exit on additions (mutually exclusive with `--json`). `--summary` collapses each delta to `{added: N, removed: N}`. `--no-index` skips the auto-incremental-index prelude (default is to re-index first so `head` reflects current source). v1 ships no `verdict` / threshold config — `codemap audit --json | jq -e '.deltas.dependencies.added | length <= 50'` is the CI exit-code idiom until v1.x ships native thresholds. Each delta pins a canonical SQL projection and validates baseline column-set membership before diffing — schema-bump-resilient (extras dropped, missing columns surface a clean re-save command). -**MCP server (`codemap mcp [--no-watch] [--debounce ]`)** — separate top-level command exposing the structural-query surface (20 JSON-RPC tools — list below) to agent hosts (Claude Code, Cursor, Codex, generic MCP clients) over stdio. Eliminates the bash round-trip on every agent call. Bootstrap once at server boot; each tool returns the same JSON payload its CLI `--json` would print (including `query batch`, `trace`, `explore`, `node`, `file`, `schema`, `symbols`, and `context --include-snippets`). MCP wraps payloads in `{content: [{type: "text", text: …}]}`. **`initialize` instructions** + resource `codemap://mcp-instructions` carry the tool-selection playbook. **Watcher default-ON since 2026-05** — every tool reads a live index, `audit`'s incremental-index prelude becomes a no-op. Pass `--no-watch` (or `CODEMAP_WATCH=0`) for one-shot fire-and-forget calls without the in-process chokidar loop. +**MCP server (`codemap mcp [--no-watch] [--debounce ]`)** — separate top-level command exposing the structural-query surface (20 JSON-RPC tools — list below) to agent hosts (Claude Code, Cursor, Codex, generic MCP clients) over stdio. Eliminates the bash round-trip on every agent call. Bootstrap once at server boot; each tool returns the same JSON payload its CLI `--json` would print (including `query batch`, `trace`, `explore`, `node`, `file`, `schema`, `symbols`, `context --include-snippets`, and `ingest-coverage`). MCP wraps payloads in `{content: [{type: "text", text: …}]}`. **`initialize` instructions** + resource `codemap://mcp-instructions` carry the tool-selection playbook. **Watcher default-ON since 2026-05** — every tool reads a live index, `audit`'s incremental-index prelude becomes a no-op. Pass `--no-watch` (or `CODEMAP_WATCH=0`) for one-shot fire-and-forget calls without the in-process chokidar loop. **HTTP server (`codemap serve [--host 127.0.0.1] [--port 7878] [--token ] [--no-watch] [--debounce ]`)** — same tool taxonomy as MCP, exposed over `POST /tool/{name}` for non-MCP consumers (CI scripts, simple `curl`, IDE plugins that don't speak MCP). Loopback-default; optional Bearer-token auth. HTTP returns each tool's native JSON payload directly (NOT MCP's `{content: [...]}` wrapper); SARIF / annotations / mermaid / diff payloads ship with `application/sarif+json` or `text/plain` Content-Type; `format: "diff-json"` uses `application/json`. Resources mirrored at `GET /resources/{encoded-uri}`. `GET /health` is auth-exempt; `GET /tools` / `GET /resources` are catalogs. **Watcher default-ON since 2026-05** — same `--no-watch` / `CODEMAP_WATCH=0` opt-out as `mcp`. From a28f44f11fb2c69f6b872b3129d382d836dae073 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Fri, 5 Jun 2026 10:39:39 +0300 Subject: [PATCH 4/5] fix(mcp): address PR #167 review cycle 4 Delete shipped plan docs, fix consumer-surface leak, document ingest_coverage in CSRF guard, and add MCP/HTTP transport tests for baseline compare and ingest_coverage. --- docs/plans/ingest-coverage-mcp.md | 37 ----- docs/plans/query-baseline-mcp-parity.md | 38 ----- src/application/http-server.test.ts | 109 ++++++++++++- src/application/http-server.ts | 5 +- src/application/mcp-server.test.ts | 146 ++++++++++++++++++ src/application/tool-handlers.test.ts | 75 +++++++++ .../agent-content/skill/10-recipes-context.md | 2 +- 7 files changed, 333 insertions(+), 79 deletions(-) delete mode 100644 docs/plans/ingest-coverage-mcp.md delete mode 100644 docs/plans/query-baseline-mcp-parity.md diff --git a/docs/plans/ingest-coverage-mcp.md b/docs/plans/ingest-coverage-mcp.md deleted file mode 100644 index 1e3b9882..00000000 --- a/docs/plans/ingest-coverage-mcp.md +++ /dev/null @@ -1,37 +0,0 @@ -# Ingest coverage — MCP/HTTP parity — plan - -> **Status:** open · **Priority:** P1 (transport parity) · **Effort:** S (~1 day) -> -> **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. -> -> **Roadmap:** transport parity (agent-relevant core) - ---- - -## Pre-locked decisions - -| # | Decision | Source | -| --- | ------------------------------------------------------------------------------------------------------ | -------------------------- | -| I.1 | **Thin transport twin** — same `IngestResult` envelope as CLI `--json`; no new semantics. | Transport-agnostic engines | -| I.2 | **Lift orchestration** to `application/ingest-coverage-run.ts`; CLI `cmd-ingest-coverage.ts` calls it. | `architecture.md` layering | -| I.3 | **Tool name** `ingest_coverage` (snake_case MCP); HTTP `POST /tool/ingest_coverage`. | MCP convention | -| I.4 | **Args:** `path` (required), `runtime` (optional, V8 dir), mirrors CLI flags. | CLI parity | -| I.5 | **No human text mode** on MCP — JSON only (infra output format excluded). | Agent transport | - ---- - -## Implementation steps - -1. Extract `runIngestCoverage` + path resolvers from `cmd-ingest-coverage.ts` → `ingest-coverage-run.ts` -2. `handleIngestCoverage` in `tool-handlers.ts` -3. Register MCP tool + HTTP dispatch + `MCP_TOOL_NAMES` allowlist -4. Tests: `ingest-coverage-run.test.ts` (minimal), `tool-handlers.test.ts` smoke -5. Update `cmd-mcp.ts` help, `mcp-instructions.md`, `architecture.md` apply/coverage wiring - ---- - -## Acceptance - -- [x] MCP `ingest_coverage` ingests Istanbul fixture → `coverage` rows queryable -- [x] CLI `ingest-coverage` unchanged behavior (refactor only) -- [x] HTTP `POST /tool/ingest_coverage` returns same JSON as CLI `--json` diff --git a/docs/plans/query-baseline-mcp-parity.md b/docs/plans/query-baseline-mcp-parity.md deleted file mode 100644 index 7ced723a..00000000 --- a/docs/plans/query-baseline-mcp-parity.md +++ /dev/null @@ -1,38 +0,0 @@ -# Query baseline compare — MCP/HTTP parity — plan - -> **Status:** open · **Priority:** P1 (transport parity) · **Effort:** S (~1 day) -> -> **Motivator:** CLI `codemap query --baseline=` 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`. -> -> **Roadmap:** transport parity (agent-relevant core) - ---- - -## Pre-locked decisions - -| # | Decision | Source | -| --- | ------------------------------------------------------------------------------------------------------------------------------ | -------------------------------- | -| B.1 | **Extend existing tools** — add optional `baseline: string` to `query` and `query_recipe`; no new tool. | Moat A — recipes stay the API | -| B.2 | **Same envelope** as CLI `--json --baseline`: `{baseline, current_row_count, added, removed}`; `summary: true` → count fields. | `cmd-query.ts` `runBaselineDiff` | -| B.3 | **Recipe `actions` on `added` rows only** — same as CLI. | Apply discover loop | -| B.4 | **Reject combos** — `baseline` + `format` (sarif/annotations/mermaid/diff/diff-json) or `group_by`; mirrors CLI parser. | Output-shape contract | -| B.5 | **Engine** — `application/query-baseline.ts` (`compareQueryBaseline`); shared by MCP handlers (CLI refactor optional later). | Layering | - ---- - -## Implementation steps - -1. Add `compareQueryBaseline` in `application/query-baseline.ts` (uses `diffRows`, `getQueryBaseline`, `filterRowsByChangedFiles`) -2. Wire `baseline?: string` into `queryArgsSchema` / `queryRecipeArgsSchema` -3. Early branch in `handleQuery` / `handleQueryRecipe` before `executeQuery` / formatted paths -4. Tests: `query-baseline.test.ts` + `tool-handlers.test.ts` baseline diff case -5. Update MCP tool descriptions + `templates/agent-content/mcp-instructions.md` - ---- - -## Acceptance - -- [x] `query_recipe` + `baseline` + `summary` returns `{added: N, removed: N}` counts -- [x] `query_recipe` + `baseline` returns full diff with `actions` on `added` when recipe declares them -- [x] Missing baseline name → `{error}` envelope -- [x] `baseline` + `format: sarif` → error (incompatible) diff --git a/src/application/http-server.test.ts b/src/application/http-server.test.ts index 4f2f63f3..bc62b985 100644 --- a/src/application/http-server.test.ts +++ b/src/application/http-server.test.ts @@ -12,7 +12,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { resolveCodemapConfig } from "../config"; -import { closeDb, createTables, openDb } from "../db"; +import { closeDb, createTables, openDb, upsertQueryBaseline } from "../db"; import { initCodemap } from "../runtime"; import { handleRequest } from "./http-server"; import { createManagedWatchSession } from "./session-lifecycle"; @@ -750,6 +750,113 @@ describe("http-server — POST /tool/{other tools}", () => { expect(r.json.error).toContain("does-not-exist"); }); + it("query with baseline diff returns 200 envelope", async () => { + const db = openDb(); + try { + upsertQueryBaseline(db, { + name: "snap-files", + recipe_id: null, + sql: "SELECT path FROM files ORDER BY path", + rows_json: JSON.stringify([{ path: "src/a.ts" }]), + row_count: 1, + git_ref: null, + created_at: 1, + }); + } finally { + closeDb(db); + } + serverHandle = await startServer(); + const r = await postTool(serverHandle.port, "query", { + sql: "SELECT path FROM files ORDER BY path", + baseline: "snap-files", + }); + expect(r.status).toBe(200); + expect(r.json).toMatchObject({ + added: [{ path: "src/b.ts" }], + removed: [], + }); + }); + + it("query rejects baseline + group_by with 400", async () => { + serverHandle = await startServer(); + const r = await postTool(serverHandle.port, "query", { + sql: "SELECT path FROM files", + baseline: "snap", + group_by: "directory", + }); + expect(r.status).toBe(400); + expect(r.json.error).toContain("group_by"); + }); + + it("query with corrupt baseline rows_json returns 400", async () => { + const db = openDb(); + try { + upsertQueryBaseline(db, { + name: "bad", + recipe_id: null, + sql: "SELECT 1", + rows_json: "not-json", + row_count: 0, + git_ref: null, + created_at: 1, + }); + } finally { + closeDb(db); + } + serverHandle = await startServer(); + const r = await postTool(serverHandle.port, "query", { + sql: "SELECT 1", + baseline: "bad", + }); + expect(r.status).toBe(400); + expect(r.json.error).toContain("corrupt rows_json"); + }); + + it("ingest_coverage ingests istanbul artifact successfully", async () => { + const db = openDb(); + try { + db.run( + `INSERT INTO symbols (file_path, name, kind, line_start, line_end, signature, is_exported, is_default_export) + VALUES ('src/a.ts', 'A', 'const', 1, 1, 'const A', 1, 0)`, + ); + } finally { + closeDb(db); + } + const coverageDir = join(benchDir, "coverage"); + mkdirSync(coverageDir); + writeFileSync( + join(coverageDir, "coverage-final.json"), + JSON.stringify({ + [`${benchDir}/src/a.ts`]: { + path: `${benchDir}/src/a.ts`, + statementMap: { + "0": { start: { line: 1, column: 0 }, end: { line: 1, column: 1 } }, + }, + s: { "0": 1 }, + }, + }), + ); + serverHandle = await startServer(); + const r = await postTool(serverHandle.port, "ingest_coverage", { + path: "coverage/coverage-final.json", + }); + expect(r.status).toBe(200); + expect(r.json).toMatchObject({ + format: "istanbul", + ingested: { symbols: 1 }, + }); + }); + + it("query_recipe with missing baseline returns 404", async () => { + serverHandle = await startServer(); + const r = await postTool(serverHandle.port, "query_recipe", { + recipe: "deprecated-symbols", + baseline: "does-not-exist", + }); + expect(r.status).toBe(404); + expect(r.json.error).toContain("does-not-exist"); + }); + it("save_baseline returns 404 for unknown recipe", async () => { serverHandle = await startServer(); const r = await postTool(serverHandle.port, "save_baseline", { diff --git a/src/application/http-server.ts b/src/application/http-server.ts index b6c10370..a7d9f259 100644 --- a/src/application/http-server.ts +++ b/src/application/http-server.ts @@ -667,8 +667,9 @@ function validate( * issue `fetch('http://127.0.0.1:7878/tool/save_baseline', {method: 'POST', body: '{...}'})`. * The browser sends the request (CORS only blocks the *response* from * being read by JS — the request itself reaches us and any side effect - * executes). For state-changing tools (`save_baseline`, `drop_baseline`) - * this lets a malicious page mutate the developer's `.codemap/index.db`. + * executes). For state-changing tools (`save_baseline`, `drop_baseline`, + * `ingest_coverage`) this lets a malicious page mutate the developer's + * `.codemap/index.db`. * * DNS rebinding extends the same attack: `evil.com` resolves to * `127.0.0.1` after page load; the browser sends `Host: evil.com:7878` diff --git a/src/application/mcp-server.test.ts b/src/application/mcp-server.test.ts index 37caf666..a50bd315 100644 --- a/src/application/mcp-server.test.ts +++ b/src/application/mcp-server.test.ts @@ -884,6 +884,152 @@ describe("MCP server — baseline tools", () => { }); }); +describe("MCP server — ingest_coverage tool", () => { + it("lists ingest_coverage in tools/list", async () => { + const { client, server } = await makeClient(); + try { + const tools = await client.listTools(); + const names = tools.tools.map((t) => t.name); + expect(names).toContain("ingest_coverage"); + } finally { + await server.close(); + } + }); + + it("ingests istanbul artifact and returns IngestResult envelope", async () => { + const db = openDb(); + try { + db.run( + `INSERT INTO symbols (file_path, name, kind, line_start, line_end, signature, is_exported, is_default_export) + VALUES ('src/a.ts', 'A', 'const', 1, 1, 'const A', 1, 0)`, + ); + } finally { + closeDb(db); + } + const coverageDir = join(benchDir, "coverage"); + mkdirSync(coverageDir); + writeFileSync( + join(coverageDir, "coverage-final.json"), + JSON.stringify({ + [`${benchDir}/src/a.ts`]: { + path: `${benchDir}/src/a.ts`, + statementMap: { + "0": { start: { line: 1, column: 0 }, end: { line: 1, column: 1 } }, + }, + s: { "0": 1 }, + }, + }), + ); + + const { client, server } = await makeClient(); + try { + const r = await client.callTool({ + name: "ingest_coverage", + arguments: { path: "coverage/coverage-final.json" }, + }); + expect((r as { isError?: boolean }).isError).toBeUndefined(); + expect(readJson(r)).toMatchObject({ + format: "istanbul", + ingested: { symbols: 1 }, + }); + } finally { + await server.close(); + } + }); + + it("returns isError when path is missing", async () => { + const { client, server } = await makeClient(); + try { + const r = await client.callTool({ + name: "ingest_coverage", + arguments: { path: "no-such/coverage-final.json" }, + }); + expect((r as { isError?: boolean }).isError).toBe(true); + expect(readJson(r)).toMatchObject({ + error: expect.stringContaining("path not found"), + }); + } finally { + await server.close(); + } + }); +}); + +describe("MCP server — query baseline compare", () => { + it("query with missing baseline returns isError", async () => { + const { client, server } = await makeClient(); + try { + const r = await client.callTool({ + name: "query", + arguments: { sql: "SELECT 1", baseline: "does-not-exist" }, + }); + expect((r as { isError?: boolean }).isError).toBe(true); + expect(readJson(r)).toMatchObject({ + error: expect.stringContaining("does-not-exist"), + }); + } finally { + await server.close(); + } + }); + + it("query rejects baseline + group_by", async () => { + const { client, server } = await makeClient(); + try { + const r = await client.callTool({ + name: "query", + arguments: { + sql: "SELECT path FROM files", + baseline: "snap", + group_by: "directory", + }, + }); + expect((r as { isError?: boolean }).isError).toBe(true); + expect(readJson(r)).toMatchObject({ + error: expect.stringContaining("group_by"), + }); + } finally { + await server.close(); + } + }); + + it("query_recipe baseline diff attaches actions on added rows", async () => { + const db = openDb(); + try { + upsertQueryBaseline(db, { + name: "funcs", + recipe_id: "deprecated-symbols", + sql: "SELECT name FROM symbols WHERE doc_comment LIKE '%@deprecated%'", + rows_json: JSON.stringify([]), + row_count: 0, + git_ref: null, + created_at: 1, + }); + db.run( + `INSERT INTO symbols (file_path, name, kind, line_start, line_end, signature, doc_comment) + VALUES ('src/a.ts', 'oldFn', 'function', 1, 5, 'function oldFn()', '/** @deprecated */')`, + ); + } finally { + closeDb(db); + } + + const { client, server } = await makeClient(); + try { + const r = await client.callTool({ + name: "query_recipe", + arguments: { recipe: "deprecated-symbols", baseline: "funcs" }, + }); + const json = readJson(r) as { + added: Array<{ name: string; actions?: Array<{ type: string }> }>; + }; + expect(json.added).toHaveLength(1); + expect(json.added[0]?.actions?.map((a) => a.type)).toEqual( + expect.arrayContaining(["flag-caller", "apply-migrate-deprecated"]), + ); + } finally { + await server.close(); + } + }); +}); + function readResourceText(r: { contents: unknown[] }): string { const first = r.contents[0] as { text?: string }; if (typeof first.text !== "string") { diff --git a/src/application/tool-handlers.test.ts b/src/application/tool-handlers.test.ts index 398bc49d..dca36a02 100644 --- a/src/application/tool-handlers.test.ts +++ b/src/application/tool-handlers.test.ts @@ -272,6 +272,81 @@ describe("handleIngestCoverage", () => { ingested: { symbols: 1 }, }); }); + + it("ingests v8 runtime directory when runtime is true", async () => { + const db = openDb(); + try { + insertFile(db, { + path: "src/lib/cache.ts", + content_hash: "h2", + size: 1, + line_count: 3, + language: "typescript", + last_modified: 0, + indexed_at: 0, + }); + db.run( + "INSERT INTO symbols (file_path, name, kind, line_start, line_end, signature, is_exported, is_default_export, members, doc_comment, value, parent_name, visibility, complexity) VALUES ('src/lib/cache.ts', 'get', 'function', 1, 3, 'get(): void', 1, 0, NULL, NULL, NULL, NULL, NULL, 1)", + ); + } finally { + closeDb(db); + } + + const source = "export function get() {\n return 1;\n}\n"; + mkdirSync(join(projectRoot, "src/lib"), { recursive: true }); + writeFileSync(join(projectRoot, "src/lib/cache.ts"), source); + const dir = join(projectRoot, "v8-runtime"); + mkdirSync(dir); + const { pathToFileURL } = await import("node:url"); + writeFileSync( + join(dir, "coverage-1.json"), + JSON.stringify({ + result: [ + { + scriptId: "1", + url: pathToFileURL( + join(projectRoot, "src/lib/cache.ts"), + ).toString(), + functions: [ + { + functionName: "get", + isBlockCoverage: true, + ranges: [ + { startOffset: 0, endOffset: source.length, count: 1 }, + ], + }, + ], + }, + ], + }), + ); + + const result = await handleIngestCoverage( + { path: "v8-runtime", runtime: true }, + projectRoot, + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.payload).toMatchObject({ + format: "v8", + ingested: { symbols: 1 }, + }); + }); + + it("returns error for malformed istanbul JSON", async () => { + const coverageDir = join(projectRoot, "coverage"); + mkdirSync(coverageDir); + writeFileSync(join(coverageDir, "coverage-final.json"), "{not-json"); + + const result = await handleIngestCoverage( + { path: "coverage/coverage-final.json" }, + projectRoot, + ); + expect(result).toMatchObject({ + ok: false, + error: expect.any(String), + }); + }); }); describe("handleQueryRecipe params", () => { diff --git a/templates/agent-content/skill/10-recipes-context.md b/templates/agent-content/skill/10-recipes-context.md index db2d8837..e96eb281 100644 --- a/templates/agent-content/skill/10-recipes-context.md +++ b/templates/agent-content/skill/10-recipes-context.md @@ -77,7 +77,7 @@ git diff --name-only origin/main | codemap affected --stdin --json codemap query --json --recipe affected-tests --params changed_files=src/foo.ts ``` -**Resources** — same URI set over MCP **and** HTTP (`GET /resources/{encoded-uri}` against `codemap serve`); shared `readResource()` handler so bodies are identical. Freshness split: `schema` / `skill` / `rule` / `mcp-instructions` lazy-cache per server process; `recipes` / `recipes/{id}` / `files/{path}` / `symbols/{name}` read live every call so recency fields and index mutations under `--watch` stay fresh. +**Resources** — same URI set over MCP **and** HTTP (`GET /resources/{encoded-uri}` against `codemap serve`); identical resource bodies on both transports. Freshness split: `schema` / `skill` / `rule` / `mcp-instructions` lazy-cache per server process; `recipes` / `recipes/{id}` / `files/{path}` / `symbols/{name}` read live every call so recency fields and index mutations under `--watch` stay fresh. - **`codemap://recipes`** — full catalog (same as `--recipes-json`). Each row carries `source: "bundled" | "project"`, optional `shadows: true`, plus `last_run_at` / `run_count` recency fields. - **`codemap://recipes/{id}`** — one recipe `{id, description, body?, sql, actions?, source, shadows?, last_run_at, run_count}` (replaces `--print-sql `). From ad70f58d7475a658323e233a2cd666c4968319be Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Fri, 5 Jun 2026 10:42:28 +0300 Subject: [PATCH 5/5] docs(mcp): close PR #167 review cycle 5 glossary gaps Cross-ref MCP/HTTP baseline on query baseline entry, document ingest-coverage-run orchestration layer, and note baseline format incompatibilities in cmd-mcp help. --- docs/glossary.md | 4 ++-- src/cli/cmd-mcp.ts | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/glossary.md b/docs/glossary.md index 559df2b2..d7a3bde7 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -165,7 +165,7 @@ Format auto-detected from extension (`.json` → istanbul, `.info` → lcov, dir - `files-by-coverage` — files ranked ascending by statement coverage (replaces a deferred `file_coverage` rollup table; aggregates the symbol-level table via index-bounded `GROUP BY`). - `worst-covered-exports` — top-20 worst-covered exported functions. -Engine: `application/coverage-engine.ts` — pure `upsertCoverageRows({db, projectRoot, rows, format, sourcePath})` core consumed by `ingestIstanbul`, `ingestLcov`, and `ingestV8`. +Engine: `application/coverage-engine.ts` — pure `upsertCoverageRows({db, projectRoot, rows, format, sourcePath})` core consumed by `ingestIstanbul`, `ingestLcov`, and `ingestV8`. Transport orchestration (path resolution, artifact I/O, V8 directory merge) lives in `application/ingest-coverage-run.ts` (`runIngestCoverageOnDb`), shared by CLI, MCP `ingest_coverage`, and HTTP. ### `content_hash` @@ -453,7 +453,7 @@ Any SQL run against `.codemap/index.db` — either a **recipe** (saved SQL by id ### query baseline -A snapshot of a query result set saved by `codemap query --save-baseline[=]` and replayed by `codemap query --baseline[=]` for added/removed diffs. Stored in the `query_baselines` table inside `/index.db` (default `.codemap/index.db`; no parallel JSON files; survives `--full` and `SCHEMA_VERSION` rebuilds because the table is intentionally absent from `dropAll()`). Default name = `--recipe` id; ad-hoc SQL must pass an explicit name. Diff identity is per-row `JSON.stringify` equality — exact match, no fuzzy "changed" category in v1. +A snapshot of a query result set saved by `codemap query --save-baseline[=]` and replayed by `codemap query --baseline[=]` for added/removed diffs. MCP/HTTP twin: optional `baseline` on `query` / `query_recipe` (explicit name string; incompatible with non-`json` `format` and `group_by`). Stored in the `query_baselines` table inside `/index.db` (default `.codemap/index.db`; no parallel JSON files; survives `--full` and `SCHEMA_VERSION` rebuilds because the table is intentionally absent from `dropAll()`). Default name = `--recipe` id; ad-hoc SQL must pass an explicit name. Diff identity is per-row `JSON.stringify` equality — exact match, no fuzzy "changed" category in v1. ### query recipe diff --git a/src/cli/cmd-mcp.ts b/src/cli/cmd-mcp.ts index 82050383..e424bbe8 100644 --- a/src/cli/cmd-mcp.ts +++ b/src/cli/cmd-mcp.ts @@ -86,10 +86,12 @@ launched by an agent host (Claude Code, Cursor, Codex, generic MCP clients) — JSON-RPC on stdin/stdout, logs on stderr. Tools (20; snake_case — mirrors CLI verbs where a shell twin exists): - query One read-only SQL statement (optional \`baseline\` for row diff). + query One read-only SQL statement (optional \`baseline\` for row diff; + incompatible with non-json \`format\` / \`group_by\`). query_batch N statements in one round-trip (CLI: codemap query batch). query_recipe Recipe by id (bundled or project-local); per-row \`actions\` hints; - optional \`baseline\` for row diff (\`actions\` on \`added\` only). + optional \`baseline\` for row diff (\`actions\` on \`added\` only; + incompatible with non-json \`format\` / \`group_by\`). audit Structural-drift audit ({head, deltas} envelope). save_baseline Snapshot rows under a name (sql or recipe). list_baselines Catalog of saved baselines.