diff --git a/.agents/rules/codemap.md b/.agents/rules/codemap.md index 28d86b3e..353a6674 100644 --- a/.agents/rules/codemap.md +++ b/.agents/rules/codemap.md @@ -25,6 +25,7 @@ A local database (default **`.codemap.db`**) indexes structure: symbols, imports | Save / diff a baseline | — | `bun src/index.ts query --save-baseline -r visibility-tags` then `… --json --baseline -r visibility-tags` | | List / drop baselines | — | `bun src/index.ts query --baselines` · `bun src/index.ts query --drop-baseline ` | | Per-delta audit | — | `bun src/index.ts audit --json --baseline base` (auto-resolves `base-files` / `base-dependencies` / `base-deprecated`) | +| Audit vs git ref | — | `bun src/index.ts audit --base origin/main --json` — worktree+reindex against any committish; sub-100ms second run via sha-keyed cache. Mutually exclusive with `--baseline`; per-delta overrides compose. | | MCP server (for agent hosts) | — | `bun src/index.ts mcp [--watch] [--debounce ]` — JSON-RPC on stdio; one tool per CLI verb. See **MCP** section below. | | HTTP server (for non-MCP) | — | `bun src/index.ts serve [--host 127.0.0.1] [--port 7878] [--token ] [--watch] [--debounce ]` — same tool taxonomy over POST /tool/{name}. | | Watch mode (live reindex) | — | `bun src/index.ts watch [--debounce 250] [--quiet]` — long-running; debounced reindex on file changes. Combine with `mcp --watch` / `serve --watch` (or `CODEMAP_WATCH=1`) so every tool reads a live index without per-request prelude. | @@ -52,7 +53,7 @@ Validation: SQL is rejected at load time if it starts with DML/DDL (DELETE/DROP/ **Baselines** (`query_baselines` table inside `.codemap.db`, no parallel JSON files): `--save-baseline[=]` snapshots a result set; `--baseline[=]` diffs the current result against it (added / removed rows; identity = `JSON.stringify(row)`). Name defaults to the `--recipe` id; ad-hoc SQL needs an explicit `=`. Survives `--full` and SCHEMA bumps. -**Audit (`bun src/index.ts audit`)**: structural-drift command; emits `{head, deltas: {files, dependencies, deprecated}}` (each delta carries its own `base` metadata). Reuses B.6 baselines as the snapshot source. Two CLI shapes — `--baseline ` auto-resolves `-files` / `-dependencies` / `-deprecated`; `---baseline ` is the explicit per-delta override. v1 ships no `verdict` / threshold config — consumers compose `--json` + `jq` for CI exit codes. Auto-runs an incremental index before the diff (use `--no-index` to skip for frozen-DB CI). +**Audit (`bun src/index.ts audit`)**: structural-drift command; emits `{head, deltas: {files, dependencies, deprecated}}` (each delta carries its own `base` metadata). Three mutually-exclusive snapshot sources: `--base ` materialises a git committish via `git worktree add` to a sha-keyed cache under `.codemap/audit-cache/`, reindexes a temp DB, then diffs (sub-100ms second run; requires git; `base.source: "ref"`); `--baseline ` auto-resolves `-files` / `-dependencies` / `-deprecated` from saved `query_baselines` entries (`base.source: "baseline"`); `---baseline ` is the explicit per-delta override (composes with both). v1 ships no `verdict` / threshold config — consumers compose `--json` + `jq` for CI exit codes. Auto-runs an incremental index before the diff (use `--no-index` to skip for frozen-DB CI). **Targeted reads (`show` / `snippet`)**: precise lookup by exact symbol name without composing SQL. `show` returns metadata (`file_path:line_start-line_end` + `signature`); `snippet` returns the source text from disk plus `stale` / `missing` flags. Both share the same flag set (`--kind ` to filter by `symbols.kind`, `--in ` for file-scope filter — directory prefix or exact file). Output envelope is `{matches, disambiguation?}` — single match → `{matches: [{...}]}`; multi-match adds `disambiguation: {n, by_kind, files, hint}` so agents narrow without re-scanning. Name match is exact / case-sensitive — for fuzzy use `query` with `LIKE '%name%'`. Snippet stale-file behavior: `source` is always returned when the file exists; `stale: true` means the line range may have shifted (re-index with `bun src/index.ts` or `--files ` before acting on the source). diff --git a/.agents/skills/codemap/SKILL.md b/.agents/skills/codemap/SKILL.md index 674d6dda..3b962b4f 100644 --- a/.agents/skills/codemap/SKILL.md +++ b/.agents/skills/codemap/SKILL.md @@ -65,7 +65,7 @@ Each emitted delta carries its own `base` metadata so mixed-baseline audits are - **`query`** — one SQL statement. Args: `{sql, summary?, changed_since?, group_by?, format?}`. Same envelope as `codemap query --json`. Pass `format: "sarif"` or `"annotations"` to receive a formatted text payload (SARIF 2.1.0 doc / `::notice` lines); ad-hoc SQL gets `rule.id = codemap.adhoc`. Format is incompatible with `summary` / `group_by` (parser rejects with a structured `{error}`). - **`query_batch`** — MCP-only, no CLI counterpart. Args: `{statements: (string | {sql, summary?, changed_since?, group_by?})[], summary?, changed_since?, group_by?}`. Items are bare SQL strings (inherit batch-wide flag defaults) or objects (override on a per-key basis). Output is N-element array; per-element shape mirrors single-`query`'s output for that statement's effective flag set. Per-statement errors are isolated — failed statements return `{error}` in their slot; siblings still execute. SQL-only (no `recipe` polymorphism in items). `format` deferred to v1.x — annotation/sarif on a heterogeneous batch is awkward; call `query` per recipe instead. - **`query_recipe`** — `{recipe, summary?, changed_since?, group_by?, format?}`. Resolves the recipe id to SQL + per-row actions, then executes like `query`. Unknown recipe id returns a structured `{error}` pointing at the `codemap://recipes` resource. With `format: "sarif"`, `rule.id = codemap.`, `rule.shortDescription` = recipe description, `rule.fullDescription` = the recipe's `.md` body. -- **`audit`** — `{baseline_prefix?, baselines?: {files?, dependencies?, deprecated?}, summary?, no_index?}`. Composes per-delta baselines into the `{head, deltas}` envelope. Auto-runs incremental index unless `no_index: true`. +- **`audit`** — `{base?, baseline_prefix?, baselines?: {files?, dependencies?, deprecated?}, summary?, no_index?}`. Composes per-delta snapshots into the `{head, deltas}` envelope. Two **primary** sources are mutually exclusive: `base: ` (git committish — worktree+reindex against any committish; sha-keyed cache under `.codemap/audit-cache/`; sub-100ms second run; requires git, errors cleanly on non-git projects) OR `baseline_prefix: ""` (auto-resolve `-{files,dependencies,deprecated}` from `query_baselines`). Plus optional **per-delta overrides** via `baselines: {: }` that compose with either primary source. Per-delta `base.source` is `"ref"` (with `base.ref` + `base.sha`) or `"baseline"` (with `base.name` + `base.sha`). Auto-runs incremental index unless `no_index: true`; watch-active sessions skip the prelude automatically. - **`save_baseline`** — polymorphic `{name, sql? | recipe?}` with runtime exclusivity check (mirrors the CLI's single `--save-baseline=` verb). Pass exactly one of `sql` or `recipe`. - **`list_baselines`** — no args; returns the array `codemap query --baselines --json` would print. - **`drop_baseline`** — `{name}`. Returns `{dropped: }` on success or `isError` if the name doesn't exist. diff --git a/.changeset/codemap-audit-base.md b/.changeset/codemap-audit-base.md new file mode 100644 index 00000000..df7c8f90 --- /dev/null +++ b/.changeset/codemap-audit-base.md @@ -0,0 +1,33 @@ +--- +"@stainless-code/codemap": minor +--- + +`codemap audit --base ` — ad-hoc structural-drift audit against any git committish (`origin/main`, `HEAD~5`, ``, tag, …). Closes the highest-frequency post-watch agent loop: "what changed structurally between this branch and `origin/main`?". Replaces today's 3-step `--baseline` dance (switch branches, reindex, save baselines, switch back) with one verb. + +**Three transports, one engine:** + +- **CLI:** `codemap audit --base [---baseline ] [--summary] [--json] [--no-index]` +- **MCP tool:** `audit` with new `base?: string` arg +- **HTTP:** `POST /tool/audit` (auto-wired via the existing dispatcher) + +All three dispatch the same pure `runAuditFromRef` engine in `application/audit-engine.ts`. + +**How it works:** + +1. `git rev-parse --verify "^{commit}"` resolves `` to a sha (clean error on non-git or unresolvable ref). +2. Cache lookup at `/.codemap/audit-cache//.codemap.db`. Hit → sub-100ms; miss → continue. +3. **Atomic populate** — `git worktree add` to a per-pid temp dir + `runCodemapIndex({mode: "full"})` against the worktree's `.codemap.db` + POSIX `rename` claims the final `/` slot. Concurrent CI matrix runs against the same sha race-safely without lock files (loser's rename fails with EEXIST → falls through to cache hit). +4. Run each delta's canonical SQL on the cached DB vs the live DB; `diffRows` (existing helper) computes `{added, removed}`. +5. Compose `AuditEnvelope` with per-delta `base.source: "ref"` (new value) + `base.ref` (user-supplied string) + `base.sha` (resolved). + +**Decisions worth knowing:** + +- **`AuditBase` is now a discriminated union** — existing `{source: "baseline", name, sha, indexed_at}` rows untouched; new `{source: "ref", ref, sha, indexed_at}` arm. Consumers narrowing on `base.source` keep compiling. +- **Mutually exclusive with `--baseline `.** Parser + handler both guard. Per-delta `---baseline` overrides compose orthogonally with both, so `--base origin/main --files-baseline pre-refactor-files` is valid (mixed sources). +- **Eviction:** hardcoded LRU 5 entries / 500 MiB; `git worktree remove --force` + `rm -rf` for each victim. Orphan `.tmp.*` dirs older than 10 min get swept on the next cycle. No config knobs in v1; defer to v1.x+ if real consumers ask. +- **Hard error on non-git projects.** No graceful fallback — there's no meaningful "ref" without git. The other audit modes (`--baseline`, `---baseline`) still work without git. +- **Env hygiene.** All git spawns in `audit-worktree.ts` strip inherited `GIT_*` env vars so a containing git operation (e.g. running codemap from a husky hook) doesn't route worktree calls at the wrong index. + +**Auto-`.gitignore`:** `codemap agents init` now adds `.codemap/audit-cache/` alongside `.codemap.*` so cached worktrees never get committed. `.codemap/recipes/` stays git-tracked. + +Plan: PR #51 (merged). Implementation: PR #52. diff --git a/.gitignore b/.gitignore index 10649298..8fffa96e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ .codemap.* +.codemap/audit-cache/ .DS_Store dist/ *.tgz diff --git a/README.md b/README.md index 16567aba..d3693d17 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,11 @@ codemap audit --json --summary --baseline base # counts-only codemap audit --files-baseline base-files # explicit per-delta — runs only the slots provided codemap audit --baseline base --files-baseline hotfix-files # mixed — auto-resolve deps + deprecated; override files codemap audit --baseline base --no-index # skip the auto-incremental-index prelude (frozen-DB CI) +codemap audit --base origin/main --json # ad-hoc — worktree+reindex against any committish; no --save-baseline needed +codemap audit --base v1.0.0 --files-baseline pre-release-files # mix --base with per-delta override +# --base materialises via `git worktree add` to .codemap/audit-cache//, reindexes into +# a temp DB, then diffs. Cache hit on second run against same sha is sub-100ms. Requires git; +# non-git projects get a clean `--base requires a git repository` error. # Recipes that define per-row action templates append "actions" hints (kebab-case verb + # description) in --json output; ad-hoc SQL never carries actions. Inspect via --recipes-json. # --format — pipe results into GitHub Code Scanning (SARIF diff --git a/docs/architecture.md b/docs/architecture.md index 95eae8d4..77858d92 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -16,13 +16,13 @@ A local SQLite database (`.codemap.db`) indexes the project tree and stores stru ## 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`); `context-engine.ts` (`buildContextEnvelope`); `validate-engine.ts` (`computeValidateRows` + `toProjectRelative`); `show-engine.ts` (lookup + envelope builders); `impact-engine.ts` (`findImpact` — graph blast-radius walker); `query-recipes.ts` + `recipes-loader.ts` (recipe registry); `output-formatters.ts` (SARIF + GH annotations); `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); `query-recipes.ts` + `recipes-loader.ts` (recipe registry); `output-formatters.ts` (SARIF + GH annotations); `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). @@ -123,7 +123,7 @@ A local SQLite database (`.codemap.db`) indexes the project tree and stores stru **Validate wiring:** **`src/cli/cmd-validate.ts`** (argv + render) + **`src/application/validate-engine.ts`** (engine — **`computeValidateRows`** + **`toProjectRelative`**). `computeValidateRows` is a pure function over `(db, projectRoot, paths)` returning `{path, status}` rows where `status ∈ stale | missing | unindexed`. CLI wraps it with read-once-and-print + exits **1** on any drift (git-status semantics). Path normalization: **`toProjectRelative`** converts CLI input to POSIX-style relative keys matching the `files.path` storage format (Windows backslash → forward slash); same convention as `lint-staged.config.js`. Also reused by `cmd-show.ts` / `cmd-snippet.ts` and the MCP show/snippet handlers — single canonical implementation. -**Audit wiring:** **`src/cli/cmd-audit.ts`** (argv, `--baseline ` auto-resolve sugar, `---baseline ` per-delta explicit overrides, `--json`, `--summary`, `--no-index`) + **`src/application/audit-engine.ts`** (delta registry + diff). Mirrors the `cmd-index.ts ↔ application/index-engine.ts` seam — CLI parses + dispatches; engine does the diff. **`runAudit({db, baselines})`** iterates the per-delta baseline map; deltas absent from the map don't run. Each entry in **`V1_DELTAS`** pins a canonical SQL projection (`files`: `SELECT path FROM files`; `dependencies`: `SELECT from_path, to_path FROM dependencies`; `deprecated`: `SELECT name, kind, file_path FROM symbols WHERE doc_comment LIKE '%@deprecated%'`) plus a `requiredColumns` list. **`computeDelta`** validates baseline column-set membership, projects baseline rows down to the canonical column subset (extras dropped — schema-drift-resilient), runs the canonical SQL via the caller's DB connection, and set-diffs via the existing **`src/diff-rows.ts`** multiset helper (shared with `query --baseline`). Each emitted delta carries its own **`base`** metadata so mixed-baseline audits (e.g. `--baseline base --dependencies-baseline override`) are first-class. **`runAuditCmd`** runs an auto-incremental-index prelude (`runCodemapIndex({mode: "incremental", quiet: true})`) before the diff so `head` reflects the current source — `--no-index` opts out for frozen-DB CI scenarios. **`resolveAuditBaselines({db, baselinePrefix, perDelta})`** composes the baseline map: auto-resolves `-` for slots that exist (silently absent otherwise) and lets per-delta flags override individual slots. v1 ships no `verdict` / threshold config / non-zero exit codes — consumers compose `--json` + `jq` for CI exit codes; v1.x adds `verdict` + `codemap.config.audit` thresholds + `--base ` (worktree+reindex snapshot strategy). +**Audit wiring:** **`src/cli/cmd-audit.ts`** (argv, `--baseline ` auto-resolve sugar, `---baseline ` per-delta explicit overrides, `--base ` git-ref baseline, `--json`, `--summary`, `--no-index`) + **`src/application/audit-engine.ts`** (delta registry + diff). Mirrors the `cmd-index.ts ↔ application/index-engine.ts` seam — CLI parses + dispatches; engine does the diff. **`runAudit({db, baselines})`** iterates the per-delta baseline map; deltas absent from the map don't run. Each entry in **`V1_DELTAS`** pins a canonical SQL projection (`files`: `SELECT path FROM files`; `dependencies`: `SELECT from_path, to_path FROM dependencies`; `deprecated`: `SELECT name, kind, file_path FROM symbols WHERE doc_comment LIKE '%@deprecated%'`) plus a `requiredColumns` list. **`computeDelta`** validates baseline column-set membership, projects baseline rows down to the canonical column subset (extras dropped — schema-drift-resilient), runs the canonical SQL via the caller's DB connection, and set-diffs via the existing **`src/diff-rows.ts`** multiset helper (shared with `query --baseline`). Each emitted delta carries its own **`base`** metadata so mixed-baseline audits (e.g. `--baseline base --dependencies-baseline override`) are first-class. **`runAuditCmd`** runs an auto-incremental-index prelude (`runCodemapIndex({mode: "incremental", quiet: true})`) before the diff so `head` reflects the current source — `--no-index` opts out for frozen-DB CI scenarios. **`resolveAuditBaselines({db, baselinePrefix, perDelta})`** composes the baseline map: auto-resolves `-` for slots that exist (silently absent otherwise) and lets per-delta flags override individual slots. v1 ships no `verdict` / threshold config / non-zero exit codes — consumers compose `--json` + `jq` for CI exit codes; v1.x still tracks `verdict` + `codemap.config.audit` thresholds. **`--base ` (shipped):** **`runAuditFromRef({db, ref, perDeltaOverrides, projectRoot, reindex})`** materialises the ref via **`application/audit-worktree.ts`** — `git rev-parse --verify "^{commit}"` → resolved sha → cache lookup at `/.codemap/audit-cache//`. Cache miss: per-pid temp dir (`.tmp...`) gets `git worktree add --detach`, the injected `reindex` callback (`makeWorktreeReindex` in production — re-inits the runtime singletons against the worktree path, runs `runCodemapIndex({mode: "full"})`, restores) writes `.codemap.db` inside, then POSIX `rename` claims the final `/` slot. **Atomic populate** — concurrent processes resolving the same sha race-safely without lock files (loser's rename fails with EEXIST → falls through to cache hit). Eviction: hardcoded LRU 5 entries / 500 MiB; `git worktree remove --force` then `rm -rf` for each victim; orphan `.tmp.*` dirs older than 10 min get swept too. Per-delta `base` metadata gains a discriminator: existing baseline-source remains `{source: "baseline", name, sha, indexed_at}`; new ref-source is `{source: "ref", ref, sha, indexed_at}`. `--base` is mutually exclusive with `--baseline ` (parser + handler both guard); composes orthogonally with per-delta `---baseline name` overrides. Hard error on non-git projects (`existsSync(/.git)` check before any spawn). All git spawns in `audit-worktree.ts` strip inherited `GIT_*` env vars so a containing git operation (e.g. running codemap inside a husky hook) doesn't route worktree calls at the wrong index. **Context wiring:** **`src/cli/cmd-context.ts`** (argv + render) + **`src/application/context-engine.ts`** (engine — **`buildContextEnvelope`**, **`classifyIntent`**, `ContextEnvelope` type). `buildContextEnvelope` composes the JSON envelope from existing recipes (`fan-in` for `hubs`, `markers` SELECT for `sample_markers`, `QUERY_RECIPES` map for the catalog). **`classifyIntent`** maps `--for ""` to one of `refactor | debug | test | feature | explore | other` via regex against the trimmed input; whitespace-only intents are rejected. `--compact` drops `hubs` + `sample_markers` and emits one-line JSON; otherwise pretty-prints with 2-space indent. diff --git a/docs/glossary.md b/docs/glossary.md index d284af91..3ebe7b51 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -33,7 +33,11 @@ A `.agents/rules/.md` file with YAML frontmatter. Distinct from a **skill* ### audit -Two-snapshot structural-drift command: `codemap audit --baseline ` (or `---baseline `) diffs the live `.codemap.db` against per-delta saved baselines (B.6) and emits `{head, deltas}` where each `deltas[]` carries `{base, added, removed}`. v1 ships three deltas: `files`, `dependencies`, `deprecated`. Each delta pins a canonical SQL projection (in `V1_DELTAS`) and a required-columns list — projects baseline rows down to that subset before diffing so schema bumps that add columns don't break pre-bump baselines. Distinct from `codemap query --baseline` (that's one query, one diff; audit composes multiple per-delta diffs into one envelope). Distinct from `fallow audit` (that runs code-quality verdicts — dead code, dupes, complexity — which are explicit non-goals per [`roadmap.md` § Non-goals (v1)](./roadmap.md#non-goals-v1); codemap audit stays structural). +Two-snapshot structural-drift command: `codemap audit` diffs the live `.codemap.db` against a base snapshot and emits `{head, deltas}` where each `deltas[]` carries `{base, added, removed}`. v1 ships three deltas: `files`, `dependencies`, `deprecated`. Each delta pins a canonical SQL projection (in `V1_DELTAS`) and a required-columns list — projects baseline rows down to that subset before diffing so schema bumps that add columns don't break pre-bump baselines. Three mutually-exclusive top-level snapshot sources: `--baseline ` (auto-resolve `-files` / `-dependencies` / `-deprecated` from `query_baselines`), `---baseline ` (explicit per-delta — composes with the others), and `--base ` (worktree + reindex against a git committish — see § A `audit --base`). Distinct from `codemap query --baseline` (that's one query, one diff; audit composes multiple per-delta diffs into one envelope). Distinct from `fallow audit` (that runs code-quality verdicts — dead code, dupes, complexity — which are explicit non-goals per [`roadmap.md` § Non-goals (v1)](./roadmap.md#non-goals-v1); codemap audit stays structural). + +### `audit --base ` / git-ref baseline + +Ad-hoc audit snapshot from any git committish (`origin/main`, `HEAD~5`, ``, tag, …). `git worktree add` materialises `` to `/.codemap/audit-cache//`, codemap reindexes into the worktree's `.codemap.db`, then per-delta canonical SQL runs on that DB vs the live one. Cache key is the **resolved sha** (`git rev-parse --verify`), so `--base origin/main` and `--base ` (when they point at the same commit) share one cache entry. **Atomic populate** — per-pid temp dir + POSIX `rename`; concurrent processes resolving the same sha race-safely without lock files. Eviction: hardcoded LRU 5 entries / 500 MiB. Per-delta `base.source` is `"ref"` (vs `"baseline"`) and the delta carries `base.ref` (user-supplied string) + `base.sha` (resolved). Mutually exclusive with `--baseline `; composes orthogonally with per-delta `---baseline ` overrides. Hard error on non-git projects (no graceful fallback — there's no meaningful "ref" without git). Both transports (MCP `audit` tool's `base?` arg, HTTP `POST /tool/audit`) call the same `runAuditFromRef` engine in `application/audit-engine.ts`. --- diff --git a/docs/plans/audit-base.md b/docs/plans/audit-base.md deleted file mode 100644 index 4b5f013d..00000000 --- a/docs/plans/audit-base.md +++ /dev/null @@ -1,132 +0,0 @@ -# `codemap audit --base ` — git-ref baseline (worktree + reindex) - -> **Status:** in design (no code) · **Backlog:** [`docs/roadmap.md` § Backlog](../roadmap.md#backlog) → "`codemap audit --base ` (v1.x)". Delete this file when shipped (per [`docs/README.md` Rule 3](../README.md)). - -## Goal - -`codemap audit --baseline ` (PR #33) compares the live index against pre-saved per-delta baselines. That covers the workflow "I baseline once at v1.0.0, then track drift between releases." It does NOT cover the workflow agents hit every PR review: - -> "What changed structurally between this branch and `origin/main`?" - -Today's workaround is a 3-step dance: `codemap query --save-baseline=pr-base -r files`, `git checkout origin/main && codemap && codemap query --save-baseline=…`, switch back, `codemap audit --baseline pr-base`. The agent has to remember to switch branches, remember the baseline name, AND keep the index in sync each time. - -`codemap audit --base ` collapses that into one verb. Worktree + reindex against any committish, diff against current — same `{head, deltas}` envelope `--baseline` already emits. - -## Why this is the next-best agent-value move - -| Loop | Status (post-PR #50) | This plan | -| ----------------------------------------------------------------------------- | ------------------------------------- | ------------------------ | -| "Is the index stale?" | Solved by `--watch` | — | -| "Where is X?" | Solved by `show` / `snippet` | — | -| "Blast radius of X?" | Solved by `impact` (PR #50) | — | -| **"What changed in this PR? / Did my refactor break anything structurally?"** | Workaround: 3-step `--baseline` dance | **`audit --base `** | -| "When did X arrive?" | Requires `git log -L` shell-out | Out of scope | -| "Is this dead code?" | `impact` says 0 callers; coverage gap | Out of scope (C.11) | - -PR review is a **daily** agent loop. This unblocks it with one verb that reuses 90% of the existing audit infrastructure. - -## Sketched API - -CLI surface (additive on the existing `audit` command): - -```bash -# Existing flags (unchanged): -codemap audit --baseline [---baseline ] [--summary] [--json] [--no-index] - -# New flag: -codemap audit --base [--summary] [--json] [--no-index] - # = any committish: origin/main · HEAD~5 · v1.0.0 · · etc. - -# New: combine --base with explicit per-delta override -codemap audit --base origin/main --files-baseline pr-files [--json] - # files delta uses pre-saved 'pr-files' baseline; dependencies + deprecated - # use the worktree-derived rows from origin/main. - -# Errors: -codemap audit --base origin/main --baseline pr # ERROR: --base and --baseline are mutually exclusive -codemap audit --base bogus-ref # ERROR: --base: cannot resolve "bogus-ref" to a commit (git rev-parse failed) -codemap audit --base origin/main # in non-git project: ERROR: --base requires a git repository -``` - -MCP tool: `audit` gains an optional `base?: string` arg with the same semantics. HTTP `POST /tool/audit` lights up automatically via the existing dispatcher. - -## Output envelope - -Identical to today's `--baseline` shape. Only the per-delta `base` metadata changes: - -```jsonc -{ - "head": { "sha": "abc123", "indexed_at": 1714742400 }, - "deltas": { - "files": { - "base": { - "source": "ref", // NEW value (was always "baseline") - "ref": "origin/main", // NEW field (alongside existing name) - "sha": "def456", // resolved sha at audit time - "indexed_at": 1714742400 // when the worktree index ran - }, - "added": [{ "path": "src/new.ts" }, ...], - "removed": [{ "path": "src/old.ts" }, ...] - }, - "dependencies": { ... }, - "deprecated": { ... } - } -} -``` - -Per-delta `base.source` already exists as a discriminator (`"baseline"` today). Adding `"ref"` is a backwards-compatible enum extension; consumers that switch on `base.source` get a clean miss instead of a silent failure. - -**TS type change required.** `AuditBase` (in `src/application/audit-engine.ts`) becomes a discriminated union: the existing `{source: "baseline", name, sha, indexed_at}` shape stays, plus a new `{source: "ref", ref, sha, indexed_at}` arm. Tracer 1 ships the type widening; downstream consumers that narrow on `base.source` keep compiling because the discriminator is exhaustive. - -## Decisions - -| # | Decision | -| --- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| D1 | **Worktree + reindex strategy.** Use `git worktree add /.codemap/audit-cache// ` to materialise the ref alongside the project (NOT in `/tmp` — keeps it on the same filesystem as `.codemap.db` so `git worktree add`'s linkfile resolves and so it auto-falls under the project-local `.gitignore` entry). Run the existing `runCodemapIndex` against it; the indexer writes `.codemap.db` inside the worktree dir. Run each delta's canonical SQL on that DB to get the baseline rows. **NOT** in-place `git checkout` — that would mutate the user's working tree, break `watch` if running, and disrupt open editors. | -| D2 | **Cache IS the worktree dir.** `/.codemap/audit-cache//` is both the materialised tree AND the location of the temp `.codemap.db`. Cache hit on second run against same `` (resolved sha): existence check passes → just open the existing `.codemap.db` (no `git worktree add`, no reindex). Cache miss: see D11 (atomic populate). Eviction: LRU after 5 cache entries OR 500 MiB total (hardcoded in v1; no config surface — defer to v1.x+ if real consumers ask). Eviction calls `git worktree remove --force ` then `rm -rf` for safety. Cache key is the **resolved sha**, not the ref string, so `--base origin/main` and `--base abc123` (where `abc123` is `origin/main`'s tip) share one entry. **Worktree never removed on success** — that's the whole point of caching. | -| D3 | **Ref resolution upfront.** `git rev-parse --verify "${ref}^{commit}"` runs first — if it fails, return `{error: "codemap audit: --base: cannot resolve \"\" to a commit"}` before touching the worktree. Same shape `getFilesChangedSince` already uses (`git-changed.ts:23`). | -| D4 | **Non-git projects.** Hard error: `{error: "codemap audit: --base requires a git repository"}`. No graceful fallback — there's no meaningful "ref" without git. Detected via `existsSync(join(root, ".git"))` (cheap, runs before any spawn). | -| D5 | **Dirty working tree.** Audit current state regardless. The whole point is "compare current (potentially uncommitted) work to ." NO check / warning — symmetric with how `getFilesChangedSince` already handles `git status` rows alongside `git diff` output. | -| D6 | **Mutual exclusivity with `--baseline`.** Reject `--base X --baseline Y` at parse time with a structured error. `--base` + per-delta `---baseline name` IS allowed (D7) — that's the "I have a saved baseline for `files` but want fresh refs for the others" escape hatch. | -| D7 | **Per-delta override interaction.** `--base ` populates all 3 deltas from the worktree by default. `---baseline ` on top overrides ONE delta to use the saved baseline; the other two still use the worktree. Mirrors how `--baseline ` + per-delta overrides compose today (`resolveAuditBaselines` is the shared composer). | -| D8 | **Cleanup runs on failure, not success.** Per D2 the worktree IS the cache entry — keeping it is correct. `git worktree remove --force ` runs only on (a) cache-miss reindex throwing midway (rollback so a half-populated dir doesn't poison future cache hits), or (b) LRU eviction. Stale entries from process crashes (SIGKILL between worktree-add and reindex completion) get swept by the next eviction cycle. Optional `codemap audit --prune-cache` verb deferred to v1.x+ once real-world stale rates motivate it. | -| D9 | **Index prelude on the worktree.** Always run `runCodemapIndex({mode: "full"})` against the temp DB on cache miss — the worktree's tree has its own changed-set we can't reconstruct from the live `.codemap.db`. CLI `--no-index` controls the **head-side** prelude (existing flag, unchanged); the worktree-side reindex is non-optional because there's no prior index to be incremental against. | -| D10 | **`base.ref` field is the user-supplied string** (`origin/main`), `base.sha` is the resolved sha. Both surface in the envelope so CI logs can echo what the user asked for AND what it resolved to. Mirrors how baselines record `git_ref` today. | -| D11 | **Atomic cache populate (concurrency safety).** Two `codemap audit --base ` invocations resolving to the same sha must not race. Populate sequence on cache miss: (a) `mkdir -p .codemap/audit-cache/.tmp..` (per-pid temp dir, never the final path); (b) `git worktree add .codemap/audit-cache/.tmp.. ` then reindex into it; (c) `rename(.tmp.., )` — POSIX `rename` is atomic for same-filesystem moves and fails cleanly if `/` already exists (lost the race; remove the `.tmp` dir + reuse the winner's cache entry). Readers (cache-hit path) treat `/.codemap.db`'s existence as proof the entry is complete because the rename only happens after the reindex finishes. No lock files needed — POSIX `rename` semantics give us single-flight for free. Same pattern applies to LRU eviction: rename the victim dir to `.tmp.evict.` first, then `rm -rf` and `git worktree remove`. | - -## Tracers - -| # | Slice | Acceptance | -| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | -| 1 | `application/audit-engine.ts` extends with `runAuditFromRef({db, ref, perDeltaOverrides, root})` — pure function: takes a ref string, materialises the worktree (via new `application/audit-worktree.ts` helper), runs deltas, returns the same `AuditEnvelope` shape with `base.source: "ref"`. Unit tests against a tmp git repo fixture (init + commit + run audit against `HEAD~1`). | Returns 3 deltas; `base.ref` matches input; `base.sha` is the resolved sha; cache hit on second run against same sha. | -| 2 | `cmd-audit.ts` parser gains `--base `. Parser rejects `--base + --baseline` combo with a structured error. Per-delta `---baseline` still composes. Wired through `runAuditCmd` to dispatch to `runAuditFromRef` when `opts.base` is set. | `bun src/index.ts audit --base HEAD~1 --json` returns the envelope on this repo's history; error cases give clean messages. | -| 3 | MCP `audit` tool args schema gains `base?: z.string()`. `handleAudit` dispatches to the new ref path when set. Mutual-exclusion guard mirrors the CLI parser. | MCP integration test runs `audit` with `base: "HEAD~1"` against a fixture repo, confirms envelope shape + `base.source = "ref"`. | -| 4 | HTTP `POST /tool/audit` auto-wired via the existing `dispatchTool` switch arm — Zod validation on the new `base` arg lights up automatically. | HTTP integration test: POST with `{base: "HEAD~1"}` returns 200 + envelope; non-git project → 400 with the clean error message. | -| 5 | Docs sync — README (new audit example), `docs/architecture.md` § Audit wiring extended (worktree + cache section), `docs/glossary.md` (`audit --base` entry), `.agents/rules/codemap.md` + `templates/agents/rules/codemap.md` (Rule 10 lockstep — new table row + paragraph), `.agents/skills/codemap/SKILL.md` + templates (audit tool description gets `base` field), changeset (minor). Delete this plan file. Update `docs/roadmap.md` to remove the v1.x backlog entry. Add `.codemap/audit-cache/` to the auto-`.gitignore` list in `agents-init.ts` (mirrors how `.codemap.*` is handled today). | All docs updated; plan deleted. | - -## Performance considerations - -- **Cache miss** (first run against a ref): full reindex on the worktree. ~3 s for codemap (~110 files); ~30 s for a 10k-file repo. One-time cost per ref. -- **Cache hit** (subsequent runs): skip reindex; just open the temp DB, run 3 SQL queries, diff. Sub-100ms. -- **Worktree size**: `git worktree add` is essentially free (shares git objects via the `.git/worktrees/` linkfile). Only the working-tree files are duplicated; `.git/` itself is not copied. -- **Disk pressure**: 5-entry LRU × ~repo working-tree size. For a 10 MB working tree → 50 MB cache ceiling. No config surface in v1; defer to v1.x+ if real consumers ask. -- **Concurrent audits**: safe via the atomic populate pattern (D11) — POSIX `rename` gives single-flight semantics without lock files. SQLite WAL mode handles read concurrency on the head DB. - -## Alternatives considered (and rejected for now) - -| Candidate | Why not | -| ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| In-place `git checkout ` then reindex then checkout back | Mutates the user's working tree, breaks `watch` if running, disrupts editor open buffers, and any uncommitted work has to be stashed. Worktree avoids all of that. | -| Read git objects directly via `git cat-file --batch` (no working tree) | Possible but re-implements `git checkout`'s file-extraction logic. Worktree IS git's official "compare against another commit without disturbing the main tree" primitive. | -| Per-file diff against the ref (no full reindex on the worktree) | Would need a way to reconstruct the index incrementally from a tree object. Codemap's `getChangedFiles` does the inverse (current vs `last_indexed_commit`) — running it backwards is non-trivial and produces a less reliable result than just reindexing. | -| Save baselines automatically per ref | We have `--save-baseline` for that. `--base` is for **ad-hoc** comparisons where the user doesn't want to litter `query_baselines` with throwaway snapshots. | -| Use `git diff --raw -- ` to get the file change set, then derive deltas without reindexing | `dependencies` + `deprecated` deltas need parsed-symbol facts, not just file paths. No way to get those without running the parser on the ref's content. Files-only delta could shortcut this, but the asymmetry (one fast, two slow) is worse UX than uniform "all 3 take a worktree." | - -## Out of scope - -- **`--base` for non-audit commands** (e.g. `codemap query --base ""`). Would need a generic ref-snapshot facility; defer until 2 consumers ask. -- **Auto-baseline-save on first ref audit** (cache the worktree's row data into `query_baselines` for cross-tool reuse). Conflates two distinct lifecycles (baselines are durable; ref caches are LRU-evictable). -- **Ref-vs-ref audit** (`--base origin/main --head v1.0.0`). v1 always compares the current working tree to one ref. Two-ref audit is a v1.x+ if asked. -- **Verdict / threshold integration** (`audit.deltas[].{added_max, action}`). Already on the v1.x backlog as a separate item; orthogonal to this plan. -- **Network refs** (`--base https://github.com/foo/bar@main`). The user's local clone has to know the ref already. Fetching is `git fetch`'s job. -- **Worktree cache config** (size limit, TTL, eviction policy). Defer to env var + sane defaults; only build a config surface if benchmarks demand it. diff --git a/docs/research/fallow.md b/docs/research/fallow.md index cb504ffe..f5aeae14 100644 --- a/docs/research/fallow.md +++ b/docs/research/fallow.md @@ -11,20 +11,20 @@ Adoption-candidate ship status. The tier tables in § 1 are preserved as the original assessment record; this snapshot is the single source of truth for "what's open." Update on every PR that closes a row. -| Tier | # | Item | Status | Where it landed / why deferred | -| ---- | --------- | ------------------------------------------------------------------------------------ | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| A | A.1 | Per-row recipe `actions` | ✅ Shipped | PR [#26](https://github.com/stainless-code/codemap/pull/26) | -| A | A.2 | `--changed-since ` | ✅ Shipped | PR [#26](https://github.com/stainless-code/codemap/pull/26) | -| A | A.3 | `--group-by owner\|directory\|package` | ✅ Shipped | PR [#26](https://github.com/stainless-code/codemap/pull/26) | -| A | A.4 | `--summary` flag | ✅ Shipped | PR [#26](https://github.com/stainless-code/codemap/pull/26) | -| B | B.5 | `codemap audit` (structural-drift) | ⚠️ Partial — v1 shipped | v1 in PR [#33](https://github.com/stainless-code/codemap/pull/33). Reuses B.6 baselines instead of `--base ` worktree+reindex (deferred to v1.x — trigger: a real consumer asks). `verdict` / threshold config also deferred to v1.x — trigger: 2 consumers ship `jq`-based threshold scripts with similar shapes. Schema landed on `symbols` (not `exports`) per actual usage. | -| B | B.6 | `--save-baseline` / `--baseline` on `query` | ✅ Shipped | PR [#30](https://github.com/stainless-code/codemap/pull/30). Implemented as a `query_baselines` table inside `.codemap.db` (not parallel JSON files) — survives `--full` and SCHEMA bumps because the table is intentionally absent from `dropAll()`. | -| B | B.7 | `symbols.visibility` column | ✅ Shipped | PR [#28](https://github.com/stainless-code/codemap/pull/28). Landed on `symbols` (not `exports`) — `visibility` is a property of the symbol's docstring, not its export status. | -| B | B.8 | `--format sarif` + `--format annotations` | ✅ Shipped | PR [#43](https://github.com/stainless-code/codemap/pull/43). `codemap query --format sarif\|annotations` (also on MCP `query` / `query_recipe` tools as `format: "sarif"\|"annotations"`); `rule.id = codemap.` (`codemap.adhoc` for ad-hoc SQL); auto-detects `file_path` / `path` / `to_path` / `from_path`; aggregate recipes (`index-summary`, `markers-by-kind`) emit `results: []` + stderr warning. Per-recipe `sarifLevel` / `sarifMessage` / `sarifRuleId` overrides via frontmatter deferred to v1.x. | -| C | C.9 | Framework plugin layer | ❌ Open | Big surface; worth a `plans/.md` before any code. | -| C | C.10 | LSP server + Code Lens | ❌ Open | Independent but tangles with persistent-daemon non-goal. | -| C | C.11 | Static coverage ingestion | ❌ Open | Schema bump; one-shot ingester. | -| D | D.12-D.16 | Suppressions / per-rule severity / `fix` / suffix-array dupes / runtime intelligence | ⏸️ Skip | See § 1 Defer / skip table for the per-row reasoning. | +| Tier | # | Item | Status | Where it landed / why deferred | +| ---- | --------- | ------------------------------------------------------------------------------------ | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| A | A.1 | Per-row recipe `actions` | ✅ Shipped | PR [#26](https://github.com/stainless-code/codemap/pull/26) | +| A | A.2 | `--changed-since ` | ✅ Shipped | PR [#26](https://github.com/stainless-code/codemap/pull/26) | +| A | A.3 | `--group-by owner\|directory\|package` | ✅ Shipped | PR [#26](https://github.com/stainless-code/codemap/pull/26) | +| A | A.4 | `--summary` flag | ✅ Shipped | PR [#26](https://github.com/stainless-code/codemap/pull/26) | +| B | B.5 | `codemap audit` (structural-drift) | ⚠️ Partial — v1 + v1.x shipped; verdict deferred | v1 in PR [#33](https://github.com/stainless-code/codemap/pull/33) (`--baseline ` reusing B.6 baselines). v1.x `--base ` worktree+reindex shipped in PR [#52](https://github.com/stainless-code/codemap/pull/52) (planned PR [#51](https://github.com/stainless-code/codemap/pull/51)) — closes the per-PR structural-diff loop. `verdict` / threshold config still deferred to v1.x+ — trigger: 2 consumers ship `jq`-based threshold scripts with similar shapes. Schema landed on `symbols` (not `exports`) per actual usage. | +| B | B.6 | `--save-baseline` / `--baseline` on `query` | ✅ Shipped | PR [#30](https://github.com/stainless-code/codemap/pull/30). Implemented as a `query_baselines` table inside `.codemap.db` (not parallel JSON files) — survives `--full` and SCHEMA bumps because the table is intentionally absent from `dropAll()`. | +| B | B.7 | `symbols.visibility` column | ✅ Shipped | PR [#28](https://github.com/stainless-code/codemap/pull/28). Landed on `symbols` (not `exports`) — `visibility` is a property of the symbol's docstring, not its export status. | +| B | B.8 | `--format sarif` + `--format annotations` | ✅ Shipped | PR [#43](https://github.com/stainless-code/codemap/pull/43). `codemap query --format sarif\|annotations` (also on MCP `query` / `query_recipe` tools as `format: "sarif"\|"annotations"`); `rule.id = codemap.` (`codemap.adhoc` for ad-hoc SQL); auto-detects `file_path` / `path` / `to_path` / `from_path`; aggregate recipes (`index-summary`, `markers-by-kind`) emit `results: []` + stderr warning. Per-recipe `sarifLevel` / `sarifMessage` / `sarifRuleId` overrides via frontmatter deferred to v1.x. | +| C | C.9 | Framework plugin layer | ❌ Open | Big surface; worth a `plans/.md` before any code. | +| C | C.10 | LSP server + Code Lens | ❌ Open | Independent but tangles with persistent-daemon non-goal. | +| C | C.11 | Static coverage ingestion | ❌ Open | Schema bump; one-shot ingester. | +| D | D.12-D.16 | Suppressions / per-rule severity / `fix` / suffix-array dupes / runtime intelligence | ⏸️ Skip | See § 1 Defer / skip table for the per-row reasoning. | **Adjacent — also shipped post-refresh:** @@ -34,6 +34,7 @@ Adoption-candidate ship status. The tier tables in § 1 are preserved as the ori - **Doc-governance Rule 10** added during PR [#29](https://github.com/stainless-code/codemap/pull/29) — every core-surface change must update both `templates/agents/` (ships to npm) and `.agents/` (this clone) in lockstep. - **`cli/*` → `application/*` engine lift (internal)** — PR [#41](https://github.com/stainless-code/codemap/pull/41) closed the last layer-reversal imports `application/mcp-server.ts` had on `cli/*` (called out in the PR #35 self-audit). New engines `context-engine` / `validate-engine`; `query-recipes` moved to `application/`; envelope builders + helpers consolidated in `audit-engine` / `show-engine`. Pure refactor — no behavior or public API change — but unblocks the HTTP transport (B-tier `serve`) since that engine reuse is now clean. - **`codemap serve` HTTP API** — PR [#44](https://github.com/stainless-code/codemap/pull/44). Same tool taxonomy as `codemap mcp` over `POST /tool/{name}` for non-MCP consumers (CI scripts, simple `curl`, IDE plugins). Loopback default (`127.0.0.1:7878`); optional `--token` for Bearer auth. Bare `node:http` (no Express/Fastify dep). Tool bodies + resource fetchers live in shared `application/{tool,resource}-handlers.ts` — both transports dispatch the same pure handlers. CSRF + DNS-rebinding guard rejects `Sec-Fetch-Site: cross-site|same-site`, mismatched `Host` (loopback bind), and any `Origin` header — defends against malicious local webpages `fetch`-ing the API while the dev browses. Per-tool Zod validation at the HTTP boundary; ToolResult error arm carries `status?: 400|404|500` so unknown recipe / baseline → 404 and engine throws → 500. +- **`codemap audit --base ` (git-ref baseline)** — PR [#52](https://github.com/stainless-code/codemap/pull/52), planned in PR [#51](https://github.com/stainless-code/codemap/pull/51). Closes the highest-frequency post-watch agent loop: "what changed structurally between this branch and origin/main?". Worktree+reindex against any git committish to a sha-keyed cache under `.codemap/audit-cache/`; cache hit on second run is sub-100ms. Atomic populate via per-pid temp dir + POSIX `rename` — concurrent CI matrix runs safe by construction. `AuditBase` discriminated union — existing `{source: "baseline", ...}` rows untouched, new `{source: "ref", ref, sha, ...}` arm. Mutually exclusive with `--baseline `; per-delta `---baseline` overrides compose orthogonally. Hard error on non-git projects (no graceful fallback — there's no meaningful "ref" without git). MCP `audit` tool gains `base?: string` arg + HTTP `POST /tool/audit` lights up automatically via the existing dispatcher. - **`codemap impact` (blast-radius walker)** — PR [#50](https://github.com/stainless-code/codemap/pull/50), planned in PR [#49](https://github.com/stainless-code/codemap/pull/49). Replaces the "agent composes `WITH RECURSIVE` by hand" tax — single verb walks the calls / dependencies / imports graphs (callers, callees, dependents, dependencies), depth- and limit-bounded, cycle-detected. Same pure-engine pattern as `show` / `snippet`: `application/impact-engine.ts` reused by CLI / MCP `impact` / HTTP `POST /tool/impact` via the existing `tool-handlers.ts` dispatcher. Symbol vs file targets walk compatible backends automatically; mismatched explicit `--via` choices land in `skipped_backends`. Output envelope `{target, matches, summary: {nodes, terminated_by}}` — `--summary` trims `matches` for cheap CI-gate consumption (`jq '.summary.nodes'`). - **`codemap watch` (live reindex)** — PR [#47](https://github.com/stainless-code/codemap/pull/47), planned in PR [#46](https://github.com/stainless-code/codemap/pull/46). The biggest agent-UX win in the roadmap: eliminates the "is the index stale?" friction every CLI / MCP / HTTP query rides on today. Three shapes: standalone `codemap watch`, plus killer combos `codemap mcp --watch` and `codemap serve --watch` (also `CODEMAP_WATCH=1`). Chokidar v5 backend (selected via 6-watcher audit on PR #46 — pure JS, no Bun N-API quirks, identical on Bun + Node). Sliding-window debouncer (default 250 ms) + path-segment exclude scan + project-local recipe glob. Optional `onPrime` opt runs an incremental catch-up BEFORE flipping `isWatchActive()` true so `handleAudit` only skips its prelude when the index is genuinely fresh. Stop drains in-flight reindex (serialized via inFlight chain) before close so SIGINT/SIGTERM never leaves a half-written DB. Backend errors clear the active flag so a dying chokidar re-enables the audit prelude immediately. diff --git a/docs/roadmap.md b/docs/roadmap.md index ec27bed7..d6ebfd19 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -35,7 +35,6 @@ Codemap stays a structural-index primitive that other tools can consume. Out of ## Backlog -- [ ] **`codemap audit --base `** (v1.x) — worktree+reindex snapshot strategy. v1 shipped `--baseline ` / `---baseline ` (B.6 reuse) — see [`architecture.md` § Audit wiring](./architecture.md#cli-usage). v1.x adds `--base ` for "audit against an arbitrary ref I haven't pre-baselined." Plan: [`plans/audit-base.md`](./plans/audit-base.md). - [ ] **`codemap audit` verdict + thresholds** (v1.x) — `verdict: "pass" | "warn" | "fail"` driven by `codemap.config.audit.deltas[].{added_max, action}`. Triggers: two consumers ship `jq`-based threshold scripts with similar shapes, OR one consumer asks with a concrete config sketch. Until then, raw deltas + consumer-side `jq` is the CI exit-code idiom. - [ ] **Monorepo / workspace awareness** — discover workspaces from `pnpm-workspace.yaml` / `package.json` and index per-workspace dependency graphs - [ ] **Cross-agent handoff artifact** — _speculative_; layered prefix/delta JSON written on session-stop, read on session-start. Complementary to indexing rather than core to it; revisit if user demand emerges diff --git a/src/agents-init.test.ts b/src/agents-init.test.ts index 0feca048..8411f248 100644 --- a/src/agents-init.test.ts +++ b/src/agents-init.test.ts @@ -134,11 +134,11 @@ describe("runAgentsInit", () => { mkdirSync(join(dir, ".git"), { recursive: true }); ensureGitignoreCodemapPattern(dir); expect(readFileSync(join(dir, ".gitignore"), "utf-8")).toBe( - ".codemap.*\n", + ".codemap.*\n.codemap/audit-cache/\n", ); ensureGitignoreCodemapPattern(dir); expect(readFileSync(join(dir, ".gitignore"), "utf-8")).toBe( - ".codemap.*\n", + ".codemap.*\n.codemap/audit-cache/\n", ); } finally { rmSync(dir, { recursive: true, force: true }); @@ -165,7 +165,7 @@ describe("runAgentsInit", () => { mkdirSync(join(dir, ".git"), { recursive: true }); expect(runAgentsInit({ projectRoot: dir, force: true })).toBe(true); expect(readFileSync(join(dir, ".gitignore"), "utf-8")).toBe( - ".codemap.*\n", + ".codemap.*\n.codemap/audit-cache/\n", ); } finally { rmSync(dir, { recursive: true, force: true }); diff --git a/src/agents-init.ts b/src/agents-init.ts index e366edc2..95487300 100644 --- a/src/agents-init.ts +++ b/src/agents-init.ts @@ -123,6 +123,9 @@ function removeBundledPathsIfExist(destBase: string, relPaths: string[]): void { /** Default DB basename `.codemap` plus SQLite sidecars (`.db`, `-wal`, `-shm`, …). */ const GITIGNORE_CODEMAP_PATTERN = ".codemap.*"; +// `.codemap/audit-cache/` ignored separately because `.codemap.*` doesn't +// match the directory shape AND `.codemap/recipes/` is git-tracked. +const GITIGNORE_AUDIT_CACHE_PATTERN = ".codemap/audit-cache/"; /** * Optional integrations after canonical `.agents/` is written. @@ -288,9 +291,14 @@ export interface AgentsInitOptions { } /** - * Ensure `.codemap.*` is listed in `.gitignore` when the project uses Git: - * - If `/.git` exists and there is no `.gitignore`, create one with `.codemap.*`. - * - If `.gitignore` exists, append `.codemap.*` once when missing. + * Ensure codemap-managed paths are listed in `.gitignore` when the project + * uses Git. Adds `.codemap.*` (matches `.codemap.db` etc.) AND + * `.codemap/audit-cache/` (the audit-base worktree cache; `.codemap/recipes/` + * stays tracked, so we can't ignore the whole `.codemap/` dir). + * + * - If `/.git` exists and there is no `.gitignore`, create one + * with both patterns. + * - If `.gitignore` exists, append each pattern once when missing. * - If there is no `.git`, do nothing (not a Git working tree). */ export function ensureGitignoreCodemapPattern(projectRoot: string): void { @@ -299,25 +307,27 @@ export function ensureGitignoreCodemapPattern(projectRoot: string): void { if (!existsSync(gitDir)) { return; } + const patterns = [GITIGNORE_CODEMAP_PATTERN, GITIGNORE_AUDIT_CACHE_PATTERN]; if (!existsSync(gitignorePath)) { - writeFileSync(gitignorePath, `${GITIGNORE_CODEMAP_PATTERN}\n`, "utf-8"); + writeFileSync(gitignorePath, `${patterns.join("\n")}\n`, "utf-8"); console.log( - ` Created .gitignore with ${GITIGNORE_CODEMAP_PATTERN} (Git repo, no .gitignore yet)`, + ` Created .gitignore with ${patterns.join(" + ")} (Git repo, no .gitignore yet)`, ); return; } const content = readFileSync(gitignorePath, "utf-8"); const lines = content.split(/\r?\n/); - if (lines.some((line) => line.trim() === GITIGNORE_CODEMAP_PATTERN)) { - return; - } + const missing = patterns.filter( + (p) => !lines.some((line) => line.trim() === p), + ); + if (missing.length === 0) return; const needsLeadingNewline = content.length > 0 && !content.endsWith("\n"); appendFileSync( gitignorePath, - `${needsLeadingNewline ? "\n" : ""}${GITIGNORE_CODEMAP_PATTERN}\n`, + `${needsLeadingNewline ? "\n" : ""}${missing.join("\n")}\n`, "utf-8", ); - console.log(` Appended ${GITIGNORE_CODEMAP_PATTERN} to .gitignore`); + console.log(` Appended ${missing.join(" + ")} to .gitignore`); } function removePathForRewrite( diff --git a/src/application/audit-engine.test.ts b/src/application/audit-engine.test.ts index 9cf1f7ee..6f6c05ee 100644 --- a/src/application/audit-engine.test.ts +++ b/src/application/audit-engine.test.ts @@ -133,10 +133,15 @@ describe("runAudit (engine)", () => { }); if ("error" in r1 || "error" in r2) throw new Error("unexpected error"); - expect(r1.deltas.files!.base.name).toBe("files-snap-yesterday"); - expect(r1.deltas.files!.base.sha).toBe("yesterday-sha"); - expect(r2.deltas.files!.base.name).toBe("files-snap-today"); - expect(r2.deltas.files!.base.sha).toBe("today-sha"); + const r1Base = r1.deltas.files!.base; + const r2Base = r2.deltas.files!.base; + if (r1Base.source !== "baseline" || r2Base.source !== "baseline") { + throw new Error("expected baseline-source bases"); + } + expect(r1Base.name).toBe("files-snap-yesterday"); + expect(r1Base.sha).toBe("yesterday-sha"); + expect(r2Base.name).toBe("files-snap-today"); + expect(r2Base.sha).toBe("today-sha"); } finally { db.close(); } diff --git a/src/application/audit-engine.ts b/src/application/audit-engine.ts index 23bdcc24..fe444c39 100644 --- a/src/application/audit-engine.ts +++ b/src/application/audit-engine.ts @@ -1,7 +1,24 @@ +import { loadUserConfig, resolveCodemapConfig } from "../config"; import { getQueryBaseline } from "../db"; import type { CodemapDatabase } from "../db"; import { diffRows } from "../diff-rows"; +import { configureResolver } from "../resolver"; +import { + getCodemapConfig, + getProjectRoot, + getTsconfigPath, + initCodemap, +} from "../runtime"; +import { openCodemapDatabase } from "../sqlite-db"; +import { + isGitRepo, + lookupCacheEntry, + populateWorktree, + resolveSha, +} from "./audit-worktree"; +import type { PopulatedCacheEntry } from "./audit-worktree"; import { getCurrentCommit } from "./index-engine"; +import { runCodemapIndex } from "./run-index"; /** * Per-delta diff payload — the rows that drifted between baseline and current, @@ -18,18 +35,30 @@ export interface AuditDelta { } /** - * Per-delta snapshot metadata. v1 always has `source: "baseline"` (B.6 reuse); - * v1.x adds `source: "ref"` for the worktree+reindex path. Each delta carries - * its own `base` because audits can mix baselines (e.g. `--files-baseline X - * --dependencies-baseline Y`). + * Per-delta snapshot metadata — discriminated by `source`. `"baseline"` (B.6 + * reuse) loads rows from the `query_baselines` table; `"ref"` materialises the + * snapshot via worktree + reindex (`--base `). Per-delta because audits + * can mix sources (e.g. `--base origin/main --files-baseline pr-files`). */ -export interface AuditBase { +export type AuditBase = AuditBaseFromBaseline | AuditBaseFromRef; + +export interface AuditBaseFromBaseline { source: "baseline"; name: string; sha: string | null; indexed_at: number; } +export interface AuditBaseFromRef { + source: "ref"; + /** User-supplied ref string (e.g. `origin/main`, `HEAD~5`, `v1.0.0`). */ + ref: string; + /** Resolved sha — what `git rev-parse --verify` returned. */ + sha: string; + /** When the worktree-side `.codemap.db` was last indexed (cache-mtime). */ + indexed_at: number; +} + /** * Current-state metadata at audit time. `indexed_at` reflects the live * `.codemap.db`'s last index run — `cmd-audit.ts` runs an incremental @@ -288,3 +317,197 @@ function tryGetGitRef(): string | null { return null; } } + +/** + * Reindex callback contract — `runAuditFromRef` injects this. The default + * production implementation (`makeWorktreeReindex`) re-inits the runtime + * singletons against the worktree path then calls `runCodemapIndex`; tests + * inject a stub via this same hook. + */ +export type ReindexFn = (worktreePath: string) => Promise; + +/** + * Process-level serialiser for the runtime-singleton swap inside + * `makeWorktreeReindex`. `initCodemap` / `configureResolver` mutate global + * state (`getProjectRoot`, the resolver instance). Two HTTP / MCP audits + * starting in parallel would otherwise interleave save/swap/restore and + * route one request's index work at the other's project root. The mutex + * guarantees one reindex critical section at a time per process. + * + * Long-term cleanup tracked in `docs/roadmap.md` § Backlog (`runCodemapIndex` + * accepts an explicit context — would let us drop the mutex). Cost today + * is small: only `--base` audits pay the lock; cache hits skip it. + */ +let _reindexChain: Promise = Promise.resolve(); + +/** + * Standard production reindex callback for `runAuditFromRef`. Save → swap + * runtime singletons against the worktree → run `runCodemapIndex` → restore. + * Both `cmd-audit.ts` and `application/tool-handlers.ts` (MCP / HTTP) call + * this — single source of truth. Critical section is serialised + * process-wide via `_reindexChain` so concurrent audits don't interleave. + */ +export function makeWorktreeReindex(): ReindexFn { + return (worktreePath: string) => { + const next = _reindexChain.then(async () => { + const wtDbPath = `${worktreePath}/.codemap.db`; + const wtDb = openCodemapDatabase(wtDbPath); + const savedConfig = getCodemapConfig(); + try { + const wtUser = await loadUserConfig(worktreePath, undefined); + initCodemap(resolveCodemapConfig(worktreePath, wtUser)); + configureResolver(getProjectRoot(), getTsconfigPath()); + await runCodemapIndex(wtDb, { mode: "full", quiet: true }); + } finally { + wtDb.close(); + initCodemap(savedConfig); + configureResolver(getProjectRoot(), getTsconfigPath()); + } + }); + // Catch on the chain itself so one failed reindex doesn't poison the + // serializer; surface the error to THIS caller via `next`. + _reindexChain = next.catch(() => {}); + return next; + }; +} + +export interface RunAuditFromRefOpts { + db: CodemapDatabase; + ref: string; + /** + * Per-delta override map. When a delta key is present here, the delta + * uses the saved baseline (`source: "baseline"`) instead of the worktree + * (`source: "ref"`). Composes orthogonally with `--base` per plan §D7. + */ + perDeltaOverrides?: AuditBaselineMap; + projectRoot: string; + reindex: ReindexFn; +} + +/** + * Run an audit with the base snapshot materialised from a git ref. + * Resolves `` to a sha, reuses (or populates) the worktree cache, + * runs each delta's canonical SQL on the cached `.codemap.db`, and diffs + * against the live DB. Per-delta overrides escape to the existing + * `query_baselines`-backed path. + * + * Mirrors {@link runAudit} but the "base rows" come from a sibling SQLite + * file instead of `query_baselines`. Errors map to `AuditError` for the + * same `{error}` shape the CLI / MCP / HTTP transports already render. + */ +export async function runAuditFromRef( + opts: RunAuditFromRefOpts, +): Promise { + if (!isGitRepo(opts.projectRoot)) { + return { error: "codemap audit: --base requires a git repository." }; + } + + const resolved = resolveSha(opts.ref, opts.projectRoot); + if ("error" in resolved) return { error: resolved.error }; + const sha = resolved.sha; + + let entry: PopulatedCacheEntry | undefined = lookupCacheEntry(sha, { + projectRoot: opts.projectRoot, + }); + if (entry === undefined) { + const populated = await populateWorktree({ + projectRoot: opts.projectRoot, + sha, + reindex: opts.reindex, + }); + if ("error" in populated) return { error: populated.error }; + entry = populated; + } + + const baseDb = openCodemapDatabase(entry.dbPath); + try { + const deltas: Record = {}; + const overrides = opts.perDeltaOverrides ?? {}; + for (const spec of V1_DELTAS) { + const overrideName = overrides[spec.key]; + if (overrideName !== undefined) { + const baselineDelta = computeDeltaFromBaseline( + opts.db, + overrideName, + spec, + ); + if ("error" in baselineDelta) return baselineDelta; + deltas[spec.key] = baselineDelta; + continue; + } + + const baseRows = baseDb.query(spec.sql).all() as unknown[]; + const projectedBase = baseRows.map((row) => + projectRow(row, spec.requiredColumns), + ); + const headRows = opts.db.query(spec.sql).all() as unknown[]; + const projectedHead = headRows.map((row) => + projectRow(row, spec.requiredColumns), + ); + const diff = diffRows(projectedBase, projectedHead); + + deltas[spec.key] = { + base: { + source: "ref", + ref: opts.ref, + sha, + indexed_at: entry.indexedAt, + }, + ...diff, + }; + } + + return { + head: { + sha: tryGetGitRef(), + indexed_at: Date.now(), + }, + deltas, + }; + } finally { + baseDb.close(); + } +} + +/** + * Replays the existing baseline-side flow for one delta — used by + * `runAuditFromRef` when the user passes `--base ---baseline X` + * to override one delta with a saved baseline (per plan §D7). + */ +function computeDeltaFromBaseline( + db: CodemapDatabase, + baselineName: string, + spec: AuditDeltaSpec, +): AuditDelta | AuditError { + const baseline = getQueryBaseline(db, baselineName); + if (baseline === undefined) { + return { + error: `codemap audit: no baseline named "${baselineName}" (requested for delta "${spec.key}"). Use \`codemap query --baselines\` to list saved baselines.`, + }; + } + let baselineRows: unknown[]; + try { + const parsed = JSON.parse(baseline.rows_json) as unknown; + if (!Array.isArray(parsed)) { + return { + error: `codemap audit: baseline "${baseline.name}" (delta "${spec.key}") has invalid rows_json — drop and re-save.`, + }; + } + baselineRows = parsed; + } catch { + return { + error: `codemap audit: baseline "${baseline.name}" (delta "${spec.key}") has corrupt rows_json — drop and re-save.`, + }; + } + const diff = computeDelta(db, baseline.name, baselineRows, spec); + if ("error" in diff) return diff; + return { + base: { + source: "baseline", + name: baseline.name, + sha: baseline.git_ref, + indexed_at: baseline.created_at, + }, + ...diff, + }; +} diff --git a/src/application/audit-worktree.test.ts b/src/application/audit-worktree.test.ts new file mode 100644 index 00000000..faea011e --- /dev/null +++ b/src/application/audit-worktree.test.ts @@ -0,0 +1,376 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { createTables } from "../db"; +import type { CodemapDatabase } from "../db"; +import { openCodemapDatabase } from "../sqlite-db"; +import { runAuditFromRef } from "./audit-engine"; +import { + _wipeCacheForTests, + isGitRepo, + lookupCacheEntry, + populateWorktree, + resolveSha, +} from "./audit-worktree"; + +// Production `audit-worktree.ts` already strips GIT_* env vars on its +// own git spawns; the fixture-side helpers below mirror that for the +// `git init` / `git commit` calls used to set up the repo. +let projectRoot: string; +let baseSha: string; +let headSha: string; + +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[]): void { + const r = spawnSync("git", args, { + cwd: projectRoot, + env: fixtureEnv(), + }); + if (r.status !== 0) { + throw new Error( + `git ${args.join(" ")} failed: ${r.stderr.toString().trim()}`, + ); + } +} + +function commitFiles(message: string, files: Record): string { + for (const [rel, content] of Object.entries(files)) { + const abs = join(projectRoot, rel); + mkdirSync(join(abs, ".."), { recursive: true }); + writeFileSync(abs, content); + } + git(["add", "."]); + const commit = spawnSync("git", ["commit", "-m", message, "--no-gpg-sign"], { + cwd: projectRoot, + env: fixtureEnv(), + }); + if (commit.status !== 0) { + throw new Error(`git commit failed: ${commit.stderr.toString().trim()}`); + } + const sha = spawnSync("git", ["rev-parse", "HEAD"], { + cwd: projectRoot, + env: fixtureEnv(), + }) + .stdout.toString() + .trim(); + return sha; +} + +beforeEach(() => { + projectRoot = mkdtempSync(join(tmpdir(), "audit-base-")); + git(["init", "-q", "-b", "main"]); + git(["config", "user.email", "test@example.com"]); + git(["config", "user.name", "Test"]); + git(["config", "commit.gpgsign", "false"]); + + // Base commit — only a.ts. + baseSha = commitFiles("base", { + "src/a.ts": "export const a = 1;\n", + }); + // Head commit — adds b.ts, removes a.ts. + rmSync(join(projectRoot, "src", "a.ts")); + headSha = commitFiles("head", { + "src/b.ts": "export const b = 2;\n", + }); +}); + +afterEach(() => { + rmSync(projectRoot, { recursive: true, force: true }); +}); + +describe("isGitRepo", () => { + it("returns true for a git-initialised dir", () => { + expect(isGitRepo(projectRoot)).toBe(true); + }); + it("returns false for a non-git dir", () => { + const plain = mkdtempSync(join(tmpdir(), "non-git-")); + try { + expect(isGitRepo(plain)).toBe(false); + } finally { + rmSync(plain, { recursive: true, force: true }); + } + }); +}); + +describe("resolveSha", () => { + it("resolves a branch ref to its tip sha", () => { + const r = resolveSha("HEAD", projectRoot); + expect(r).toEqual({ sha: headSha }); + }); + + it("resolves HEAD~1 to the base sha", () => { + const r = resolveSha("HEAD~1", projectRoot); + expect(r).toEqual({ sha: baseSha }); + }); + + it("returns ref-unresolved for bogus refs", () => { + const r = resolveSha("definitely-not-a-real-ref", projectRoot); + expect(r).toMatchObject({ code: "ref-unresolved" }); + }); + + it("returns not-git-repo for non-git dirs", () => { + const plain = mkdtempSync(join(tmpdir(), "non-git-")); + try { + const r = resolveSha("HEAD", plain); + expect(r).toMatchObject({ code: "not-git-repo" }); + } finally { + rmSync(plain, { recursive: true, force: true }); + } + }); +}); + +describe("populateWorktree + lookupCacheEntry", () => { + it("populates a fresh cache entry then hits on lookup", async () => { + const populated = await populateWorktree({ + projectRoot, + sha: baseSha, + reindex: async (worktreePath) => { + // Stand-in for the real reindex — just create an empty .codemap.db. + const db = openCodemapDatabase(join(worktreePath, ".codemap.db")); + createTables(db); + db.close(); + }, + }); + expect(populated).toMatchObject({ sha: baseSha }); + expect(existsSync((populated as { dbPath: string }).dbPath)).toBe(true); + + const hit = lookupCacheEntry(baseSha, { projectRoot }); + expect(hit).toMatchObject({ sha: baseSha }); + expect(hit?.dbPath).toBe((populated as { dbPath: string }).dbPath); + }); + + it("cache hit short-circuits — second populate would reindex but lookup returns first", async () => { + let reindexCalls = 0; + const reindex = async (wp: string) => { + reindexCalls += 1; + const db = openCodemapDatabase(join(wp, ".codemap.db")); + createTables(db); + db.close(); + }; + await populateWorktree({ projectRoot, sha: baseSha, reindex }); + expect(reindexCalls).toBe(1); + + // Caller checks cache before populating — the engine path does this. + const hit = lookupCacheEntry(baseSha, { projectRoot }); + expect(hit).not.toBeUndefined(); + // Confirm we never called reindex again because the caller skipped populate. + expect(reindexCalls).toBe(1); + }); + + it("cleans up temp dir on reindex failure (no .tmp.* leftover)", async () => { + const populated = await populateWorktree({ + projectRoot, + sha: baseSha, + reindex: async () => { + throw new Error("simulated reindex failure"); + }, + }); + expect(populated).toMatchObject({ code: "reindex-failed" }); + + const cacheRoot = join(projectRoot, ".codemap/audit-cache"); + if (existsSync(cacheRoot)) { + const { readdirSync } = await import("node:fs"); + const entries = readdirSync(cacheRoot); + const tmps = entries.filter((e) => e.startsWith(".tmp.")); + expect(tmps).toEqual([]); + } + }); + + it("eviction does not delete the freshly-populated entry (protectPath)", async () => { + // Single-huge-entry case: even if the new entry alone would breach + // MAX_CACHE_BYTES, populateWorktree's protectPath guard keeps it. + // We don't actually need >500 MiB to exercise the path — a passing + // populate followed by a successful lookupCacheEntry is sufficient. + const populated = await populateWorktree({ + projectRoot, + sha: baseSha, + reindex: async (worktreePath) => { + const db = openCodemapDatabase(join(worktreePath, ".codemap.db")); + createTables(db); + db.close(); + }, + }); + expect(populated).toMatchObject({ sha: baseSha }); + expect(lookupCacheEntry(baseSha, { projectRoot })).toMatchObject({ + sha: baseSha, + }); + }); + + it("returns ref-unresolved-shaped error for bogus shas (worktree add fails)", async () => { + const r = await populateWorktree({ + projectRoot, + sha: "0000000000000000000000000000000000000000", + reindex: async () => { + // never called + }, + }); + expect(r).toMatchObject({ code: "worktree-add-failed" }); + }); +}); + +describe("runAuditFromRef — end-to-end against a fixture repo", () => { + /** + * Reindex stub that actually runs the canonical SQL projection by creating + * a `.codemap.db` with the worktree's files seeded into the `files` table. + * Stand-in for the real `runCodemapIndex` — Tracer 2 wires the real one. + */ + async function fakeReindex(worktreePath: string): Promise { + const dbPath = join(worktreePath, ".codemap.db"); + const db = openCodemapDatabase(dbPath); + try { + createTables(db); + // Walk worktree's src/ and insert each .ts file into `files`. + const { readdirSync } = await import("node:fs"); + const srcDir = join(worktreePath, "src"); + if (existsSync(srcDir)) { + for (const f of readdirSync(srcDir)) { + if (f.endsWith(".ts")) { + db.run( + `INSERT INTO files (path, content_hash, size, line_count, language, last_modified, indexed_at) + VALUES (?, 'h', 0, 1, 'ts', 0, 0)`, + [`src/${f}`], + ); + } + } + } + } finally { + db.close(); + } + } + + let liveDb: CodemapDatabase | undefined; + + beforeEach(() => { + // The "head" live DB has b.ts indexed. + liveDb = openCodemapDatabase(":memory:"); + createTables(liveDb); + db().run( + `INSERT INTO files (path, content_hash, size, line_count, language, last_modified, indexed_at) + VALUES ('src/b.ts', 'h', 0, 1, 'ts', 0, 0)`, + ); + }); + + afterEach(() => { + liveDb?.close(); + liveDb = undefined; + _wipeCacheForTests(projectRoot); + }); + + function db(): CodemapDatabase { + if (!liveDb) throw new Error("liveDb not initialized"); + return liveDb; + } + + it("returns the full envelope with base.source: 'ref' for each delta", async () => { + const env = await runAuditFromRef({ + db: db(), + ref: "HEAD~1", + projectRoot, + reindex: fakeReindex, + }); + expect("error" in env).toBe(false); + if ("error" in env) return; + expect(env.deltas.files).toBeDefined(); + expect(env.deltas.files!.base).toMatchObject({ + source: "ref", + ref: "HEAD~1", + sha: baseSha, + }); + // a.ts in base, b.ts in head → added: b.ts, removed: a.ts. + expect(env.deltas.files!.added).toEqual([{ path: "src/b.ts" }]); + expect(env.deltas.files!.removed).toEqual([{ path: "src/a.ts" }]); + }); + + it("non-git project errors cleanly", async () => { + const plain = mkdtempSync(join(tmpdir(), "non-git-")); + try { + const env = await runAuditFromRef({ + db: db(), + ref: "HEAD~1", + projectRoot: plain, + reindex: fakeReindex, + }); + expect(env).toMatchObject({ + error: "codemap audit: --base requires a git repository.", + }); + } finally { + rmSync(plain, { recursive: true, force: true }); + } + }); + + it("bogus ref errors cleanly", async () => { + const env = await runAuditFromRef({ + db: db(), + ref: "no-such-ref-xyz", + projectRoot, + reindex: fakeReindex, + }); + expect(env).toMatchObject({ + error: expect.stringContaining(`cannot resolve "no-such-ref-xyz"`), + }); + }); + + it("second run hits the cache (reindex called once total)", async () => { + let reindexCalls = 0; + const countingReindex = async (wp: string) => { + reindexCalls += 1; + await fakeReindex(wp); + }; + await runAuditFromRef({ + db: db(), + ref: "HEAD~1", + projectRoot, + reindex: countingReindex, + }); + await runAuditFromRef({ + db: db(), + ref: "HEAD~1", + projectRoot, + reindex: countingReindex, + }); + expect(reindexCalls).toBe(1); + }); + + it("per-delta override uses query_baselines for that delta only", async () => { + // Save a baseline that pretends the 'files' set was empty at audit time. + db().run( + `INSERT INTO query_baselines (name, recipe_id, sql, rows_json, row_count, git_ref, created_at) + VALUES ('pr-files', NULL, 'SELECT path FROM files', '[]', 0, 'abc', 1700000000000)`, + ); + const env = await runAuditFromRef({ + db: db(), + ref: "HEAD~1", + projectRoot, + perDeltaOverrides: { files: "pr-files" }, + reindex: fakeReindex, + }); + expect("error" in env).toBe(false); + if ("error" in env) return; + expect(env.deltas.files!.base).toMatchObject({ + source: "baseline", + name: "pr-files", + }); + // dependencies + deprecated still resolve via the worktree (source: ref). + expect(env.deltas.dependencies?.base.source).toBe("ref"); + expect(env.deltas.deprecated?.base.source).toBe("ref"); + }); +}); diff --git a/src/application/audit-worktree.ts b/src/application/audit-worktree.ts new file mode 100644 index 00000000..897d3150 --- /dev/null +++ b/src/application/audit-worktree.ts @@ -0,0 +1,340 @@ +import { spawnSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { + existsSync, + mkdirSync, + readdirSync, + renameSync, + rmSync, + statSync, +} from "node:fs"; +import { join } from "node:path"; + +/** + * Strip GIT_* env vars before spawning a fixture git command. Inherited + * `GIT_INDEX_FILE` / `GIT_DIR` (set by an outer git operation, e.g. when + * codemap runs from inside a husky pre-commit hook) would otherwise route + * spawned git calls at the WRONG repo's index. The audit worktree path + * always resolves itself via `cwd`; honoring inherited git env would + * actively break it. + */ +function gitSpawnEnv(): NodeJS.ProcessEnv { + const e: NodeJS.ProcessEnv = {}; + for (const [k, v] of Object.entries(process.env)) { + if (k.startsWith("GIT_")) continue; + e[k] = v; + } + return e; +} + +/** + * Sha-keyed worktree cache for `audit --base `. + * + * Each cache entry is a populated `git worktree` at `/.codemap/audit-cache//` + * containing the materialised tree at that commit AND a temp `.codemap.db` + * indexed against it. Cache-hit detection is "does `/.codemap.db` exist?" + * — atomic populate (D11) guarantees the DB only appears after a successful + * reindex, so a cache hit never observes a half-written entry. + * + * **Concurrency.** Two parallel `audit --base ` invocations resolving to + * the same sha race-safely: each writes to a per-pid temp dir, then POSIX + * `rename` claims the final `/` slot. Whichever rename loses gets EEXIST + * on most platforms — we treat that as "the winner already populated, fall + * through to cache-hit." No lock files needed. + * + * **Eviction.** LRU after 5 entries OR 500 MiB (D2). Computed by directory + * mtime; `git worktree remove --force` then `rm -rf` to clean up. + */ + +const CACHE_DIR_NAME = ".codemap/audit-cache"; +const MAX_CACHE_ENTRIES = 5; +const MAX_CACHE_BYTES = 500 * 1024 * 1024; + +export interface WorktreeCacheOpts { + projectRoot: string; +} + +export interface PopulatedCacheEntry { + /** Absolute path to the cached worktree dir. */ + worktreePath: string; + /** Absolute path to the `.codemap.db` inside that worktree. */ + dbPath: string; + /** Resolved sha this entry was created against. */ + sha: string; + /** Cache-mtime in epoch-ms — surfaces as `AuditBase.indexed_at`. */ + indexedAt: number; +} + +export type WorktreeError = + | { error: string; code: "not-git-repo" } + | { error: string; code: "ref-unresolved" } + | { error: string; code: "worktree-add-failed" } + | { error: string; code: "reindex-failed" }; + +/** + * Resolve `` to a sha via `git rev-parse --verify "^{commit}"`. + * Returns `{error, code}` on failure (no git, ref not found, etc.). + */ +export function resolveSha( + ref: string, + projectRoot: string, +): { sha: string } | WorktreeError { + if (!isGitRepo(projectRoot)) { + return { + code: "not-git-repo", + error: "codemap audit: --base requires a git repository.", + }; + } + const out = spawnSync("git", ["rev-parse", "--verify", `${ref}^{commit}`], { + cwd: projectRoot, + env: gitSpawnEnv(), + }); + if (out.status !== 0) { + const stderr = out.stderr.toString().trim(); + return { + code: "ref-unresolved", + error: `codemap audit: --base: cannot resolve "${ref}" to a commit${ + stderr ? ` (${stderr})` : "" + }.`, + }; + } + return { sha: out.stdout.toString().trim() }; +} + +export function isGitRepo(projectRoot: string): boolean { + return existsSync(join(projectRoot, ".git")); +} + +/** + * Cache-hit fast path. Returns the entry when `/.codemap.db` exists. + * Caller falls back to {@link populateWorktree} on a miss. + */ +export function lookupCacheEntry( + sha: string, + opts: WorktreeCacheOpts, +): PopulatedCacheEntry | undefined { + const worktreePath = join(opts.projectRoot, CACHE_DIR_NAME, sha); + const dbPath = join(worktreePath, ".codemap.db"); + if (!existsSync(dbPath)) return undefined; + return { + worktreePath, + dbPath, + sha, + indexedAt: statSync(dbPath).mtimeMs, + }; +} + +export interface PopulateOpts extends WorktreeCacheOpts { + sha: string; + /** Reindex callback — receives the worktree path, must build `.codemap.db` inside it. */ + reindex: (worktreePath: string) => Promise; +} + +/** + * Populate a cache entry atomically (D11): + * 1. mkdir per-pid temp dir under the cache root + * 2. `git worktree add ` + * 3. caller's `reindex()` builds `.codemap.db` + * 4. `rename(, )` — POSIX-atomic; if the final slot already exists + * (raced with a concurrent populate), discard the temp and use the winner. + * + * On failure mid-populate, the temp dir + worktree are removed in a `finally` + * so `.codemap/audit-cache/.tmp.*` never accumulates. + */ +export async function populateWorktree( + opts: PopulateOpts, +): Promise { + const cacheRoot = join(opts.projectRoot, CACHE_DIR_NAME); + mkdirSync(cacheRoot, { recursive: true }); + + // randomUUID() suffix on top of (sha, pid, ms) — defensive against + // cross-process races where two `codemap audit --base` invocations share + // the same sha and start within the same millisecond. Mutex (audit-engine) + // already serialises in-process; this catches the multi-process case. + const tmpName = `.tmp.${opts.sha}.${process.pid}.${Date.now()}.${randomUUID()}`; + const tmpPath = join(cacheRoot, tmpName); + const finalPath = join(cacheRoot, opts.sha); + + let cleanup = true; + try { + const add = spawnSync( + "git", + ["worktree", "add", "--detach", tmpPath, opts.sha], + { cwd: opts.projectRoot, env: gitSpawnEnv() }, + ); + if (add.status !== 0) { + const stderr = add.stderr.toString().trim(); + return { + code: "worktree-add-failed", + error: `codemap audit: git worktree add failed for sha ${opts.sha}${ + stderr ? ` (${stderr})` : "" + }.`, + }; + } + + try { + await opts.reindex(tmpPath); + } catch (err) { + return { + code: "reindex-failed", + error: `codemap audit: reindex failed on worktree (${ + err instanceof Error ? err.message : String(err) + }).`, + }; + } + + try { + renameSync(tmpPath, finalPath); + cleanup = false; + } catch { + // Lost the race — the final slot already exists. Trust the winner's + // entry and fall through to cache-hit. The temp dir is removed in + // the `finally` below. + const winner = lookupCacheEntry(opts.sha, opts); + if (winner !== undefined) return winner; + // Unexpected: rename failed AND no winner present. Surface a clean + // error rather than poisoning subsequent runs. + return { + code: "worktree-add-failed", + error: `codemap audit: cache rename failed for sha ${opts.sha} and no existing entry found.`, + }; + } + } finally { + if (cleanup && existsSync(tmpPath)) { + removeWorktree(tmpPath, opts.projectRoot); + } + } + + // Pass `protectPath` so the freshly-populated entry can't be its own victim + // (single huge entry > MAX_CACHE_BYTES would otherwise oscillate between + // re-populate and return-dead-path). + evictIfOverLimits(opts.projectRoot, finalPath); + + return { + worktreePath: finalPath, + dbPath: join(finalPath, ".codemap.db"), + sha: opts.sha, + indexedAt: Date.now(), + }; +} + +/** + * `git worktree remove --force ` followed by `rm -rf` for safety. + * Used by both rollback (failed populate) and eviction. Errors are + * swallowed — best-effort cleanup; the next eviction cycle sweeps stragglers. + */ +function removeWorktree(worktreePath: string, projectRoot: string): void { + spawnSync("git", ["worktree", "remove", "--force", worktreePath], { + cwd: projectRoot, + env: gitSpawnEnv(), + }); + if (existsSync(worktreePath)) { + try { + rmSync(worktreePath, { recursive: true, force: true }); + } catch { + // Best-effort — leave for the next sweep. + } + } +} + +interface CacheEntryInfo { + sha: string; + path: string; + mtimeMs: number; + sizeBytes: number; +} + +/** + * LRU sweep — runs after every successful populate. Removes oldest entries + * until under both ENTRY and BYTE budgets. `.tmp.*` dirs older than a few + * minutes are also swept (orphans from crashed populates). `protectPath` + * is excluded from both counts and eviction — callers pass the freshly- + * populated entry so it can't evict itself when a single entry exceeds + * `MAX_CACHE_BYTES`. + */ +function evictIfOverLimits(projectRoot: string, protectPath?: string): void { + const cacheRoot = join(projectRoot, CACHE_DIR_NAME); + if (!existsSync(cacheRoot)) return; + + const now = Date.now(); + const entries: CacheEntryInfo[] = []; + for (const name of readdirSync(cacheRoot)) { + const path = join(cacheRoot, name); + if (path === protectPath) continue; + let stat; + try { + stat = statSync(path); + } catch { + continue; + } + if (!stat.isDirectory()) continue; + if (name.startsWith(".tmp.")) { + // Sweep orphan temp dirs older than 10 min — must be from crashed runs + // because successful populates rename the dir away within seconds. + if (now - stat.mtimeMs > 10 * 60 * 1000) { + removeWorktree(path, projectRoot); + } + continue; + } + entries.push({ + sha: name, + path, + mtimeMs: stat.mtimeMs, + sizeBytes: dirSizeBytes(path), + }); + } + + entries.sort((a, b) => b.mtimeMs - a.mtimeMs); // newest first + let totalBytes = entries.reduce((sum, e) => sum + e.sizeBytes, 0); + let count = entries.length; + // Pop oldest until under both limits. + while ( + (count > MAX_CACHE_ENTRIES || totalBytes > MAX_CACHE_BYTES) && + entries.length > 0 + ) { + const victim = entries.pop(); + if (!victim) break; + removeWorktree(victim.path, projectRoot); + totalBytes -= victim.sizeBytes; + count -= 1; + } +} + +function dirSizeBytes(path: string): number { + let total = 0; + const stack: string[] = [path]; + while (stack.length > 0) { + const cur = stack.pop()!; + let entries; + try { + entries = readdirSync(cur, { withFileTypes: true }); + } catch { + continue; + } + for (const e of entries) { + const full = join(cur, e.name); + if (e.isDirectory()) { + stack.push(full); + } else { + try { + total += statSync(full).size; + } catch { + // skipped + } + } + } + } + return total; +} + +/** + * Test-only — wipe the cache root. Tests use this between scenarios to + * avoid cross-test pollution. Production callers go through the LRU. + */ +export function _wipeCacheForTests(projectRoot: string): void { + const cacheRoot = join(projectRoot, CACHE_DIR_NAME); + if (!existsSync(cacheRoot)) return; + for (const name of readdirSync(cacheRoot)) { + removeWorktree(join(cacheRoot, name), projectRoot); + } +} diff --git a/src/application/http-server.test.ts b/src/application/http-server.test.ts index 0dfef50e..b3afe2de 100644 --- a/src/application/http-server.test.ts +++ b/src/application/http-server.test.ts @@ -401,6 +401,29 @@ describe("http-server — Zod input validation at HTTP boundary", () => { expect(r.json.error).toContain("name"); }); + it("audit rejects base + baseline_prefix combo (mutually exclusive)", async () => { + serverHandle = await startServer(); + const r = await postTool(serverHandle.port, "audit", { + base: "origin/main", + baseline_prefix: "v1", + no_index: true, + }); + // ToolResult error → 400 by default; mutual-exclusion error has no + // dedicated status, so it falls through to 400. + expect(r.status).toBe(400); + expect(r.json.error).toContain("mutually exclusive"); + }); + + it("audit --base in non-git project errors cleanly", async () => { + serverHandle = await startServer(); + const r = await postTool(serverHandle.port, "audit", { + base: "HEAD~1", + no_index: true, + }); + expect(r.status).toBe(400); + expect(r.json.error).toContain("requires a git repository"); + }); + it("impact without target → 400 with structured error", async () => { serverHandle = await startServer(); const r = await postTool(serverHandle.port, "impact", {}); diff --git a/src/application/mcp-server.test.ts b/src/application/mcp-server.test.ts index 142ed522..a265a7a3 100644 --- a/src/application/mcp-server.test.ts +++ b/src/application/mcp-server.test.ts @@ -496,6 +496,41 @@ describe("MCP server — audit / context / validate tools", () => { } }); + it("audit rejects base + baseline_prefix as mutually exclusive", async () => { + const { client, server } = await makeClient(); + try { + const r = await client.callTool({ + name: "audit", + arguments: { + base: "origin/main", + baseline_prefix: "v1", + no_index: true, + }, + }); + expect((r as { isError?: boolean }).isError).toBe(true); + const json = readJson(r); + expect(json.error).toContain("mutually exclusive"); + } finally { + await server.close(); + } + }); + + it("audit returns the {error: ...} envelope when --base used in non-git project", async () => { + // benchDir is mkdtemp'd without `git init`, so isGitRepo() returns false. + const { client, server } = await makeClient(); + try { + const r = await client.callTool({ + name: "audit", + arguments: { base: "HEAD~1", no_index: true }, + }); + expect((r as { isError?: boolean }).isError).toBe(true); + const json = readJson(r); + expect(json.error).toContain("requires a git repository"); + } finally { + await server.close(); + } + }); + it("context returns the envelope shape (file count etc.)", async () => { const { client, server } = await makeClient(); try { diff --git a/src/application/mcp-server.ts b/src/application/mcp-server.ts index 0c026760..72801292 100644 --- a/src/application/mcp-server.ts +++ b/src/application/mcp-server.ts @@ -165,7 +165,7 @@ function registerAuditTool(server: McpServer): void { "audit", { description: - "Structural-drift audit. Composes per-delta baselines (files / dependencies / deprecated) into a {head, deltas} envelope. Pass `baseline_prefix` to auto-resolve -{files,dependencies,deprecated} from query_baselines, OR `baselines: {: }` for explicit per-delta overrides (composes with prefix — both shapes work the same in watch mode). `summary: true` collapses each delta to {added: N, removed: N}. `no_index` controls the auto-incremental-index prelude that runs before the diff: default `true`-equivalent without watch (re-indexes first so head reflects current source), default `false`-equivalent with `--watch` active (the watcher already kept the index fresh — prelude becomes a no-op). Pass `no_index: false` explicitly to force a re-index even when watch is active (escape hatch for 'force a re-index right now').", + "Structural-drift audit. Composes per-delta snapshots (files / dependencies / deprecated) into a {head, deltas} envelope. Two **primary** snapshot sources are mutually exclusive: (1) `base: ` — materialises a git committish (origin/main, HEAD~5, sha, tag) via `git worktree add` to a sha-keyed cache under `.codemap/audit-cache/`, reindexes into a temp DB, diffs against current. Cache hit on second run against same sha is sub-100ms. Requires a git repository — non-git projects get `{error: 'codemap audit: --base requires a git repository'}`. (2) `baseline_prefix` — auto-resolves -{files,dependencies,deprecated} from `query_baselines`. Plus optional **per-delta overrides** via `baselines: {: }` that compose with either primary source. `summary: true` collapses each delta to {added: N, removed: N}. `no_index` controls the head-side incremental-index prelude (default re-indexes; watch-active default is no-op since the watcher keeps the index fresh; pass `no_index: false` to force).", inputSchema: auditArgsSchema, }, async (args) => wrapToolResult(await handleAudit(args)), diff --git a/src/application/tool-handlers.ts b/src/application/tool-handlers.ts index 5ca0b483..b99f1192 100644 --- a/src/application/tool-handlers.ts +++ b/src/application/tool-handlers.ts @@ -29,7 +29,12 @@ import { getFilesChangedSince } from "../git-changed"; import type { GroupByMode } from "../group-by"; import { GROUP_BY_MODES } from "../group-by"; import { getProjectRoot } from "../runtime"; -import { resolveAuditBaselines, runAudit } from "./audit-engine"; +import { + makeWorktreeReindex, + resolveAuditBaselines, + runAudit, + runAuditFromRef, +} from "./audit-engine"; import { buildContextEnvelope } from "./context-engine"; import { findImpact } from "./impact-engine"; import type { ImpactBackend, ImpactDirection } from "./impact-engine"; @@ -340,6 +345,7 @@ function mergeBatchItem( export const auditArgsSchema = { baseline_prefix: z.string().optional(), + base: z.string().optional(), baselines: z .object({ files: z.string().optional(), @@ -353,12 +359,19 @@ export const auditArgsSchema = { export interface AuditArgs { baseline_prefix?: string; + /** Git committish (origin/main, HEAD~5, sha, tag…). Mutually exclusive with baseline_prefix. */ + base?: string; baselines?: { files?: string; dependencies?: string; deprecated?: string }; summary?: boolean; no_index?: boolean; } export async function handleAudit(args: AuditArgs): Promise { + if (args.base !== undefined && args.baseline_prefix !== undefined) { + return err( + "codemap audit: `base` and `baseline_prefix` are mutually exclusive. Use `base` for ad-hoc git-ref comparison; `baseline_prefix` for saved snapshots. Per-delta `baselines.` overrides compose with either.", + ); + } // Skip the incremental-index prelude when the watcher already keeps // the index fresh (mcp --watch / serve --watch). Explicit // `no_index: false` is honored even when watch is on (escape hatch @@ -378,12 +391,23 @@ export async function handleAudit(args: AuditArgs): Promise { if (typeof v === "string") perDelta[k] = v; } } - const baselines = resolveAuditBaselines({ - db, - baselinePrefix: args.baseline_prefix, - perDelta, - }); - const result = runAudit({ db, baselines }); + const result = + args.base !== undefined + ? await runAuditFromRef({ + db, + ref: args.base, + perDeltaOverrides: perDelta, + projectRoot: getProjectRoot(), + reindex: makeWorktreeReindex(), + }) + : runAudit({ + db, + baselines: resolveAuditBaselines({ + db, + baselinePrefix: args.baseline_prefix, + perDelta, + }), + }); if ("error" in result) { return err(result.error); } diff --git a/src/cli/cmd-audit.test.ts b/src/cli/cmd-audit.test.ts index 52a31c02..b83bad8e 100644 --- a/src/cli/cmd-audit.test.ts +++ b/src/cli/cmd-audit.test.ts @@ -42,6 +42,7 @@ describe("parseAuditRest", () => { expect(r).toEqual({ kind: "run", baselinePrefix: "base", + base: undefined, perDelta: {}, json: false, summary: false, @@ -54,6 +55,7 @@ describe("parseAuditRest", () => { expect(r).toEqual({ kind: "run", baselinePrefix: "base", + base: undefined, perDelta: {}, json: false, summary: false, @@ -74,6 +76,7 @@ describe("parseAuditRest", () => { expect(r).toEqual({ kind: "run", baselinePrefix: undefined, + base: undefined, perDelta: { files: "X", dependencies: "Y", deprecated: "Z" }, json: false, summary: false, @@ -92,6 +95,7 @@ describe("parseAuditRest", () => { expect(r).toEqual({ kind: "run", baselinePrefix: "base", + base: undefined, perDelta: { dependencies: "experimental-deps" }, json: false, summary: false, @@ -150,6 +154,70 @@ describe("parseAuditRest", () => { expect(r.kind).toBe("error"); if (r.kind === "error") expect(r.message).toContain("--unknown"); }); + + it("parses --base alone", () => { + const r = parseAuditRest(["audit", "--base", "origin/main"]); + expect(r).toEqual({ + kind: "run", + baselinePrefix: undefined, + base: "origin/main", + perDelta: {}, + json: false, + summary: false, + noIndex: false, + }); + }); + + it("parses --base=", () => { + const r = parseAuditRest(["audit", "--base=HEAD~3"]); + if (r.kind !== "run") throw new Error("expected run"); + expect(r.base).toBe("HEAD~3"); + }); + + it("rejects --base + --baseline (mutually exclusive)", () => { + const r = parseAuditRest([ + "audit", + "--base", + "origin/main", + "--baseline", + "pr", + ]); + expect(r.kind).toBe("error"); + if (r.kind === "error") { + expect(r.message).toContain("mutually exclusive"); + } + }); + + it("allows --base + per-delta override (composes)", () => { + const r = parseAuditRest([ + "audit", + "--base", + "origin/main", + "--files-baseline", + "pre-refactor", + ]); + if (r.kind !== "run") throw new Error("expected run"); + expect(r.base).toBe("origin/main"); + expect(r.perDelta).toEqual({ files: "pre-refactor" }); + }); + + it("errors when --base has no value", () => { + const r = parseAuditRest(["audit", "--base"]); + expect(r.kind).toBe("error"); + if (r.kind === "error") expect(r.message).toContain("--base"); + }); + + it("errors when --base= has empty value", () => { + const r = parseAuditRest(["audit", "--base="]); + expect(r.kind).toBe("error"); + if (r.kind === "error") expect(r.message).toContain("non-empty"); + }); + + it("errors when --base gets an empty-string value (two-token form)", () => { + const r = parseAuditRest(["audit", "--base", ""]); + expect(r.kind).toBe("error"); + if (r.kind === "error") expect(r.message).toContain("--base"); + }); }); describe("resolveAuditBaselines", () => { diff --git a/src/cli/cmd-audit.ts b/src/cli/cmd-audit.ts index 1c701240..cc65eb76 100644 --- a/src/cli/cmd-audit.ts +++ b/src/cli/cmd-audit.ts @@ -1,6 +1,8 @@ import { + makeWorktreeReindex, resolveAuditBaselines, runAudit, + runAuditFromRef, V1_DELTAS, } from "../application/audit-engine"; import type { AuditEnvelope } from "../application/audit-engine"; @@ -28,6 +30,7 @@ export function parseAuditRest(rest: string[]): | { kind: "run"; baselinePrefix: string | undefined; + base: string | undefined; perDelta: Record; json: boolean; summary: boolean; @@ -42,6 +45,7 @@ export function parseAuditRest(rest: string[]): let summary = false; let noIndex = false; let baselinePrefix: string | undefined; + let base: string | undefined; const perDelta: Record = {}; while (i < rest.length) { @@ -74,6 +78,14 @@ export function parseAuditRest(rest: string[]): continue; } + if (a === "--base" || a.startsWith("--base=")) { + const value = consumeFlagValue(rest, i, "--base"); + if (value.kind === "error") return value; + base = value.value; + i = value.next; + continue; + } + // Per-delta `---baseline ` (explicit). let matchedPerDelta = false; for (const [flag, key] of Object.entries(PER_DELTA_FLAGS)) { @@ -94,15 +106,35 @@ export function parseAuditRest(rest: string[]): }; } - if (baselinePrefix === undefined && Object.keys(perDelta).length === 0) { + if (base !== undefined && baselinePrefix !== undefined) { + return { + kind: "error", + message: + "codemap audit: --base and --baseline are mutually exclusive. Use --base for ad-hoc git-ref comparison; --baseline for saved snapshots. Per-delta ---baseline overrides compose with either.", + }; + } + + if ( + base === undefined && + baselinePrefix === undefined && + Object.keys(perDelta).length === 0 + ) { return { kind: "error", message: - "codemap audit: missing snapshot source. Pass --baseline (auto-resolves -files / -dependencies / -deprecated) or ---baseline per delta. v1.x adds --base .", + "codemap audit: missing snapshot source. Pass --base (worktree+reindex against any committish), --baseline (auto-resolves -files / -dependencies / -deprecated) or ---baseline per delta.", }; } - return { kind: "run", baselinePrefix, perDelta, json, summary, noIndex }; + return { + kind: "run", + baselinePrefix, + base, + perDelta, + json, + summary, + noIndex, + }; } // Eat either `--flag value` (two tokens) or `--flag=value` (one). Returns the @@ -155,14 +187,21 @@ export function printAuditCmdHelp(): void { ` --${d.key}-baseline Explicit baseline for the ${d.key} delta.`, ).join("\n"); - console.log(`Usage: codemap audit [--baseline ] [---baseline ]... [--json] [--summary] [--no-index] + console.log(`Usage: codemap audit [--base | --baseline ] [---baseline ]... [--json] [--summary] [--no-index] Diff the current .codemap.db against per-delta baselines (saved by \`codemap query --save-baseline\`) -and emit the structural deltas as a {head, deltas} envelope. Each delta carries its own \`base\` -metadata. v1 ships three deltas: files, dependencies, deprecated. No verdict / threshold / non-zero -exit codes in v1 — compose --json + jq for CI exit codes. +or against a git ref (\`--base \` materialises a worktree + reindex), and emit structural deltas +as a {head, deltas} envelope. Each delta carries its own \`base\` metadata. v1 ships three deltas: +files, dependencies, deprecated. No verdict / threshold / non-zero exit codes — compose --json + jq +for CI exit codes. + +Snapshot sources (one of these must resolve; --base and --baseline are mutually exclusive): -Snapshot sources (at least one delta-baseline must resolve): + --base Materialise via git worktree to a sha-keyed cache + under .codemap/audit-cache/, reindex into a temp DB, then + diff. = any committish (origin/main, HEAD~5, sha, + tag, …). Cache hit on second run against same sha is + sub-100ms. Requires a git repository. --baseline Auto-resolve sugar — looks up -files, -dependencies, -deprecated in @@ -170,9 +209,8 @@ Snapshot sources (at least one delta-baseline must resolve): silently absent (no error per missing slot). ${perDeltaLines} - Each per-delta flag overrides the auto-resolved - slot for that delta. Names must exist in - query_baselines or audit exits 1. + Each per-delta flag overrides one delta's source — + composes with both --base and --baseline. Other flags: --json Emit the {head, deltas} envelope as JSON to stdout @@ -185,6 +223,12 @@ Other flags: Examples: + # Compare current branch to origin/main (no setup — worktree + reindex on first run) + codemap audit --base origin/main --json + + # Compare to a tag, with explicit per-delta override for one slot + codemap audit --base v1.0.0 --files-baseline pre-release-files + # Convention: save with the - naming, then audit by prefix codemap query --save-baseline=base-files "SELECT path FROM files" codemap query --save-baseline=base-dependencies "SELECT from_path, to_path FROM dependencies" @@ -216,6 +260,7 @@ export async function runAuditCmd(opts: { root: string; configFile: string | undefined; baselinePrefix: string | undefined; + base: string | undefined; perDelta: Record; json: boolean; summary: boolean; @@ -236,13 +281,24 @@ export async function runAuditCmd(opts: { await runCodemapIndex(db, { mode: "incremental", quiet: true }); } - const baselines = resolveAuditBaselines({ - db, - baselinePrefix: opts.baselinePrefix, - perDelta: opts.perDelta, - }); + const result = + opts.base !== undefined + ? await runAuditFromRef({ + db, + ref: opts.base, + perDeltaOverrides: opts.perDelta, + projectRoot: getProjectRoot(), + reindex: makeWorktreeReindex(), + }) + : runAudit({ + db, + baselines: resolveAuditBaselines({ + db, + baselinePrefix: opts.baselinePrefix, + perDelta: opts.perDelta, + }), + }); - const result = runAudit({ db, baselines }); if ("error" in result) { emitAuditError(result.error, opts.json); return; @@ -326,7 +382,10 @@ function renderAuditTerminal(envelope: AuditEnvelope, summary: boolean): void { const keyWidth = entries.reduce((n, [k]) => Math.max(n, k.length), 0); for (const [key, delta] of entries) { const sha = delta.base.sha ? ` @ ${delta.base.sha.slice(0, 8)}` : ""; - const provenance = `← ${delta.base.name}${sha}`; + // base.source narrows the union: "baseline" carries `name`; "ref" carries `ref`. + const provenanceLabel = + delta.base.source === "baseline" ? delta.base.name : delta.base.ref; + const provenance = `← ${provenanceLabel}${sha}`; const counts = delta.added.length === 0 && delta.removed.length === 0 ? "(no drift)" diff --git a/src/cli/main.ts b/src/cli/main.ts index 8bbe2196..2b6f9099 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -262,6 +262,7 @@ Copies bundled agent templates into .agents/ under the project root. root, configFile, baselinePrefix: parsed.baselinePrefix, + base: parsed.base, perDelta: parsed.perDelta, json: parsed.json, summary: parsed.summary, diff --git a/templates/agents/rules/codemap.md b/templates/agents/rules/codemap.md index 46a0406c..a79d43f1 100644 --- a/templates/agents/rules/codemap.md +++ b/templates/agents/rules/codemap.md @@ -32,6 +32,7 @@ Install **[@stainless-code/codemap](https://www.npmjs.com/package/@stainless-cod | Save / diff a baseline | `codemap query --save-baseline -r visibility-tags` then `… --json --baseline -r visibility-tags` | | List / drop baselines | `codemap query --baselines` · `codemap query --drop-baseline ` | | Per-delta audit | `codemap audit --json --baseline base` (auto-resolves `base-files` / `base-dependencies` / `base-deprecated`) | +| Audit vs git ref | `codemap audit --base origin/main --json` — worktree+reindex against any committish; sub-100ms second run via sha-keyed cache. Mutually exclusive with `--baseline`; per-delta overrides compose. | | MCP server (for agent hosts) | `codemap mcp` — JSON-RPC on stdio; one tool per CLI verb. See **MCP** section below. | | Targeted read (metadata) | `codemap show [--kind ] [--in ] [--json]` — file:line + signature | | Targeted read (source text) | `codemap snippet [--kind ] [--in ] [--json]` — same lookup + source from disk + stale flag | @@ -59,7 +60,7 @@ Validation: SQL is rejected at load time if it starts with DML/DDL (DELETE/DROP/ **Baselines** (`query_baselines` table inside `.codemap.db`, no parallel JSON files): `--save-baseline[=]` snapshots a result set; `--baseline[=]` diffs the current result against it (added / removed rows; identity = `JSON.stringify(row)`). Name defaults to the `--recipe` id; ad-hoc SQL needs an explicit `=`. Survives `--full` and SCHEMA bumps. -**Audit (`codemap audit`)**: structural-drift command; emits `{head, deltas: {files, dependencies, deprecated}}` (each delta carries its own `base` metadata). Reuses B.6 baselines as the snapshot source. Two CLI shapes — `--baseline ` auto-resolves `-files` / `-dependencies` / `-deprecated`; `---baseline ` is the explicit per-delta override. v1 ships no `verdict` / threshold config — consumers compose `--json` + `jq` for CI exit codes. Auto-runs an incremental index before the diff (use `--no-index` to skip for frozen-DB CI). +**Audit (`codemap audit`)**: structural-drift command; emits `{head, deltas: {files, dependencies, deprecated}}` (each delta carries its own `base` metadata). Three mutually-exclusive snapshot sources: `--base ` materialises a git committish via `git worktree add` to a sha-keyed cache under `.codemap/audit-cache/`, reindexes a temp DB, then diffs (sub-100ms second run; requires git; `base.source: "ref"`); `--baseline ` auto-resolves `-files` / `-dependencies` / `-deprecated` from saved `query_baselines` entries (`base.source: "baseline"`); `---baseline ` is the explicit per-delta override (composes with both). v1 ships no `verdict` / threshold config — consumers compose `--json` + `jq` for CI exit codes. Auto-runs an incremental index before the diff (use `--no-index` to skip for frozen-DB CI). **Targeted reads (`show` / `snippet`)**: precise lookup by exact symbol name without composing SQL. `show` returns metadata (`file_path:line_start-line_end` + `signature`); `snippet` returns the source text from disk plus `stale` / `missing` flags. Both share the same flag set (`--kind ` to filter by `symbols.kind`, `--in ` for file-scope filter — directory prefix or exact file). Output envelope is `{matches, disambiguation?}` — single match → `{matches: [{...}]}`; multi-match adds `disambiguation: {n, by_kind, files, hint}` so agents narrow without re-scanning. Name match is exact / case-sensitive — for fuzzy use `query` with `LIKE '%name%'`. Snippet stale-file behavior: `source` is always returned when the file exists; `stale: true` means the line range may have shifted (re-index with `codemap` or `codemap --files ` before acting on the source). diff --git a/templates/agents/skills/codemap/SKILL.md b/templates/agents/skills/codemap/SKILL.md index 19f896fa..140310ab 100644 --- a/templates/agents/skills/codemap/SKILL.md +++ b/templates/agents/skills/codemap/SKILL.md @@ -65,7 +65,7 @@ Each emitted delta carries its own `base` metadata so mixed-baseline audits are - **`query`** — one SQL statement. Args: `{sql, summary?, changed_since?, group_by?, format?}`. Same envelope as `codemap query --json`. Pass `format: "sarif"` or `"annotations"` to receive a formatted text payload (SARIF 2.1.0 doc / `::notice` lines); ad-hoc SQL gets `rule.id = codemap.adhoc`. Format is incompatible with `summary` / `group_by` (parser rejects with a structured `{error}`). - **`query_batch`** — MCP-only, no CLI counterpart. Args: `{statements: (string | {sql, summary?, changed_since?, group_by?})[], summary?, changed_since?, group_by?}`. Items are bare SQL strings (inherit batch-wide flag defaults) or objects (override on a per-key basis). Output is N-element array; per-element shape mirrors single-`query`'s output for that statement's effective flag set. Per-statement errors are isolated — failed statements return `{error}` in their slot; siblings still execute. SQL-only (no `recipe` polymorphism in items). `format` deferred to v1.x — annotation/sarif on a heterogeneous batch is awkward; call `query` per recipe instead. - **`query_recipe`** — `{recipe, summary?, changed_since?, group_by?, format?}`. Resolves the recipe id to SQL + per-row actions, then executes like `query`. Unknown recipe id returns a structured `{error}` pointing at the `codemap://recipes` resource. With `format: "sarif"`, `rule.id = codemap.`, `rule.shortDescription` = recipe description, `rule.fullDescription` = the recipe's `.md` body. -- **`audit`** — `{baseline_prefix?, baselines?: {files?, dependencies?, deprecated?}, summary?, no_index?}`. Composes per-delta baselines into the `{head, deltas}` envelope. Auto-runs incremental index unless `no_index: true`. +- **`audit`** — `{base?, baseline_prefix?, baselines?: {files?, dependencies?, deprecated?}, summary?, no_index?}`. Composes per-delta snapshots into the `{head, deltas}` envelope. Two **primary** sources are mutually exclusive: `base: ` (git committish — worktree+reindex against any committish; sha-keyed cache under `.codemap/audit-cache/`; sub-100ms second run; requires git, errors cleanly on non-git projects) OR `baseline_prefix: ""` (auto-resolve `-{files,dependencies,deprecated}` from `query_baselines`). Plus optional **per-delta overrides** via `baselines: {: }` that compose with either primary source. Per-delta `base.source` is `"ref"` (with `base.ref` + `base.sha`) or `"baseline"` (with `base.name` + `base.sha`). Auto-runs incremental index unless `no_index: true`; watch-active sessions skip the prelude automatically. - **`save_baseline`** — polymorphic `{name, sql? | recipe?}` with runtime exclusivity check (mirrors the CLI's single `--save-baseline=` verb). Pass exactly one of `sql` or `recipe`. - **`list_baselines`** — no args; returns the array `codemap query --baselines --json` would print. - **`drop_baseline`** — `{name}`. Returns `{dropped: }` on success or `isError` if the name doesn't exist.