diff --git a/.changeset/mcp-affected-tool.md b/.changeset/mcp-affected-tool.md new file mode 100644 index 00000000..531b64e2 --- /dev/null +++ b/.changeset/mcp-affected-tool.md @@ -0,0 +1,5 @@ +--- +"codemap": patch +--- + +Add MCP/HTTP `affected` tool — same preprocessor as `codemap affected`, composes the `affected-tests` recipe. Respects `CODEMAP_MCP_TOOLS` allowlist. diff --git a/.codemap/.gitignore b/.codemap/.gitignore index 3011fd49..b5133a3c 100644 --- a/.codemap/.gitignore +++ b/.codemap/.gitignore @@ -3,4 +3,6 @@ index.db index.db-shm index.db-wal +index.lock +errors.log audit-cache/ diff --git a/docs/architecture.md b/docs/architecture.md index 73ba7d37..9e723d0a 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`); `validate-engine.ts` (`computeValidateRows` + `toProjectRelative`); `show-engine.ts` (lookup + envelope builders); `impact-engine.ts` (`findImpact` — graph blast-radius walker); `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`); `audit-engine.ts` (`runAudit` + `resolveAuditBaselines` + `runAuditFromRef` + `makeWorktreeReindex`); `audit-worktree.ts` (sha-keyed cache + atomic populate); `context-engine.ts` (`buildContextEnvelope`); `validate-engine.ts` (`computeValidateRows` + `toProjectRelative`); `show-engine.ts` (lookup + envelope builders); `impact-engine.ts` (`findImpact` — graph blast-radius walker); `affected-engine.ts` (`resolveAffectedChangedPaths` + `executeAffectedTests` — `affected-tests` recipe composer); `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). @@ -97,7 +97,7 @@ A local SQLite database (`.codemap/index.db`) indexes the project tree and store | `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`, `impact-engine`, `apply-engine`, `coverage-engine`, `query-recipes`, `recipes-loader`, `mcp-server`, `http-server`, `watcher`) | +| `application/` | Pure transport-agnostic engines (`run-index`, `index-engine`, `query-engine`, `audit-engine`, `context-engine`, `validate-engine`, `show-engine`, `impact-engine`, `affected-engine`, `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 | @@ -133,6 +133,8 @@ A local SQLite database (`.codemap/index.db`) indexes the project tree and store **Impact wiring:** **`src/cli/cmd-impact.ts`** (argv — `` + `--direction up|down|both` + `--depth N` + `--via dependencies|calls|imports|all` + `--limit N` + `--summary` + `--json`; bootstrap absorbs `--root`/`--config`) + **`src/application/impact-engine.ts`** (engine — `findImpact({db, target, direction?, via?, depth?, limit?})`). Pure transport-agnostic walker over the calls + dependencies + imports graphs; CLI / MCP / HTTP all dispatch the same engine function via `tool-handlers.ts`'s `handleImpact`. Target auto-resolves: contains `/` or matches `files.path` → file target; otherwise symbol (case-sensitive). Walks compatible backends per resolved kind: **symbol** → `calls` (callers / callees by `caller_name` / `callee_name`); **file** → `dependencies` (`from_path` / `to_path`) + `imports` (`file_path` / `resolved_path`, `IS NOT NULL` filter). `--via ` overrides; mismatched explicit choices land in `skipped_backends` (no error — agents see why their backend selection yielded fewer rows than expected). One `WITH RECURSIVE` per (direction, backend) combo with cycle detection via path-string `instr` check (SQLite has no native cycle predicate); JS-side merge + dedup by `(direction, kind, name?, file_path)` keeping the shallowest depth. `--depth 0` uses an unbounded sentinel (`UNBOUNDED_DEPTH_SENTINEL = 1_000_000`); cycle detection + `LIMIT` keep cyclic graphs cheap regardless. Termination reason classification: `limit` (truncated) > `depth` (any node sat at the cap) > `exhausted`. Result envelope: `{target, direction, via, depth_limit, matches: [{depth, direction, edge, kind, name?, file_path}], summary: {nodes, max_depth_reached, by_kind, terminated_by}, skipped_backends?}`. `--summary` blanks `matches` (transport bandwidth saver) but preserves `summary.nodes` so CI gates (`jq '.summary.nodes'`) still see the count. SARIF / annotations not supported (graph traversal, not findings — the parser accepts the flag combos but the engine only emits JSON). +**Affected wiring:** **`src/cli/cmd-affected.ts`** (argv — positional paths / `--stdin` / `--changed-since ` / `--params test_glob|max_depth` + `--json`; bootstrap absorbs `--root`/`--config`) + **`src/application/affected-engine.ts`** (engine — `resolveAffectedChangedPaths` + `executeAffectedTests`; pure recipe composer over bundled `affected-tests` SQL). CLI / MCP / HTTP dispatch the same engine via `tool-handlers.ts`'s `handleAffected` (MCP/HTTP) and `runAffectedCmd` (CLI). Path precedence: explicit paths (CLI positional / MCP `paths` array) → CLI `--stdin` → git vs `changed_since` / `HEAD` (`paths: []` on MCP/HTTP skips git). Result envelope: JSON array of `{test_path, impact_depth, actions?}` — file paths only; CI composes the runner command. **`tryRecordRecipeRun("affected-tests")`** lives at the orchestration layer (`handleAffected` + `runAffectedCmd`), not in the engine — same boundary discipline as `query_recipe` (see [§ `recipe_recency`](#recipe_recency--per-recipe-last-run--run-count-user-data-strict-without-rowid)). Recency records only when at least one changed path was resolved and the recipe SQL ran (empty path sets return `[]` without a recency write). + **Apply wiring:** **`src/cli/cmd-apply.ts`** (argv — `` + `--params` + `--dry-run` + `--yes` + `--json`; bootstrap absorbs `--root`/`--config`) + **`src/application/apply-engine.ts`** (engine — `applyDiffPayload({rows, projectRoot, dryRun})`). Pure transport-agnostic substrate-shaped fix executor: consumes the existing `--format diff-json` row contract from any recipe (`{file_path, line_start, before_pattern, after_pattern}`), validates each row against current disk, and either previews (dry-run) or writes (apply). CLI / MCP / HTTP all dispatch the same engine via `tool-handlers.ts`'s `handleApply`. **Phase 1** (always) resolves the project root via `path.resolve(projectRoot)` once, then for each row: rejects absolute `file_path` inputs and any candidate whose `path.resolve(resolvedRoot, file_path)` lands outside `resolvedRoot` (conflict `path escapes project root` — guards CLI + MCP + HTTP write paths against `../escape.ts`-style traversal); rejects duplicate `(file_path, line_start)` tuples (conflict `duplicate edit on same line` — without this, two phase-1-passing rows targeting the same line would split the run mid-phase-2 because the first replace invalidates the second's substring assertion, leaving Q2 (c) cross-file partial state). Reads each file at most once into `sourceCache`, splits on `/\r?\n/` for conflict reporting, checks `actual.includes(before_pattern)` (substring match — mirrors `buildDiffJson`'s contract; `rename-preview` emits `before_pattern = old_name` as the bare identifier, so whole-line exact match would conflict every time). Conflicts collect five reasons (`file missing` / `line out of range` / `line content drifted` / `path escapes project root` / `duplicate edit on same line`) — Q3 scan-and-collect, not fail-fast. **Phase 2** (gated on `!dryRun && conflicts.length === 0`) re-splits the cached source on raw `"\n"` (preserves CRLF as trailing `\r` per line; rejoining with `"\n"` round-trips losslessly), applies each file's edits in descending line order via `actual.replace(before, after)` with `$`-pre-escape (`replace(/\$/g, "$$$$")` — matches `buildDiffJson`'s GetSubstitution defence so identifiers like `$inject` round-trip safely), writes to a sibling temp path (`.codemap-apply-.tmp`), then `renameSync` into place — POSIX-atomic per file; concurrent readers see either pre-rename or post-rename content, never a torn write. **Q2 (c) all-or-nothing (semantic)**: any phase-1 conflict aborts phase 2 entirely before any file is touched. Phase-2 I/O failures (`writeFileSync` / `renameSync`) are NOT transactional across files — per-file atomicity holds (temp + rename), but a crash on file N leaves files `1..N-1` already renamed with no rollback; cross-file rollback would require pre-write backups + restore-on-throw and is deferred to a future PR. **Q6 gate**: TTY no `--yes` → phase-1 preview + `Proceed? [y/N]` prompt on stderr (default-N, `node:readline/promises`); TTY `--yes` → no prompt; non-TTY (CI / agents / MCP) without `--yes`/`--dry-run` rejected with stderr message. `--dry-run` + `--yes` mutually exclusive (parse-time error). MCP/HTTP transports (`handleApply`) require `yes: true` for the write path — there's no prompt to fall back on; `dry_run + yes` rejected as mutually exclusive. Result envelope (Q5; identical across modes): `{mode: 'dry-run'|'apply', applied: bool, files: [{file_path, rows_applied, warnings?}], conflicts: [{file_path, line_start, before_pattern, actual_at_line, reason}], summary: {files, files_modified, rows, rows_applied, conflicts, files_with_conflicts}}`. `applied: true` only when `mode === 'apply'` AND zero conflicts AND at least one row applied. Q7 idempotency: re-running on already-applied code reports a `line content drifted` conflict with `actual_at_line` showing the post-rename content; the user reads it and re-runs `codemap` to refresh the index → next run produces 0 rows (recipe finds nothing to rename) → vacuous clean apply. **Same-line ambiguity caveat (documented limitation):** `actual.replace(before_pattern, after_pattern)` rewrites only the **first** occurrence on the line. When `before_pattern` appears twice (e.g. `const foo = foo();` with `before = "foo"`) only the leftmost is replaced; the engine still reports `applied: true`. This mirrors `buildDiffJson`'s formatter contract verbatim — recipe authors who hit it normalise their SQL to emit a more specific pattern, or accept it (the formatter's `--format diff` preview shows the same shape). Promotion path: tighten phase-1 to conflict on ambiguity in a future PR if real users complain, but only alongside the formatter so preview and execution stay in lockstep. SARIF / annotations not supported (write action, not findings). TOCTOU: phase-1 reads through `sourceCache`; phase-2 transforms the cached source and writes — the gap between read and rename is a deliberate v1 simplification (apply isn't adversarial). Per Q10, only `cli/cmd-apply.ts` + `application/tool-handlers.ts` (+ the test files) may import `apply-engine.ts` for production execution — re-runnable forbidden-edge query at [§ Boundary verification — apply write path](#boundary-verification--apply-write-path). **Show / snippet wiring:** **`src/cli/cmd-show.ts`** + **`src/cli/cmd-snippet.ts`** — sibling CLI verbs sharing the same parser shape (`` + `--kind` + `--in ` + `--json`) and the pure engine **`src/application/show-engine.ts`** (`findSymbolsByName({db, name, kind?, inPath?})` for the lookup; `readSymbolSource({match, projectRoot, indexedContentHash?})` + `getIndexedContentHash(db, filePath)` for the snippet-side FS read; **`buildShowResult`** + **`buildSnippetResult`** envelope builders — same engine the MCP show/snippet tools call). Both verbs return the same `{matches, disambiguation?}` envelope per plan § 4 uniformity — single match → `{matches: [{...}]}`; multi-match adds `{n, by_kind, files, hint}`. Snippet matches add `source` / `stale` / `missing` fields (additive — no shape divergence). **`--in `** is normalized through `toProjectRelative(projectRoot, p)` (from **`src/application/validate-engine.ts`**) so `--in ./src/cli/`, `--in src/cli`, and `--in src/cli/cmd-show.ts` all resolve identically. Stale-file behavior on `snippet`: `hashContent` (from **`src/hash.ts`** — same primitive `cmd-validate.ts` uses) compares the on-disk content_hash against `files.content_hash`; mismatch sets `stale: true` but the source IS still returned (read tool, no auto-reindex side-effects). MCP tools `show` and `snippet` register parallel to the CLI surface (see [§ MCP wiring](#cli-usage)). @@ -615,7 +617,7 @@ Bundled recipes consuming the table — `untested-and-dead`, `files-by-coverage` Tracks `last_run_at` (epoch ms) + `run_count` per recipe id so agent hosts can rank live recipes ahead of historic ones. Surfaces inline on `--recipes-json` and the matching `codemap://recipes` / `codemap://recipes/{id}` MCP resources (live read every call — the resource cache was dropped to avoid freezing recency at first-read for the server-process lifetime). Same lifecycle posture as `query_baselines` / `coverage`: **intentionally absent from `dropAll()`** so `--full` and `SCHEMA_VERSION` rebuilds preserve user-activity history. Local-only — no upload primitive ever ships (resists telemetry-creep PRs by construction). -Two write sites both call `tryRecordRecipeRun` (the failure-isolated wrapper around `recordRecipeRun`) from `application/recipe-recency.ts`: `handleQueryRecipe` in `application/tool-handlers.ts` (covers MCP + HTTP — both flow through it) and `runQueryCmd` in `cli/cmd-query.ts` (CLI — finally-block keys off a local `recipeQuerySucceeded` flag, NOT `process.exitCode`, so `--ci`'s deliberate exit-1-on-findings is recognised as success). Counts only successful runs; recency-write failures are swallowed with a stderr `[recency] write failed: ` warning so they NEVER block the recipe response. The 90-day rolling window is enforced eagerly on the write path (single indexed `DELETE` inside `recordRecipeRun` before the upsert); reads filter at SELECT time (`WHERE last_run_at >= cutoff`) and never mutate the DB so the catalog stays side-effect free for `--recipes-json` and the MCP `codemap://recipes` resources. +Three write sites call `tryRecordRecipeRun` (the failure-isolated wrapper around `recordRecipeRun`) from `application/recipe-recency.ts`: `handleQueryRecipe` in `application/tool-handlers.ts` (covers MCP + HTTP for generic recipes), `handleAffected` in the same module (MCP + HTTP for `affected-tests`), and the CLI paths `runQueryCmd` in `cli/cmd-query.ts` + `runAffectedCmd` in `cli/cmd-affected.ts` (each keys success locally — `runQueryCmd`'s finally-block uses a `recipeQuerySucceeded` flag, NOT `process.exitCode`, so `--ci`'s deliberate exit-1-on-findings is recognised as success). Counts only successful runs; recency-write failures are swallowed with a stderr `[recency] write failed: ` warning so they NEVER block the recipe response. The 90-day rolling window is enforced eagerly on the write path (single indexed `DELETE` inside `recordRecipeRun` before the upsert); reads filter at SELECT time (`WHERE last_run_at >= cutoff`) and never mutate the DB so the catalog stays side-effect free for `--recipes-json` and the MCP `codemap://recipes` resources. Default ON; opt-out via `.codemap/config` `recipeRecency: false` (short-circuits before any DB write — no rows ever land). `recipe_id` is loose — matches bundled or project-recipe ids (no `recipes` SQLite table to FK against; project-shadow rows share the bundled row, since only one version is ever reachable per id). @@ -625,7 +627,7 @@ Default ON; opt-out via `.codemap/config` `recipeRecency: false` (short-circuits | last_run_at | INTEGER | Epoch ms of the last successful run. | | run_count | INTEGER | Cumulative successful runs (incremented per call). `INTEGER` wraparound is theoretical. | -`idx_recipe_recency_last_run` on `last_run_at` keeps both the eager-on-write prune (`DELETE WHERE last_run_at < cutoffMs` inside `recordRecipeRun`) and the read-time filter (`WHERE last_run_at >= cutoffMs` inside `loadRecipeRecency`) indexed scans as project-recipe counts grow. **Boundary discipline (write-path)**: only `application/tool-handlers.ts` + `cli/cmd-query.ts` (+ the test file) may import `tryRecordRecipeRun` / `recordRecipeRun` — re-runnable forbidden-edge query at [§ Boundary verification — `recipe_recency` write path](#boundary-verification--recipe_recency-write-path). **Read-path** (`enrichWithRecency` / `loadRecipeRecency`) is unrestricted — any catalog renderer can import it (today: `cmd-query.ts` for `--recipes-json`, `application/resource-handlers.ts` for `codemap://recipes` + `codemap://recipes/{id}`). +`idx_recipe_recency_last_run` on `last_run_at` keeps both the eager-on-write prune (`DELETE WHERE last_run_at < cutoffMs` inside `recordRecipeRun`) and the read-time filter (`WHERE last_run_at >= cutoffMs` inside `loadRecipeRecency`) indexed scans as project-recipe counts grow. **Boundary discipline (write-path)**: only `application/tool-handlers.ts` + `cli/cmd-query.ts` + `cli/cmd-affected.ts` (+ the test file) may import `tryRecordRecipeRun` / `recordRecipeRun` — re-runnable forbidden-edge query at [§ Boundary verification — `recipe_recency` write path](#boundary-verification--recipe_recency-write-path). **Read-path** (`enrichWithRecency` / `loadRecipeRecency`) is unrestricted — any catalog renderer can import it (today: `cmd-query.ts` for `--recipes-json`, `application/resource-handlers.ts` for `codemap://recipes` + `codemap://recipes/{id}`). ### `boundary_rules` — Architecture-boundary rules (config-derived) (`STRICT, WITHOUT ROWID`) @@ -808,7 +810,7 @@ When `SCHEMA_VERSION` changes, the indexer auto-detects the mismatch and trigger ### Boundary verification — `recipe_recency` write path -Re-runnable kit, lifted from the engine module so the docstring stays slim. Only `application/tool-handlers.ts` + `cli/cmd-query.ts` (+ the test file) may import the write-path symbols (`tryRecordRecipeRun` / `recordRecipeRun`): +Re-runnable kit, lifted from the engine module so the docstring stays slim. Only `application/tool-handlers.ts` + `cli/cmd-query.ts` + `cli/cmd-affected.ts` (+ the test file) may import the write-path symbols (`tryRecordRecipeRun` / `recordRecipeRun`): ```bash bun src/index.ts query --json " @@ -820,6 +822,7 @@ bun src/index.ts query --json " OR specifiers LIKE '%\"tryRecordRecipeRun\"%') AND file_path NOT IN ('src/application/tool-handlers.ts', 'src/cli/cmd-query.ts', + 'src/cli/cmd-affected.ts', 'src/application/recipe-recency.test.ts') " ``` diff --git a/docs/glossary.md b/docs/glossary.md index fbeb8584..eba9ae40 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -470,7 +470,7 @@ Boolean flag on a project-local recipe entry that has the same `id` as a bundled ### `recipe_recency` (table) / recipe recency / `recipeRecency: false` -Per-recipe `last_run_at` (epoch ms) + `run_count` for agent-host ranking — surfaces inline on every `--recipes-json` entry and the matching `codemap://recipes` / `codemap://recipes/{id}` resources (live read every call; the resource cache was dropped to avoid freezing recency at first-read for the server-process lifetime). Counts only successful recipe runs; failed runs / param-validation rejections / SQL errors don't write. Default ON; opt-out via `.codemap/config` `recipeRecency: false` (short-circuits before any DB write — no rows ever land). 90-day rolling window enforced eagerly on the write path (single transactional `DELETE` + `INSERT … ON CONFLICT` inside `recordRecipeRun`); reads filter at SELECT, never mutate. Local-only — no upload primitive (resists telemetry-creep PRs by construction). Two write sites — `handleQueryRecipe` in `application/tool-handlers.ts` (covers MCP + HTTP) and `runQueryCmd` in `cli/cmd-query.ts` (CLI) — both call `tryRecordRecipeRun` (the failure-isolated wrapper around `recordRecipeRun`) from `application/recipe-recency.ts`. Failure-isolated: a recency-write throw NEVER blocks the recipe response (warning to stderr unless `quiet`). Schema: see [architecture.md § `recipe_recency`](./architecture.md#recipe_recency--per-recipe-last-run--run-count-user-data-strict-without-rowid). +Per-recipe `last_run_at` (epoch ms) + `run_count` for agent-host ranking — surfaces inline on every `--recipes-json` entry and the matching `codemap://recipes` / `codemap://recipes/{id}` resources (live read every call; the resource cache was dropped to avoid freezing recency at first-read for the server-process lifetime). Counts only successful recipe runs; failed runs / param-validation rejections / SQL errors don't write. Default ON; opt-out via `.codemap/config` `recipeRecency: false` (short-circuits before any DB write — no rows ever land). 90-day rolling window enforced eagerly on the write path (single transactional `DELETE` + `INSERT … ON CONFLICT` inside `recordRecipeRun`); reads filter at SELECT, never mutate. Local-only — no upload primitive (resists telemetry-creep PRs by construction). Write sites — `handleQueryRecipe` + `handleAffected` in `application/tool-handlers.ts` (MCP + HTTP) and `runQueryCmd` in `cli/cmd-query.ts` + `runAffectedCmd` in `cli/cmd-affected.ts` (CLI) — each calls `tryRecordRecipeRun` (the failure-isolated wrapper around `recordRecipeRun`) from `application/recipe-recency.ts`. Failure-isolated: a recency-write throw NEVER blocks the recipe response (warning to stderr unless `quiet`). Schema: see [architecture.md § `recipe_recency`](./architecture.md#recipe_recency--per-recipe-last-run--run-count-user-data-strict-without-rowid). ### research diff --git a/docs/plans/affected-tests-recipe.md b/docs/plans/affected-tests-recipe.md index 7f28b982..eb367cdd 100644 --- a/docs/plans/affected-tests-recipe.md +++ b/docs/plans/affected-tests-recipe.md @@ -1,21 +1,21 @@ # Affected tests recipe — plan -> **Status:** open · **Priority:** P1 · **Effort:** M (~1–2 weeks) +> **Status:** shipped · **Priority:** P1 · **Effort:** M (~1–2 weeks) > > **Motivator:** CI can skip full test suites if only a subgraph changed. Codemap already has `dependencies` and `test_suites` — missing a recipe + CLI alias to list test files transitively impacted by changed sources. > -> **Roadmap:** [§ Backlog](../roadmap.md#backlog) (test-impact item) · [agent-surface-and-ops § P1](./agent-surface-and-ops.md#p1) +> **Roadmap:** [§ Backlog](../roadmap.md#backlog) (test-impact item) · [agent-surface-and-ops § P1](./agent-surface-and-ops.md#p1) · **Shipped:** [#132](https://github.com/stainless-code/codemap/pull/132) (recipe + CLI), [#133](https://github.com/stainless-code/codemap/pull/133) (MCP/HTTP `affected` tool) --- ## Pre-locked decisions -| # | Decision | Source | -| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------ | -| L.1 | **Moat-A clean** — `affected-tests` recipe satisfies the agent surface; **`query_recipe`** is the MCP/HTTP path. Optional dedicated CLI verb **`codemap affected`** for CI (`stdin` / git path discovery) — not a 6th outcome alias. | [Moat A](../roadmap.md#moats-load-bearing) | -| L.2 | Algorithm: reverse BFS on `dependencies` from changed files → filter test paths via `test_suites.file_path` and configurable globs. | Uses existing substrate | -| L.3 | **Stdin support** — accept changed paths from `git diff --name-only` (same ergonomics as CI scripts). | CLI ergonomics | -| L.4 | Not a verdict — output is file paths only; CI composes exit policy. | Moat A | +| # | Decision | Source | +| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | +| L.1 | **Moat-A clean** — `affected-tests` recipe is the substrate; **`query_recipe`** remains the Moat-A path. Optional **`codemap affected`** CLI + MCP/HTTP **`affected`** tool for ergonomics — not a 6th outcome alias. | [Moat A](../roadmap.md#moats-load-bearing) | +| L.2 | Algorithm: reverse BFS on `dependencies` from changed files → filter test paths via `test_suites.file_path` and configurable globs. | Uses existing substrate | +| L.3 | **Stdin support** — accept changed paths from `git diff --name-only` (same ergonomics as CI scripts). | CLI ergonomics | +| L.4 | Not a verdict — output is file paths only; CI composes exit policy. | Moat A | --- @@ -26,8 +26,8 @@ **Params (frontmatter):** - `changed_files` — multiline or repeated (from `--params` or stdin preprocessor) -- `test_glob` — default `**/*.{test,spec}.{ts,tsx,js,jsx}` -- `max_depth` — optional cap on BFS +- `test_glob` — optional SQLite GLOB; when set, replaces default suffix globs (`test_suites` always included) +- `max_depth` — optional non-negative integer BFS cap (default 50) **SQL shape:** @@ -48,7 +48,9 @@ Dedicated `cmd-affected.ts` (not an outcome alias — 5-alias cap unchanged). Sh ## Agent surface (Moat A) -No dedicated MCP tool required — agents call **`query_recipe`** with `recipe: "affected-tests"` and `params.changed_files` (ASCII RS between paths when multiple). The recipe is the Moat-A substrate; the CLI verb is CI ergonomics only. +**Substrate:** **`query_recipe`** with `recipe: "affected-tests"` and `params.changed_files` (ASCII RS between paths when multiple). + +**Convenience surfaces (Phase 2):** MCP/HTTP **`affected`** (`paths?`, `changed_since?`, …) and CLI **`codemap affected`** — thin composers over the same engine + recipe. Moat-A reviewers still verify via `query --recipe affected-tests`. --- @@ -59,7 +61,13 @@ No dedicated MCP tool required — agents call **`query_recipe`** with `recipe: 3. Document test-file conventions in recipe `.md` 4. Optional GitHub Action input `mode: affected` in [github-marketplace-action](./github-marketplace-action.md) (follow-up) -**Out of scope:** dedicated MCP/HTTP `affected` tool (same outcome reachable via `query_recipe`; revisit only if agent eval shows friction). +**Out of scope (v1):** ~~dedicated MCP/HTTP `affected` tool~~ — shipped Phase 2 follow-up (`affected` tool; same engine as CLI). `query_recipe` remains the Moat-A substrate. + +--- + +## Phase 2 (shipped) + +MCP/HTTP **`affected`** — `{ paths?, changed_since?, test_glob?, max_depth? }` → shared `affected-engine` → `affected-tests` recipe. Documented in `mcp-instructions`; respects `CODEMAP_MCP_TOOLS` allowlist. [#133](https://github.com/stainless-code/codemap/pull/133). --- @@ -68,6 +76,7 @@ No dedicated MCP tool required — agents call **`query_recipe`** with `recipe: - [x] Recipe returns test file paths for a known fixture delta - [x] Stdin mode works in shell pipeline - [x] Documented in README + skill +- [x] MCP/HTTP `affected` tool (Phase 2) --- diff --git a/docs/plans/agent-surface-delivery.md b/docs/plans/agent-surface-delivery.md index 89bd402e..d6476edd 100644 --- a/docs/plans/agent-surface-delivery.md +++ b/docs/plans/agent-surface-delivery.md @@ -10,11 +10,11 @@ ## Quick resume -| Next action | Detail | -| -------------------- | ---------------------------------------------------------------------------------- | -| **Review / merge** | PR 5 — affected tests ([#132](https://github.com/stainless-code/codemap/pull/132)) | -| **Start next** | **PR 6** — MCP trace tools (`trace` / `explore` / `node`) | -| **Do not start yet** | PR 9 (eval harness) until PR 8 | +| Next action | Detail | +| -------------------- | --------------------------------------------------------------------------- | +| **Review / merge** | [#133](https://github.com/stainless-code/codemap/pull/133) — MCP `affected` | +| **Start next** | **PR 6** — MCP trace tools (`trace` / `explore` / `node`) | +| **Do not start yet** | PR 9 (eval harness) until PR 8 | Update the table below when a PR merges or a new branch opens. @@ -35,15 +35,15 @@ Merge each PR to `main` directly. No long-lived integration branch (`feat/agent- Max **3 parallel tracks** at once. -| PR | Plans | Status | Blocked by | Parallel with | -| ----- | ----------------------------------------------------------------------------------------------------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------- | --------------------------------- | -| **3** | [`index-lock-and-error-log`](./index-lock-and-error-log.md) → [`parse-worker-hardening`](./parse-worker-hardening.md) (stack) | merged | [#129](https://github.com/stainless-code/codemap/pull/129), [#130](https://github.com/stainless-code/codemap/pull/130) | 4, 5 | -| **4** | Recipe half of [`mcp-trace-explore-tools`](./mcp-trace-explore-tools.md) (`call-path`, `symbol-neighborhood` SQL + tests) | merged | [#131](https://github.com/stainless-code/codemap/pull/131) | 3, 5 | -| **5** | [`affected-tests-recipe`](./affected-tests-recipe.md) | open | [#132](https://github.com/stainless-code/codemap/pull/132) | 3, 4 | -| **6** | MCP half of trace (`trace` / `explore` / `node` tools) + update instructions | planned | PR 1, PR 4 | — | -| **7** | [`field-qualified-search`](./field-qualified-search.md) | planned | PR 1 | 4, 5 if `mcp-server.ts` untouched | -| **8** | [`agents-init-mcp-wiring`](./agents-init-mcp-wiring.md) | planned | PR 1 | 3–5 | -| **9** | [`agent-eval-harness`](./agent-eval-harness.md) | planned | PR 1, PR 8, allowlist | **last P1** | +| PR | Plans | Status | Blocked by | Parallel with | +| ----- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------- | --------------------------------- | +| **3** | [`index-lock-and-error-log`](./index-lock-and-error-log.md) → [`parse-worker-hardening`](./parse-worker-hardening.md) (stack) | merged | [#129](https://github.com/stainless-code/codemap/pull/129), [#130](https://github.com/stainless-code/codemap/pull/130) | 4, 5 | +| **4** | Recipe half of [`mcp-trace-explore-tools`](./mcp-trace-explore-tools.md) (`call-path`, `symbol-neighborhood` SQL + tests) | merged | [#131](https://github.com/stainless-code/codemap/pull/131) | 3, 5 | +| **5** | [`affected-tests-recipe`](./affected-tests-recipe.md) (+ Phase 2 MCP `affected` in [#133](https://github.com/stainless-code/codemap/pull/133)) | merged | [#132](https://github.com/stainless-code/codemap/pull/132), [#133](https://github.com/stainless-code/codemap/pull/133) | 3, 4 | +| **6** | MCP half of trace (`trace` / `explore` / `node` tools) + update instructions | planned | PR 1, PR 4 | — | +| **7** | [`field-qualified-search`](./field-qualified-search.md) | planned | PR 1 | 4, 5 if `mcp-server.ts` untouched | +| **8** | [`agents-init-mcp-wiring`](./agents-init-mcp-wiring.md) | planned | PR 1 | 3–5 | +| **9** | [`agent-eval-harness`](./agent-eval-harness.md) | planned | PR 1, PR 8, allowlist | **last P1** | **Parallelization constraints** diff --git a/docs/roadmap.md b/docs/roadmap.md index b1d60312..74abe4e5 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -67,7 +67,7 @@ Prioritized agent & indexing ops queue (2026-05). Index: [`plans/agent-surface-a - [ ] **MCP trace / explore / node** — recipe twins + thin MCP composers. Plan: [`plans/mcp-trace-explore-tools.md`](./plans/mcp-trace-explore-tools.md). Effort: M. - [ ] **Agents init MCP wiring** — `agents init --mcp` + permissions. Plan: [`plans/agents-init-mcp-wiring.md`](./plans/agents-init-mcp-wiring.md). Effort: M. -- [ ] **Affected tests recipe** — dep-graph test selection + stdin. Plan: [`plans/affected-tests-recipe.md`](./plans/affected-tests-recipe.md). Effort: M. +- [x] **Affected tests recipe** — dep-graph test selection + stdin + MCP `affected` tool. Plan: [`plans/affected-tests-recipe.md`](./plans/affected-tests-recipe.md). Shipped #132 + #133. - [ ] **Index lock + error log** — cross-process lock, `unlock`, `errors.log`. Plan: [`plans/index-lock-and-error-log.md`](./plans/index-lock-and-error-log.md). Effort: M. - [ ] **Parse worker hardening** — per-file timeout + worker recycle. Plan: [`plans/parse-worker-hardening.md`](./plans/parse-worker-hardening.md). Effort: M. - [ ] **Field-qualified search** — `kind:` / `path:` / `name:` → SQL. Plan: [`plans/field-qualified-search.md`](./plans/field-qualified-search.md). Effort: M. diff --git a/src/application/affected-engine.test.ts b/src/application/affected-engine.test.ts new file mode 100644 index 00000000..5423f209 --- /dev/null +++ b/src/application/affected-engine.test.ts @@ -0,0 +1,211 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { resolveCodemapConfig } from "../config"; +import { closeDb, createTables, openDb } from "../db"; +import { initCodemap } from "../runtime"; +import { + CHANGED_PATH_DELIM, + executeAffectedTests, + joinChangedPaths, + normalizeChangedPathList, + resolveAffectedChangedPaths, +} from "./affected-engine"; + +let benchDir: string; +let gitRoot: string | undefined; + +function fixtureEnv(): NodeJS.ProcessEnv { + const e: NodeJS.ProcessEnv = {}; + for (const [k, v] of Object.entries(process.env)) { + if (k.startsWith("GIT_") || k.startsWith("HUSKY")) continue; + e[k] = v; + } + e.GIT_AUTHOR_DATE = "2026-01-01T00:00:00Z"; + e.GIT_COMMITTER_DATE = "2026-01-01T00:00:00Z"; + return e; +} + +function git(args: string[], root: string): string { + const r = spawnSync("git", args, { cwd: root, env: fixtureEnv() }); + if (r.status !== 0) { + throw new Error(`git ${args.join(" ")}: ${r.stderr.toString().trim()}`); + } + return r.stdout.toString().trim(); +} + +function seedAffectedGraph(db: ReturnType): void { + db.run( + `INSERT INTO files (path, content_hash, size, line_count, language, last_modified, indexed_at) + VALUES + ('src/lib/util.ts', 'h1', 10, 1, 'typescript', 1, 1), + ('src/__tests__/util.test.ts', 'h2', 10, 1, 'typescript', 1, 1), + ('src/other.spec.ts', 'h3', 10, 1, 'typescript', 1, 1)`, + ); + db.run( + `INSERT INTO dependencies (from_path, to_path) + VALUES ('src/__tests__/util.test.ts', 'src/lib/util.ts')`, + ); + db.run( + `INSERT INTO test_suites (file_path, name, kind, line_start, line_end, framework) + VALUES ('src/__tests__/util.test.ts', 'util', 'describe', 1, 10, 'bun-test')`, + ); +} + +beforeEach(() => { + benchDir = mkdtempSync(join(tmpdir(), "affected-engine-")); + mkdirSync(join(benchDir, "src"), { recursive: true }); + writeFileSync(join(benchDir, "package.json"), "{}\n"); + initCodemap(resolveCodemapConfig(benchDir, undefined)); + const db = openDb(); + try { + createTables(db); + seedAffectedGraph(db); + } finally { + closeDb(db); + } +}); + +afterEach(() => { + rmSync(benchDir, { recursive: true, force: true }); + if (gitRoot !== undefined) { + rmSync(gitRoot, { recursive: true, force: true }); + gitRoot = undefined; + } +}); + +describe("normalizeChangedPathList / joinChangedPaths", () => { + it("joins unique trimmed paths with RS delimiter", () => { + expect( + joinChangedPaths([ + "src/a.ts", + "./src/b.ts", + "src/a.ts", + "", + " src/c.ts ", + ]), + ).toBe(["src/a.ts", "src/b.ts", "src/c.ts"].join(CHANGED_PATH_DELIM)); + }); + + it("normalizeChangedPathList dedupes while preserving order", () => { + expect( + normalizeChangedPathList(["./src/a.ts", "src/a.ts", "src/b.ts"]), + ).toEqual(["src/a.ts", "src/b.ts"]); + }); +}); + +describe("resolveAffectedChangedPaths", () => { + it("returns explicit paths when provided", () => { + expect( + resolveAffectedChangedPaths({ + root: benchDir, + paths: ["./src/foo.ts", "src/foo.ts"], + }), + ).toEqual({ ok: true, paths: ["src/foo.ts"] }); + }); + + it("returns empty array for explicit empty paths", () => { + expect(resolveAffectedChangedPaths({ root: benchDir, paths: [] })).toEqual({ + ok: true, + paths: [], + }); + }); + + it("uses changed_since agent error labels", () => { + const r = resolveAffectedChangedPaths({ + root: benchDir, + changedSince: "not-a-real-ref-xyz", + errorStyle: "agent", + }); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.error).toContain("changed_since"); + expect(r.error).not.toContain("--changed-since"); + } + }); + + describe("temp git repo", () => { + beforeEach(() => { + gitRoot = mkdtempSync(join(tmpdir(), "affected-engine-git-")); + git(["init", "-q", "-b", "main", "--template="], gitRoot); + git(["config", "user.email", "t@example.com"], gitRoot); + git(["config", "user.name", "T"], gitRoot); + git(["config", "commit.gpgsign", "false"], gitRoot); + mkdirSync(join(gitRoot, "src"), { recursive: true }); + writeFileSync(join(gitRoot, "src", "util.ts"), "export const x = 1;\n"); + git(["add", "."], gitRoot); + git(["commit", "-m", "base", "--no-gpg-sign"], gitRoot); + }); + + it("discovers working-tree changes when paths omitted", () => { + if (gitRoot === undefined) { + throw new Error("gitRoot not initialised"); + } + writeFileSync(join(gitRoot, "src", "util.ts"), "export const x = 2;\n"); + const r = resolveAffectedChangedPaths({ root: gitRoot }); + expect(r.ok).toBe(true); + if (r.ok) expect(r.paths).toContain("src/util.ts"); + }); + }); +}); + +describe("executeAffectedTests", () => { + it("returns transitive test file for a changed source path", () => { + const result = executeAffectedTests({ + root: benchDir, + changedPaths: ["src/lib/util.ts"], + }); + expect(result).toEqual({ + ok: true, + rows: [ + { + test_path: "src/__tests__/util.test.ts", + impact_depth: 1, + actions: [ + { + type: "run-affected-tests", + description: + "Test file paths only — CI composes the exit policy and runner command.", + }, + ], + }, + ], + }); + }); + + it("returns empty rows when no changed paths", () => { + expect(executeAffectedTests({ root: benchDir, changedPaths: [] })).toEqual({ + ok: true, + rows: [], + }); + }); + + it("respects max_depth=0 (no transitive expansion)", () => { + const result = executeAffectedTests({ + root: benchDir, + changedPaths: ["src/lib/util.ts"], + maxDepth: 0, + }); + expect(result).toEqual({ ok: true, rows: [] }); + }); + + it("respects test_glob filter", () => { + const result = executeAffectedTests({ + root: benchDir, + changedPaths: ["src/other.spec.ts"], + testGlob: "src/other.spec.ts", + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.rows).toEqual([ + expect.objectContaining({ + test_path: "src/other.spec.ts", + impact_depth: 0, + }), + ]); + } + }); +}); diff --git a/src/application/affected-engine.ts b/src/application/affected-engine.ts new file mode 100644 index 00000000..a6f0b16d --- /dev/null +++ b/src/application/affected-engine.ts @@ -0,0 +1,132 @@ +/** + * Shared `affected-tests` preprocessor + recipe executor — used by + * `codemap affected` (CLI) and the MCP/HTTP `affected` tool. + */ + +import { getFilesChangedSince } from "../git-changed"; +import { executeQuery } from "./query-engine"; +import { + getQueryRecipeActions, + getQueryRecipeParams, + getQueryRecipeSql, +} from "./query-recipes"; +import { resolveRecipeParams } from "./recipe-params"; + +/** Delimiter for `affected-tests.changed_files` (ASCII RS). */ +export const CHANGED_PATH_DELIM = "\u001e"; + +export type AffectedTestsFailureKind = "param" | "query" | "internal"; + +/** Trim, drop `./`, dedupe; preserve first-occurrence order. */ +export function normalizeChangedPathList(paths: Iterable): string[] { + const seen = new Set(); + const out: string[] = []; + for (const raw of paths) { + const path = raw.trim().replace(/^\.\/+/, ""); + if (path.length === 0 || seen.has(path)) continue; + seen.add(path); + out.push(path); + } + return out; +} + +/** Join project-relative paths for the `affected-tests` recipe param. */ +export function joinChangedPaths(paths: Iterable): string { + return normalizeChangedPathList(paths).join(CHANGED_PATH_DELIM); +} + +function formatChangedSinceError( + error: string, + errorStyle: "cli" | "agent", +): string { + if (errorStyle === "agent") { + return error.replaceAll("--changed-since", "changed_since"); + } + return error; +} + +/** + * Resolve changed paths for agent transports: explicit `paths` wins; + * otherwise git diff + working tree vs `changedSince` (default `HEAD`). + * + * `paths: []` skips git (explicit empty). Omit `paths` to discover via git. + */ +export function resolveAffectedChangedPaths(opts: { + root: string; + paths?: string[] | undefined; + changedSince?: string | undefined; + /** Git error prefix: CLI `--changed-since` vs MCP `changed_since`. */ + errorStyle?: "cli" | "agent"; +}): { ok: true; paths: string[] } | { ok: false; error: string } { + if (opts.paths !== undefined) { + return { ok: true, paths: normalizeChangedPathList(opts.paths) }; + } + const ref = opts.changedSince ?? "HEAD"; + const result = getFilesChangedSince(ref, opts.root); + if (!result.ok) { + return { + ok: false, + error: formatChangedSinceError(result.error, opts.errorStyle ?? "cli"), + }; + } + return { ok: true, paths: normalizeChangedPathList(result.files) }; +} + +export function executeAffectedTests(opts: { + root: string; + changedPaths: string[]; + testGlob?: string | undefined; + maxDepth?: number | undefined; +}): + | { ok: true; rows: unknown[] } + | { ok: false; error: string; kind: AffectedTestsFailureKind } { + const changedRaw = joinChangedPaths(opts.changedPaths); + if (changedRaw.length === 0) { + return { ok: true, rows: [] }; + } + + const declared = getQueryRecipeParams("affected-tests"); + const resolved = resolveRecipeParams({ + recipeId: "affected-tests", + declared, + provided: { + changed_files: changedRaw, + ...(opts.testGlob !== undefined ? { test_glob: opts.testGlob } : {}), + ...(opts.maxDepth !== undefined ? { max_depth: opts.maxDepth } : {}), + }, + }); + if (!resolved.ok) { + return { ok: false, error: resolved.error, kind: "param" }; + } + + const sql = getQueryRecipeSql("affected-tests"); + if (sql === undefined) { + return { + ok: false, + error: 'codemap affected: bundled recipe "affected-tests" missing', + kind: "internal", + }; + } + + const payload = executeQuery({ + sql, + bindValues: resolved.values, + root: opts.root, + recipeActions: getQueryRecipeActions("affected-tests"), + }); + + if ( + payload !== null && + typeof payload === "object" && + !Array.isArray(payload) && + "error" in payload + ) { + return { + ok: false, + error: String((payload as { error: string }).error), + kind: "query", + }; + } + + return { ok: true, rows: payload as unknown[] }; +} diff --git a/src/application/http-server.test.ts b/src/application/http-server.test.ts index 9205ad51..4a81a873 100644 --- a/src/application/http-server.test.ts +++ b/src/application/http-server.test.ts @@ -123,6 +123,7 @@ describe("http-server — health + tools catalog", () => { const body = (await r.json()) as { tools: { name: string }[] }; expect(body.tools.map((t) => t.name)).toContain("query"); expect(body.tools.map((t) => t.name)).toContain("audit"); + expect(body.tools.map((t) => t.name)).toContain("affected"); }); it("404 for unknown route", async () => { @@ -271,6 +272,139 @@ describe("http-server — POST /tool/{other tools}", () => { expect(Array.isArray(r.json.matches)).toBe(true); }); + it("affected returns transitive test paths for explicit paths", async () => { + const db = openDb(); + try { + db.run( + `INSERT INTO files (path, content_hash, size, line_count, language, last_modified, indexed_at) + VALUES ('src/lib/util.ts', 'h3', 10, 1, 'typescript', 1, 1), + ('src/__tests__/util.test.ts', 'h4', 10, 1, 'typescript', 1, 1)`, + ); + db.run( + `INSERT INTO dependencies (from_path, to_path) + VALUES ('src/__tests__/util.test.ts', 'src/lib/util.ts')`, + ); + db.run( + `INSERT INTO test_suites (file_path, name, kind, line_start, line_end, framework) + VALUES ('src/__tests__/util.test.ts', 'util', 'describe', 1, 10, 'bun-test')`, + ); + } finally { + closeDb(db); + } + serverHandle = await startServer(); + const r = await postTool(serverHandle.port, "affected", { + paths: ["src/lib/util.ts"], + }); + expect(r.status).toBe(200); + expect(r.json).toEqual([ + { + test_path: "src/__tests__/util.test.ts", + impact_depth: 1, + actions: [ + { + type: "run-affected-tests", + description: + "Test file paths only — CI composes the exit policy and runner command.", + }, + ], + }, + ]); + }); + + it("affected with non-integer max_depth → 400 (Zod rejects)", async () => { + serverHandle = await startServer(); + const r = await postTool(serverHandle.port, "affected", { + paths: ["src/a.ts"], + max_depth: 1.5, + }); + expect(r.status).toBe(400); + expect(r.json.error).toContain('"affected"'); + }); + + it("affected with paths: [] returns empty array", async () => { + serverHandle = await startServer(); + const r = await postTool(serverHandle.port, "affected", { paths: [] }); + expect(r.status).toBe(200); + expect(r.json).toEqual([]); + }); + + it("does not record recency when paths: []", async () => { + serverHandle = await startServer(); + const dbBefore = openDb(); + let before = 0; + try { + before = + dbBefore + .query<{ run_count: number }>( + "SELECT run_count FROM recipe_recency WHERE recipe_id = 'affected-tests'", + ) + .get()?.run_count ?? 0; + } finally { + closeDb(dbBefore); + } + const r = await postTool(serverHandle.port, "affected", { paths: [] }); + expect(r.status).toBe(200); + const dbAfter = openDb(); + try { + const after = + dbAfter + .query<{ run_count: number }>( + "SELECT run_count FROM recipe_recency WHERE recipe_id = 'affected-tests'", + ) + .get()?.run_count ?? 0; + expect(after).toBe(before); + } finally { + closeDb(dbAfter); + } + }); + + it("records recipe recency after a successful affected run", async () => { + const db = openDb(); + try { + db.run( + `INSERT INTO files (path, content_hash, size, line_count, language, last_modified, indexed_at) + VALUES ('src/lib/util.ts', 'h3', 10, 1, 'typescript', 1, 1), + ('src/__tests__/util.test.ts', 'h4', 10, 1, 'typescript', 1, 1)`, + ); + db.run( + `INSERT INTO dependencies (from_path, to_path) + VALUES ('src/__tests__/util.test.ts', 'src/lib/util.ts')`, + ); + db.run( + `INSERT INTO test_suites (file_path, name, kind, line_start, line_end, framework) + VALUES ('src/__tests__/util.test.ts', 'util', 'describe', 1, 10, 'bun-test')`, + ); + } finally { + closeDb(db); + } + serverHandle = await startServer(); + const r = await postTool(serverHandle.port, "affected", { + paths: ["src/lib/util.ts"], + }); + expect(r.status).toBe(200); + const dbAfter = openDb(); + try { + const row = dbAfter + .query<{ run_count: number }>( + "SELECT run_count FROM recipe_recency WHERE recipe_id = 'affected-tests'", + ) + .get(); + expect(row?.run_count).toBeGreaterThanOrEqual(1); + } finally { + closeDb(dbAfter); + } + }); + + it("affected git error uses changed_since label", async () => { + serverHandle = await startServer(); + const r = await postTool(serverHandle.port, "affected", { + changed_since: "not-a-real-ref-xyz", + }); + expect(r.status).toBe(400); + expect(r.json.error).toContain("changed_since"); + expect(r.json.error).not.toContain("--changed-since"); + }); + it("list_baselines returns array (empty when none saved)", async () => { serverHandle = await startServer(); const r = await postTool(serverHandle.port, "list_baselines", {}); diff --git a/src/application/http-server.ts b/src/application/http-server.ts index 699cc196..91252e9d 100644 --- a/src/application/http-server.ts +++ b/src/application/http-server.ts @@ -14,12 +14,14 @@ import { } from "../runtime"; import { listResources, readResource } from "./resource-handlers"; import { + affectedArgsSchema, applyArgsSchema, auditArgsSchema, contextArgsSchema, dropBaselineArgsSchema, handleApply, handleAudit, + handleAffected, handleContext, handleDropBaseline, handleImpact, @@ -91,6 +93,7 @@ const TOOL_NAMES = [ "show", "snippet", "impact", + "affected", "apply", "save_baseline", "list_baselines", @@ -480,6 +483,12 @@ async function dispatchTool( result = handleImpact(r.value); break; } + case "affected": { + const r = validate(affectedArgsSchema, args, "affected"); + if (!r.ok) return writeJson(res, 400, { error: r.error }, opts.version); + result = handleAffected(r.value, opts.root); + break; + } case "apply": { const r = validate(applyArgsSchema, args, "apply"); if (!r.ok) return writeJson(res, 400, { error: r.error }, opts.version); diff --git a/src/application/mcp-server.test.ts b/src/application/mcp-server.test.ts index 7fdbc3c9..4deca2dd 100644 --- a/src/application/mcp-server.test.ts +++ b/src/application/mcp-server.test.ts @@ -1232,3 +1232,191 @@ describe("MCP server — impact tool", () => { } }); }); + +describe("MCP server — affected tool", () => { + function seedAffectedGraph() { + const db = openDb(); + try { + db.run( + `INSERT INTO files (path, content_hash, size, line_count, language, last_modified, indexed_at) + VALUES ('src/lib/util.ts', 'h3', 10, 1, 'typescript', 1, 1), + ('src/__tests__/util.test.ts', 'h4', 10, 1, 'typescript', 1, 1)`, + ); + db.run( + `INSERT INTO dependencies (from_path, to_path) + VALUES ('src/__tests__/util.test.ts', 'src/lib/util.ts')`, + ); + db.run( + `INSERT INTO test_suites (file_path, name, kind, line_start, line_end, framework) + VALUES ('src/__tests__/util.test.ts', 'util', 'describe', 1, 10, 'bun-test')`, + ); + } finally { + closeDb(db); + } + } + + it("lists affected 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("affected"); + } finally { + await server.close(); + } + }); + + it("affected returns transitive test paths for explicit paths", async () => { + seedAffectedGraph(); + const { client, server } = await makeClient(); + try { + const r = await client.callTool({ + name: "affected", + arguments: { paths: ["src/lib/util.ts"] }, + }); + const json = readJson(r); + expect(json).toEqual([ + { + test_path: "src/__tests__/util.test.ts", + impact_depth: 1, + actions: [ + { + type: "run-affected-tests", + description: + "Test file paths only — CI composes the exit policy and runner command.", + }, + ], + }, + ]); + } finally { + await server.close(); + } + }); + + it("respects CODEMAP_MCP_TOOLS allowlist", async () => { + const { client, server } = await makeClient({ + CODEMAP_MCP_TOOLS: "query,affected", + }); + try { + const tools = await client.listTools(); + const names = tools.tools.map((t) => t.name); + expect(names).toEqual(expect.arrayContaining(["query", "affected"])); + expect(names).not.toContain("impact"); + } finally { + await server.close(); + } + }); + + it("excludes affected when not in CODEMAP_MCP_TOOLS", async () => { + const { client, server } = await makeClient({ + CODEMAP_MCP_TOOLS: "query,show", + }); + try { + const tools = await client.listTools(); + const names = tools.tools.map((t) => t.name); + expect(names).not.toContain("affected"); + } finally { + await server.close(); + } + }); + + it("affected with paths: [] returns empty array without git", async () => { + const { client, server } = await makeClient(); + try { + const r = await client.callTool({ + name: "affected", + arguments: { paths: [] }, + }); + const json = readJson(r); + expect(json).toEqual([]); + } finally { + await server.close(); + } + }); + + it("does not record recency when paths: []", async () => { + const { client, server } = await makeClient(); + try { + const dbBefore = openDb(); + let before = 0; + try { + before = + dbBefore + .query<{ run_count: number }>( + "SELECT run_count FROM recipe_recency WHERE recipe_id = 'affected-tests'", + ) + .get()?.run_count ?? 0; + } finally { + closeDb(dbBefore); + } + await client.callTool({ name: "affected", arguments: { paths: [] } }); + const dbAfter = openDb(); + try { + const after = + dbAfter + .query<{ run_count: number }>( + "SELECT run_count FROM recipe_recency WHERE recipe_id = 'affected-tests'", + ) + .get()?.run_count ?? 0; + expect(after).toBe(before); + } finally { + closeDb(dbAfter); + } + } finally { + await server.close(); + } + }); + + it("affected returns isError on non-integer max_depth (Zod rejects)", async () => { + const { client, server } = await makeClient(); + try { + const r = await client.callTool({ + name: "affected", + arguments: { paths: ["src/a.ts"], max_depth: 1.5 }, + }); + expect((r as { isError?: boolean }).isError).toBe(true); + } finally { + await server.close(); + } + }); + + it("affected reports changed_since in git errors", async () => { + const { client, server } = await makeClient(); + try { + const r = await client.callTool({ + name: "affected", + arguments: { changed_since: "not-a-real-ref-xyz" }, + }); + expect((r as { isError?: boolean }).isError).toBe(true); + const json = readJson(r); + expect(json.error).toContain("changed_since"); + expect(json.error).not.toContain("--changed-since"); + } finally { + await server.close(); + } + }); + + it("records recipe recency after a successful affected run", async () => { + seedAffectedGraph(); + const { client, server } = await makeClient(); + try { + await client.callTool({ + name: "affected", + arguments: { paths: ["src/lib/util.ts"] }, + }); + const db = openDb(); + try { + const row = db + .query<{ run_count: number }>( + "SELECT run_count FROM recipe_recency WHERE recipe_id = 'affected-tests'", + ) + .get(); + expect(row?.run_count).toBeGreaterThanOrEqual(1); + } finally { + closeDb(db); + } + } finally { + await server.close(); + } + }); +}); diff --git a/src/application/mcp-server.ts b/src/application/mcp-server.ts index 97b261a7..d0e0e6b3 100644 --- a/src/application/mcp-server.ts +++ b/src/application/mcp-server.ts @@ -23,12 +23,14 @@ import { listQueryRecipeCatalog } from "./query-recipes"; import { readResource } from "./resource-handlers"; import type { ResourcePayload } from "./resource-handlers"; import { + affectedArgsSchema, applyArgsSchema, auditArgsSchema, contextArgsSchema, dropBaselineArgsSchema, handleApply, handleAudit, + handleAffected, handleContext, handleDropBaseline, handleImpact, @@ -146,6 +148,7 @@ export function createMcpServer(opts: ServerOpts): McpServer { maybeRegister("show", () => registerShowTool(server, opts)); maybeRegister("snippet", () => registerSnippetTool(server, opts)); maybeRegister("impact", () => registerImpactTool(server)); + maybeRegister("affected", () => registerAffectedTool(server, opts)); maybeRegister("apply", () => registerApplyTool(server, opts)); registerResources(server); logMcpToolAllowlist(allowlistResolved, registered); @@ -285,6 +288,18 @@ function registerSnippetTool(server: McpServer, opts: ServerOpts): void { ); } +function registerAffectedTool(server: McpServer, opts: ServerOpts): void { + server.registerTool( + "affected", + { + description: + "List test files transitively impacted by changed source files (reverse BFS on `dependencies`). Same preprocessor as `codemap affected` → `affected-tests` recipe. Args: paths (explicit project-relative paths; when set, skips git — `paths: []` is explicit empty, omit paths for git discovery), changed_since (git ref when paths omitted; default HEAD; wins only when paths omitted), test_glob (SQLite GLOB; replaces default suffix globs when set), max_depth (non-negative integer BFS cap). Returns JSON array of {test_path, impact_depth, actions?} — file paths only; CI composes the runner command.", + inputSchema: affectedArgsSchema, + }, + (args) => wrapToolResult(handleAffected(args, opts.root)), + ); +} + function registerImpactTool(server: McpServer): void { server.registerTool( "impact", diff --git a/src/application/mcp-tool-allowlist.ts b/src/application/mcp-tool-allowlist.ts index 30c488f3..4b7af019 100644 --- a/src/application/mcp-tool-allowlist.ts +++ b/src/application/mcp-tool-allowlist.ts @@ -16,6 +16,7 @@ export const MCP_TOOL_NAMES = [ "show", "snippet", "impact", + "affected", "apply", ] as const; diff --git a/src/application/recipe-params.test.ts b/src/application/recipe-params.test.ts index cb3b4355..466a479e 100644 --- a/src/application/recipe-params.test.ts +++ b/src/application/recipe-params.test.ts @@ -123,6 +123,16 @@ describe("resolveRecipeParams", () => { expect(badBoolean.ok).toBe(false); }); + it("rejects non-integer number params", () => { + const r = resolveRecipeParams({ + recipeId: "affected-tests", + declared: [{ name: "max_depth", type: "number", required: false }], + provided: { max_depth: 1.5 }, + }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/integer/); + }); + it("rejects params passed to a recipe that declares none", () => { const r = resolveRecipeParams({ recipeId: "plain", diff --git a/src/application/recipe-params.ts b/src/application/recipe-params.ts index 3716890f..d91956ff 100644 --- a/src/application/recipe-params.ts +++ b/src/application/recipe-params.ts @@ -131,6 +131,12 @@ function coerceParamValue( error: `${prefix(recipeId)} --params ${param.name}="${String(raw)}" is not a number.`, }; } + if (!Number.isInteger(n)) { + return { + ok: false, + error: `${prefix(recipeId)} --params ${param.name}="${String(raw)}" must be an integer.`, + }; + } return { ok: true, value: n }; } if (typeof raw === "boolean") return { ok: true, value: raw }; diff --git a/src/application/recipe-recency.ts b/src/application/recipe-recency.ts index a6553ad0..653d0d2f 100644 --- a/src/application/recipe-recency.ts +++ b/src/application/recipe-recency.ts @@ -7,7 +7,7 @@ import { STATE_DIR_DEFAULT } from "./state-dir"; /** * Write-path imports (`tryRecordRecipeRun` / `recordRecipeRun`) are restricted - * to `tool-handlers.ts` + `cmd-query.ts` (+ the test file). Re-runnable + * to `tool-handlers.ts` + `cli/cmd-query.ts` + `cli/cmd-affected.ts` (+ the test file). Re-runnable * forbidden-edge query lives at [`docs/architecture.md` § Boundary verification — * `recipe_recency` write path](../../docs/architecture.md#boundary-verification--recipe_recency-write-path). * Read-path imports (`enrichWithRecency` / `loadRecipeRecency`) are unrestricted. @@ -55,12 +55,14 @@ export function recordRecipeRun(opts: RecordRunOpts): void { } /** - * The wrapper both write sites call (`handleQueryRecipe` for MCP/HTTP + - * `runQueryCmd` for CLI). Opens its own DB because `executeQuery` runs - * with `PRAGMA query_only = 1` and can't double as the writer. Swallows - * every error — recency-write failures NEVER block the recipe response. + * Orchestration-layer wrapper (`handleQueryRecipe`, `handleAffected`, `runQueryCmd`, + * `runAffectedCmd`). Opens its own DB because `executeQuery` runs with + * `PRAGMA query_only = 1` and can't double as the writer. Swallows every error — + * recency-write failures NEVER block the recipe response. * - * Caller contract: only call AFTER recipe execution returns successfully. + * Caller contract: only call AFTER recipe execution returns successfully. Convenience + * tools (`affected`, `codemap affected`) skip the call when no changed paths were + * resolved (empty `paths` / stdin / git discovery) — no SQL ran, so no recency bump. * * `_openDb` is a test seam — production omits it. */ diff --git a/src/application/tool-handlers.ts b/src/application/tool-handlers.ts index 8b2ee407..b7f298d2 100644 --- a/src/application/tool-handlers.ts +++ b/src/application/tool-handlers.ts @@ -29,6 +29,10 @@ import { getFilesChangedSince } from "../git-changed"; import type { GroupByMode } from "../group-by"; import { GROUP_BY_MODES } from "../group-by"; import { getProjectRoot } from "../runtime"; +import { + executeAffectedTests, + resolveAffectedChangedPaths, +} from "./affected-engine"; import { applyDiffPayload } from "./apply-engine"; import { makeWorktreeReindex, @@ -731,6 +735,50 @@ export function handleSnippet(args: SnippetArgs, root: string): ToolResult { } } +// === affected =============================================================== + +export const affectedArgsSchema = { + paths: z.array(z.string()).optional(), + changed_since: z.string().optional(), + test_glob: z.string().optional(), + max_depth: z.number().int().nonnegative().optional(), +}; + +export interface AffectedArgs { + paths?: string[]; + changed_since?: string; + test_glob?: string; + max_depth?: number; +} + +export function handleAffected(args: AffectedArgs, root: string): ToolResult { + try { + const pathsResult = resolveAffectedChangedPaths({ + root, + paths: args.paths, + changedSince: args.changed_since, + errorStyle: "agent", + }); + if (!pathsResult.ok) return err(pathsResult.error); + + const result = executeAffectedTests({ + root, + changedPaths: pathsResult.paths, + testGlob: args.test_glob, + maxDepth: args.max_depth, + }); + if (!result.ok) { + return err(result.error, result.kind === "internal" ? 500 : undefined); + } + if (pathsResult.paths.length > 0) { + tryRecordRecipeRun("affected-tests"); + } + return ok(result.rows); + } catch (e) { + return err(e instanceof Error ? e.message : String(e), 500); + } +} + // === impact ================================================================= export const impactArgsSchema = { diff --git a/src/cli/cmd-affected.test.ts b/src/cli/cmd-affected.test.ts index 1f9f7c48..24731283 100644 --- a/src/cli/cmd-affected.test.ts +++ b/src/cli/cmd-affected.test.ts @@ -2,11 +2,10 @@ import { beforeAll, describe, expect, it } from "bun:test"; import { existsSync } from "node:fs"; import { join } from "node:path"; -import { - CHANGED_PATH_DELIM, - joinChangedPaths, - parseAffectedRest, -} from "./cmd-affected"; +import { resolveCodemapConfig } from "../config"; +import { closeDb, openDb } from "../db"; +import { initCodemap } from "../runtime"; +import { parseAffectedRest } from "./cmd-affected"; const repoRoot = join(import.meta.dir, "..", ".."); const indexTs = join(repoRoot, "src", "index.ts"); @@ -55,20 +54,6 @@ beforeAll(() => { } }); -describe("joinChangedPaths", () => { - it("joins unique trimmed paths with RS delimiter", () => { - expect( - joinChangedPaths([ - "src/a.ts", - "./src/b.ts", - "src/a.ts", - "", - " src/c.ts ", - ]), - ).toBe(["src/a.ts", "src/b.ts", "src/c.ts"].join(CHANGED_PATH_DELIM)); - }); -}); - describe("parseAffectedRest", () => { it("returns help on --help / -h", () => { expect(parseAffectedRest(["affected", "--help"]).kind).toBe("help"); @@ -151,6 +136,12 @@ describe("parseAffectedRest", () => { ]); expect(r.kind).toBe("error"); }); + + it("rejects non-integer max_depth in --params", () => { + const r = parseAffectedRest(["affected", "--params", "max_depth=1.5"]); + expect(r.kind).toBe("error"); + if (r.kind === "error") expect(r.message).toMatch(/integer/); + }); }); describe("codemap affected — fixtures/minimal e2e", () => { @@ -207,4 +198,57 @@ describe("codemap affected — fixtures/minimal e2e", () => { expect(r.exitCode).toBe(0); expect(JSON.parse(r.out)).toEqual([]); }); + + it("records recipe recency after a successful run", async () => { + const r = await runCli( + ["affected", "src/lib/complexity-fixture.ts", "--json"], + { env: { CODEMAP_ROOT: minimalRoot } }, + ); + expect(r.exitCode).toBe(0); + initCodemap(resolveCodemapConfig(minimalRoot, undefined)); + const db = openDb(); + try { + const row = db + .query<{ run_count: number }>( + "SELECT run_count FROM recipe_recency WHERE recipe_id = 'affected-tests'", + ) + .get(); + expect(row?.run_count).toBeGreaterThanOrEqual(1); + } finally { + closeDb(db); + } + }); + + it("does not record recency when stdin has no paths", async () => { + initCodemap(resolveCodemapConfig(minimalRoot, undefined)); + let before = 0; + const dbBefore = openDb(); + try { + before = + dbBefore + .query<{ run_count: number }>( + "SELECT run_count FROM recipe_recency WHERE recipe_id = 'affected-tests'", + ) + .get()?.run_count ?? 0; + } finally { + closeDb(dbBefore); + } + const r = await runCli(["affected", "--stdin", "--json"], { + env: { CODEMAP_ROOT: minimalRoot }, + stdin: "\n\n", + }); + expect(r.exitCode).toBe(0); + const dbAfter = openDb(); + try { + const after = + dbAfter + .query<{ run_count: number }>( + "SELECT run_count FROM recipe_recency WHERE recipe_id = 'affected-tests'", + ) + .get()?.run_count ?? 0; + expect(after).toBe(before); + } finally { + closeDb(dbAfter); + } + }); }); diff --git a/src/cli/cmd-affected.ts b/src/cli/cmd-affected.ts index c34be45d..20eb4c71 100644 --- a/src/cli/cmd-affected.ts +++ b/src/cli/cmd-affected.ts @@ -1,18 +1,18 @@ import { stdin as input } from "node:process"; -import { executeQuery } from "../application/query-engine"; import { - getQueryRecipeActions, - getQueryRecipeParams, - getQueryRecipeSql, -} from "../application/query-recipes"; -import { resolveRecipeParams } from "../application/recipe-params"; -import { getFilesChangedSince } from "../git-changed"; + executeAffectedTests, + normalizeChangedPathList, + resolveAffectedChangedPaths, +} from "../application/affected-engine"; +import { tryRecordRecipeRun } from "../application/recipe-recency"; import { getProjectRoot } from "../runtime"; import { bootstrapCodemap } from "./bootstrap-codemap"; -/** Delimiter for `affected-tests.changed_files` (ASCII RS). */ -export const CHANGED_PATH_DELIM = "\u001e"; +export { + CHANGED_PATH_DELIM, + joinChangedPaths, +} from "../application/affected-engine"; export interface AffectedOpts { root: string; @@ -24,22 +24,6 @@ export interface AffectedOpts { json: boolean; } -/** - * Join project-relative paths for the `affected-tests` recipe param. - * Filters empty segments; preserves order of first occurrence. - */ -export function joinChangedPaths(paths: Iterable): string { - const seen = new Set(); - const out: string[] = []; - for (const raw of paths) { - const path = raw.trim().replace(/^\.\/+/, ""); - if (path.length === 0 || seen.has(path)) continue; - seen.add(path); - out.push(path); - } - return out.join(CHANGED_PATH_DELIM); -} - /** * Read newline-delimited paths from stdin (ignores empty lines). */ @@ -49,10 +33,9 @@ export async function readChangedPathsFromStdin(): Promise { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); } const text = Buffer.concat(chunks).toString("utf8"); - return text - .split(/\r?\n/) - .map((line) => line.trim().replace(/^\.\/+/, "")) - .filter((line) => line.length > 0); + return normalizeChangedPathList( + text.split(/\r?\n/).map((line) => line.trim()), + ); } export function printAffectedCmdHelp(): void { @@ -70,7 +53,7 @@ Path sources (first match wins): Flags: --params key=value Pass recipe params (repeatable). Supported: test_glob, max_depth. changed_files is built automatically. - --json Emit JSON array of {test_path, impact_depth}. + --json Emit JSON array of {test_path, impact_depth, actions?}. --help, -h Show this help. Examples: @@ -146,10 +129,10 @@ export function parseAffectedRest(rest: string[]): if (key === "test_glob") testGlob = value; else if (key === "max_depth") { const n = Number(value); - if (!Number.isFinite(n) || n < 0) { + if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) { return { kind: "error", - message: `codemap affected: --params max_depth="${value}" must be a non-negative number.`, + message: `codemap affected: --params max_depth="${value}" must be a non-negative integer.`, }; } maxDepth = n; @@ -203,15 +186,15 @@ async function resolveChangedPaths(opts: { positionalPaths: string[]; }): Promise<{ ok: true; paths: string[] } | { ok: false; error: string }> { if (opts.positionalPaths.length > 0) { - return { ok: true, paths: opts.positionalPaths }; + return { ok: true, paths: normalizeChangedPathList(opts.positionalPaths) }; } if (opts.stdin) { return { ok: true, paths: await readChangedPathsFromStdin() }; } - const ref = opts.changedSince ?? "HEAD"; - const result = getFilesChangedSince(ref, opts.root); - if (!result.ok) return { ok: false, error: result.error }; - return { ok: true, paths: [...result.files] }; + return resolveAffectedChangedPaths({ + root: opts.root, + changedSince: opts.changedSince, + }); } /** @@ -221,54 +204,21 @@ export async function runAffectedCmd(opts: AffectedOpts): Promise { try { await bootstrapCodemap(opts); - const changedRaw = joinChangedPaths(opts.changedPaths); - if (changedRaw.length === 0) { - if (opts.json) { - console.log("[]"); - } else { - console.log("(no changed files — no affected tests)"); - } - return; - } - - const declared = getQueryRecipeParams("affected-tests"); - const resolved = resolveRecipeParams({ - recipeId: "affected-tests", - declared, - provided: { - changed_files: changedRaw, - ...(opts.testGlob !== undefined ? { test_glob: opts.testGlob } : {}), - ...(opts.maxDepth !== undefined ? { max_depth: opts.maxDepth } : {}), - }, - }); - if (!resolved.ok) { - throw new Error(resolved.error); - } - - const sql = getQueryRecipeSql("affected-tests"); - if (sql === undefined) { - throw new Error( - 'codemap affected: bundled recipe "affected-tests" missing', - ); - } - - const payload = executeQuery({ - sql, - bindValues: resolved.values, + const result = executeAffectedTests({ root: getProjectRoot(), - recipeActions: getQueryRecipeActions("affected-tests"), + changedPaths: opts.changedPaths, + testGlob: opts.testGlob, + maxDepth: opts.maxDepth, }); + if (!result.ok) { + throw new Error(result.error); + } - if ( - payload !== null && - typeof payload === "object" && - !Array.isArray(payload) && - "error" in payload - ) { - throw new Error(String((payload as { error: string }).error)); + if (opts.changedPaths.length > 0) { + tryRecordRecipeRun("affected-tests"); } - const rows = payload as unknown[]; + const rows = result.rows; if (opts.json) { console.log(JSON.stringify(rows)); @@ -276,7 +226,11 @@ export async function runAffectedCmd(opts: AffectedOpts): Promise { } if (rows.length === 0) { - console.log("(no affected test files)"); + console.log( + opts.changedPaths.length === 0 + ? "(no changed files — no affected tests)" + : "(no affected test files)", + ); return; } diff --git a/templates/agent-content/mcp-instructions.md b/templates/agent-content/mcp-instructions.md index b83530a8..5a3341c7 100644 --- a/templates/agent-content/mcp-instructions.md +++ b/templates/agent-content/mcp-instructions.md @@ -10,18 +10,19 @@ Operational playbook injected into the MCP initialize handshake. Full schema, re ## Common tasks -| Goal | MCP tool | Recipe twin (`query_recipe`) | -| ----------------------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------ | -| Exact symbol lookup | **`show`** (`name`, optional `in`) | `find-symbol-definitions` | -| 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` | -| CI / SARIF | **`query_recipe`** + `format: "sarif"` | `deprecated-symbols`, `boundary-violations`, … | -| Ad-hoc SQL | **`query`** | — | -| N statements / one round-trip | **`query_batch`** (MCP-only) | N × `query` | -| Index freshness | **`validate`** | — | -| Drift vs baseline | **`audit`** | saved via `save_baseline` + `query_recipe` / `query` | -| Apply recipe diff rows | **`apply`** | recipe must emit `{file_path, line_start, before_pattern, after_pattern}` rows | +| Goal | MCP tool | Recipe twin (`query_recipe`) | +| ----------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------ | +| Exact symbol lookup | **`show`** (`name`, optional `in`) | `find-symbol-definitions` | +| 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` | +| 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`** (MCP-only) | N × `query` | +| Index freshness | **`validate`** | — | +| Drift vs baseline | **`audit`** | saved via `save_baseline` + `query_recipe` / `query` | +| Apply recipe diff rows | **`apply`** | recipe must emit `{file_path, line_start, before_pattern, after_pattern}` rows | ## Chains @@ -38,6 +39,6 @@ Operational playbook injected into the MCP initialize handshake. Full schema, re ## Recipe ids cited here -`find-symbol-definitions`, `find-symbol-by-kind`, `find-symbol-references`, `fan-in`, `deprecated-symbols`, `boundary-violations`, `refactor-risk-ranking`. Others: list via **`codemap://recipes`** before **`query_recipe`**. +`find-symbol-definitions`, `find-symbol-by-kind`, `find-symbol-references`, `fan-in`, `affected-tests`, `deprecated-symbols`, `boundary-violations`, `refactor-risk-ranking`. Others: list via **`codemap://recipes`** before **`query_recipe`**. - + diff --git a/templates/agent-content/skill/10-recipes-context.md b/templates/agent-content/skill/10-recipes-context.md index 920c4441..4fc756d4 100644 --- a/templates/agent-content/skill/10-recipes-context.md +++ b/templates/agent-content/skill/10-recipes-context.md @@ -47,13 +47,15 @@ Each emitted delta carries its own `base` metadata so mixed-baseline audits are - **`show`** — `{name, kind?, in?}`. Exact symbol lookup → `{matches, disambiguation?}`. Fuzzy lookup belongs in `query` with `LIKE`. - **`snippet`** — same shape as `show` but each match also carries `source` (file text) + `stale` / `missing` flags. No reindex side-effects. - **`impact`** — `{target, direction?, via?, depth?, limit?, summary?}`. Symbol/file blast-radius walker (replaces hand-composed `WITH RECURSIVE`). Auto-resolves symbol vs file target; `via` defaults to every backend compatible with the kind. +- **`affected`** — `{paths?, changed_since?, test_glob?, max_depth?}`. Reverse-dependency walk from changed files to test paths (same preprocessor as **`codemap affected`** → **`affected-tests`** recipe). Explicit `paths` (including `paths: []` for empty — skips git) wins over git discovery; omit `paths` for working tree vs `changed_since` (default `HEAD`). When both `paths` and `changed_since` are sent, `paths` wins (mirrors CLI positional + `--changed-since`). - **`apply`** — `{recipe, params?, dry_run?, yes?}`. Executes the diff hunks a recipe row produces (`{file_path, line_start, before_pattern, after_pattern}`). **All-or-nothing**: any conflict aborts before any file is written. Over MCP/HTTP `yes: true` is required for the write path; `dry_run` and `yes` are mutually exclusive. -**Affected tests (CLI-first):** **`codemap affected`** — reverse `dependencies` walk from changed files to test paths; primary consumer is CI/shell (`stdin`, `--changed-since`). Thin composer over the bundled **`affected-tests`** recipe. **Agents (MCP/HTTP):** use **`query_recipe`** with `recipe: "affected-tests"` and `changed_files` (RS-delimited paths when multiple). Path sources for the CLI verb: positional args → `--stdin` → `--changed-since ` → default `HEAD` (working tree via `git status` + `HEAD...HEAD` diff). Example: +**Affected tests:** **`codemap affected`** (CLI) for CI/shell (`stdin`, `--changed-since`). **`affected`** MCP/HTTP tool or **`query_recipe`** with `recipe: "affected-tests"` for agents. Path sources on CLI: positional → `--stdin` → `--changed-since` → default `HEAD`. On MCP/HTTP: `paths` array → else `changed_since` / `HEAD`. Example: ```bash codemap affected --json git diff --name-only origin/main | codemap affected --stdin --json +# MCP/HTTP: affected { paths: ["src/foo.ts"] } — or query_recipe fallback: codemap query --json --recipe affected-tests --params changed_files=src/foo.ts ``` diff --git a/templates/recipes/affected-tests.md b/templates/recipes/affected-tests.md index 4bcf05df..e789153d 100644 --- a/templates/recipes/affected-tests.md +++ b/templates/recipes/affected-tests.md @@ -34,4 +34,4 @@ git diff --name-only origin/main | codemap affected --stdin --json codemap query --recipe affected-tests --params changed_files=src/lib/complexity-fixture.ts --json ``` -Each row: `test_path`, `impact_depth`. +Each row: `test_path`, `impact_depth`, optional per-row `actions` (same as `--json` recipe output).