From cc2ab6644c673ec464325b55de673b22d1d9246c Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Mon, 4 May 2026 09:30:42 +0300 Subject: [PATCH 1/6] =?UTF-8?q?docs(plan):=20static=20coverage=20ingestion?= =?UTF-8?q?=20(Istanbul=20JSON=20=E2=86=92=20`coverage`=20table)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plans the C.11 candidate from `research/fallow.md` — `codemap ingest-coverage ` reads Istanbul `coverage-final.json` into two new tables (`coverage` symbol-level + `file_coverage` rollup), joinable to `symbols` for the killer "what's structurally dead AND untested?" recipe in one query. Resolves the open question from `fallow.md § 6` ("symbols column vs separate table?") in favour of a separate table with `ON DELETE CASCADE` (D1) — coverage shape evolves independently of structural columns; LEFT JOIN keeps NULL semantics explicit; rows survive `--full` reindex via the `query_baselines` precedent (D6). Key decisions: - Istanbul JSON in v1; LCOV in v1.x; raw V8 traces never (D3, fallow's paid moat). - One-shot `ingest-coverage` verb decoupled from `codemap` index runs (D4) — coverage cadence (per `bun test --coverage`) ≠ index cadence (per file edit). - Statement coverage only in v1 (D5); branch/function deferred until a consumer asks. - MCP/HTTP exposure as a query column, not a separate `coverage` tool (D9) — composes with every existing recipe + ad-hoc SQL. - `codemap audit --delta coverage` deferred to v1.x (D10) — raw schema first. Five-tracer plan: schema bump → engine → CLI verb → fixture + golden recipe → docs. Plan only — implementation follows after CodeRabbit review per the established workflow (PRs #46/47, #49/50, #51/52, #53/54). --- docs/plans/coverage-ingestion.md | 134 +++++++++++++++++++++++++++++++ docs/roadmap.md | 1 + 2 files changed, 135 insertions(+) create mode 100644 docs/plans/coverage-ingestion.md diff --git a/docs/plans/coverage-ingestion.md b/docs/plans/coverage-ingestion.md new file mode 100644 index 0000000..d56b244 --- /dev/null +++ b/docs/plans/coverage-ingestion.md @@ -0,0 +1,134 @@ +# Static coverage ingestion (`coverage-final.json` → `coverage` table) + +> **Status:** in design (no code) · **Backlog:** [research/fallow.md § C.11](../research/fallow.md#tier-c--ship-eventually-months-high-payoff-large-surface). Delete this file when shipped (per [`docs/README.md` Rule 3](../README.md)). + +## Goal + +Ingest Istanbul-format coverage JSON (`coverage-final.json`) into the codemap index so structural queries can compose coverage filters in pure SQL — without bolting Codemap to a runtime tracer or paid coverage stack. + +The killer recipe this unlocks: + +```sql +-- "What's structurally dead AND untested?" — single query, two evidence axes. +SELECT s.name, s.file_path, c.coverage_pct +FROM symbols s +LEFT JOIN coverage c ON c.symbol_id = s.id +WHERE s.is_exported = 1 + AND s.id NOT IN (SELECT callee_id FROM calls WHERE callee_id IS NOT NULL) + AND COALESCE(c.coverage_pct, 0) = 0 +ORDER BY s.file_path, s.line_start; +``` + +Today an agent has to run two tools (`codemap` + a coverage reader) and join in JS. After this lands it's one `query` + one `JOIN`. + +## Why + +- **Codemap is structural-only today.** Every "is this dead?" query has a structural false-positive rate (the §0 fallow audit found an 8-file widget pack with non-zero structural fan-in but zero runtime usage). Coverage is the **complementary evidence axis** — `structural fan-in = 0` AND `runtime coverage = 0` is the high-confidence "dead" predicate. +- **Static ingestion is free.** Istanbul-format JSON is the universal output format for `c8`, `nyc`, `vitest --coverage`, `jest --coverage`, `bun test --coverage`. We're not building a coverage tracer — we're reading the artifact those tools already produce. +- **Fallow's runtime intelligence is paid.** Static coverage ingestion gets ~80% of the agent value (the "is X dead?" predicate above) without entering Fallow's V8/production-beacon territory (explicit non-goal per [research/fallow.md § D.16](../research/fallow.md#defer--skip)). +- **Composes with `codemap impact`.** `impact --direction up --depth 0` returns callers; joining `coverage` on the result tells the agent "this symbol has 12 callers but only 2 of them are hit by tests" — refactor risk in one query. + +## Sketched layout + +### Schema (D1, D2) + +```sql +-- New table; symbols-side denormalisation rejected (see D1). +CREATE TABLE coverage ( + symbol_id INTEGER NOT NULL REFERENCES symbols(id) ON DELETE CASCADE, + coverage_pct REAL NOT NULL, -- 0.0 – 100.0; statement coverage (see D5) + hit_lines INTEGER NOT NULL, + total_lines INTEGER NOT NULL, + source TEXT NOT NULL, -- ingester id, e.g. "istanbul" / "lcov" (D3) + PRIMARY KEY (symbol_id) +) STRICT, WITHOUT ROWID; + +-- File-level rollup so "rank files by coverage" doesn't have to GROUP BY symbols. +CREATE TABLE file_coverage ( + file_path TEXT NOT NULL REFERENCES files(path) ON DELETE CASCADE, + coverage_pct REAL NOT NULL, + hit_lines INTEGER NOT NULL, + total_lines INTEGER NOT NULL, + source TEXT NOT NULL, + PRIMARY KEY (file_path) +) STRICT, WITHOUT ROWID; + +-- Meta row: timestamp + source path of the last successful ingest. Lets agents +-- check freshness without a separate verb. NULL when no coverage ever ingested. +INSERT INTO meta (key, value) VALUES ('coverage_last_ingested_at', ''); +INSERT INTO meta (key, value) VALUES ('coverage_last_ingested_path', ''); +INSERT INTO meta (key, value) VALUES ('coverage_last_ingested_source', 'istanbul'); +``` + +`ON DELETE CASCADE` on `symbols(id)` and `files(path)` means re-indexing automatically drops the coverage rows whose symbols/files no longer exist. **Stale-coverage handling falls out for free** (D6). + +### CLI + +```text +codemap ingest-coverage [--source istanbul|lcov] [--prune] [--json] +``` + +- `` — required. Path to `coverage-final.json` (Istanbul, default), `lcov.info` (LCOV, v1.x), or directory containing `coverage-final.json`. +- `--source` — optional. Auto-detected from filename / extension; flag overrides. +- `--prune` — also `DELETE FROM coverage` rows whose `symbol_id` no longer matches a symbol that has 1+ statement in the ingested coverage map (handles "symbol still exists but nothing covers it"). Default off; `coverage_pct = 0` rows are valid evidence. +- `--json` — emit `{ingested: {symbols: N, files: M}, skipped: {unmatched_files: K}, source: "istanbul"}` envelope on stdout. + +Decoupled from `codemap` (the index command) on purpose (D4) — coverage runs once per `bun test --coverage` invocation, not once per file edit. + +## Decisions + +| # | Decision | +| --- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| D1 | **Separate `coverage` table, not columns on `symbols`.** Resolves the open question in [research/fallow.md § 6](../research/fallow.md#6-open-questions). Three reasons: (a) coverage shape evolves independently of structural columns (per-branch, per-function, per-line metrics layered later) — denormalising churns `SCHEMA_VERSION` every time; (b) `symbols` rows exist for every TS / TSX file; coverage rows only for tested files — joining via `LEFT JOIN coverage` keeps NULL semantics explicit; (c) `coverage` survives a `--full` reindex because it lives next to `query_baselines` (intentional `dropAll()` exclusion — see [`db.ts` `query_baselines` comment](../../src/db.ts) for precedent). | +| D2 | **Two tables — symbol-level + file-level rollup.** File-level rollup is a single-table scan instead of `GROUP BY symbols.file_path` over a JOIN. The denormalisation cost is one extra UPSERT per file at ingest time; the read-side win is every "files ranked by coverage" query becomes a primary-key scan on `file_coverage`. Mirrors `dependencies` (composite PK) vs `imports` (raw rows) split. | +| D3 | **Istanbul JSON in v1; LCOV in v1.x; c8 raw V8 traces never.** Istanbul JSON (`coverage-final.json`) is the universal IR — `c8`, `nyc`, `vitest`, `jest`, `bun test --coverage` all emit it. LCOV (`lcov.info`) is the second-most-common format; ingester is ~50 LoC of regex parsing — defer to v1.x once Istanbul lands. Raw V8 traces (`*.cpu.profile` / `coverage-c8/*.json`) are out of scope — they're Fallow's paid moat and require a runtime tracer Codemap doesn't ship. | +| D4 | **One-shot `codemap ingest-coverage `, NOT auto-detected during `codemap` runs.** Reasons: (a) `codemap` is sub-100ms cold-start; auto-probing for `coverage/coverage-final.json` adds a `stat` and grows the surface for "where did codemap look for coverage?"; (b) coverage cadence (once per test run) is decoupled from index cadence (every file edit) — coupling them means stale coverage on every save; (c) explicit verb makes the agent's mental model trivial: "tests ran → `codemap ingest-coverage` → `codemap query`". `codemap watch` does NOT auto-ingest coverage on `coverage-final.json` change (separate concern; revisit if demand materialises). | +| D5 | **Statement coverage only in v1.** Istanbul tracks statement / branch / function / line coverage separately. `coverage_pct` is **statement** coverage (the most stable signal across runners — function coverage misses anonymous closures, branch coverage explodes for switch / ternary). v1.x can add `branch_coverage_pct` / `function_coverage_pct` columns once a real consumer asks. Don't pre-emptively widen. | +| D6 | **Coverage rows survive `--full` reindex.** `coverage` and `file_coverage` join the `query_baselines` precedent — intentionally absent from `dropAll()` so a `--full` rebuild doesn't nuke the user's last ingest. `ON DELETE CASCADE` on `symbols(id)` / `files(path)` handles the "symbol no longer exists" case automatically when those rows reinsert. Re-running `codemap ingest-coverage` is the user's explicit "refresh" verb. | +| D7 | **Symbol mapping by `(file_path, line_start ≤ stmt_line ≤ line_end)`.** Istanbul's `statementMap` is line-keyed; we project per-statement hits onto the enclosing symbol via the same line-range projection `markers` already uses. Symbols whose range covers ≥1 statement get a `coverage` row; symbols whose range covers 0 statements (interface declarations, type aliases) don't — they appear as `NULL` in `LEFT JOIN coverage`. No fuzzy matching, no source-map walking — plain BETWEEN. | +| D8 | **Path normalisation: project-relative, forward-slashed.** Istanbul writes absolute paths; we strip `/` and replace `\\` with `/` to match `files.path`. Files outside the project root land in `skipped.unmatched_files`. Same projection `toProjectRelative()` (in `validate-engine`) already does — reuse the helper instead of rewriting it. | +| D9 | **MCP / HTTP exposure: column in `query` results, NOT a separate `coverage` tool.** The killer recipe (top of this doc) is one SQL query — `query` / `query_recipe` already returns `coverage_pct` as a column when the SELECT asks for it. A standalone `coverage` MCP tool would duplicate the surface; revisit only if a consumer ships a wrapper script that proves the SQL ergonomic gap is real. | +| D10 | **`codemap audit` integration deferred.** Adding `--delta coverage` to `audit` is the natural next step (flag "files where `coverage_pct` dropped >5% vs `--base`") but layered on top of D1–D9. Track as v1.x backlog; ship the ingester + raw schema first. | +| D11 | **Ingester lives in `application/coverage-engine.ts`.** Same engine-vs-CLI split as `impact` / `audit`: pure ingester (`ingestCoverage({db, source, payload}) → IngestResult`) consumed by `cli/cmd-ingest-coverage.ts`. No MCP / HTTP transport in v1 (D9). Engine is unit-testable against fixture Istanbul JSON without spinning up the CLI. | +| D12 | **Schema bump = minor changeset.** Adds two tables (`coverage`, `file_coverage`) + three meta keys; doesn't break any existing query. Per [`.agents/lessons.md`](../../.agents/lessons.md) "changesets bump policy", new tables = minor; the `SCHEMA_VERSION` bump auto-rebuilds the index on next `codemap` run. Coverage rows are then absent until the user runs `ingest-coverage` again — documented in the changeset. | + +## Tracer-bullet plan + +Per [`tracer-bullets.mdc`](../../.cursor/rules/tracer-bullets.mdc) — vertical slices, each shippable on its own. + +| # | Tracer | Acceptance | +| --- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| 1 | **Schema + bump** in `src/db.ts`. Add `coverage` + `file_coverage` table DDL; bump `SCHEMA_VERSION`; add both tables to the `query_baselines`-style "preserve across `--full`" exclusion in `dropAll()`. New `meta` keys (`coverage_last_ingested_at` / `_path` / `_source`) need no DDL — `meta` is `(key, value)` already. Unit test (`src/db.test.ts`) inserts + reads back one row of each shape; verifies CASCADE on `DELETE FROM symbols`. | Tables exist; CASCADE works; `--full` reindex preserves coverage rows. | +| 2 | **Istanbul ingester engine** in `src/application/coverage-engine.ts` (new). `ingestCoverage({db, payload, source: "istanbul", projectRoot}) → {ingested, skipped, source}`. Pure: parses the Istanbul JSON shape (`{ [absPath]: {statementMap, s, fnMap, f, branchMap, b, path} }`), maps statement coverage (`s` counter) to symbols via the line-range projection (D7), upserts `coverage` and `file_coverage`, writes meta keys. Reuses `toProjectRelative` from `validate-engine` (D8). Unit tests cover: fresh ingest, re-ingest (UPSERT idempotence), unmatched file (skipped), symbol with 0 statements (NULL row), absolute → relative path normalisation, Windows-path handling. | Pure engine; deterministic upsert; no FS / process side effects beyond `db.run`. | +| 3 | **CLI verb** `cli/cmd-ingest-coverage.ts` — parses `` + `--source` + `--prune` + `--json`, reads the file (`Bun.file` on Bun, `readFile` on Node — same split as `config.ts`), dispatches to `coverage-engine`, renders the result. `main.ts` dispatcher gains the verb between existing entries. Help text in `bootstrap.ts` lists the new command. Unit tests cover: file-not-found, malformed JSON, source mismatch (`--source lcov` on a `coverage-final.json`), `--json` envelope shape. | `bun src/index.ts ingest-coverage fixtures/coverage/coverage-final.json` writes rows; `--help` lists the verb. | +| 4 | **Fixture coverage data** — `fixtures/minimal/coverage/coverage-final.json` (small Istanbul shape covering the existing fixture symbols partially: e.g. `usePermissions` 100%, `legacyClient` 0%, `now` 50%). Golden query under `fixtures/golden/minimal/coverage-deprecated.json` exercises the killer recipe ("`@deprecated` symbols with `coverage_pct = 0`"). Adds the fixture row to `fixtures/minimal/README.md` "What's exercised" table. The pre-enriched `legacyClient` / `now` / `epochMs` deprecated symbols (PR #55) directly test the join axis — no new fixture symbols needed beyond the coverage JSON itself. | Golden recipe passes; documents how a real consumer wires `bun test --coverage` into the workflow. | +| 5 | **Doc + agent rule + skill + changeset + plan deletion** — `docs/architecture.md` § Persistence wiring (new tables, ingester engine, CLI verb), `docs/glossary.md` (`coverage_pct`, `file_coverage`, "Istanbul JSON", "static coverage ingestion"), `.agents/rules/codemap.md` + `templates/agents/rules/codemap.md` (Rule 10 lockstep — new trigger pattern: "What's untested AND structurally dead?", new SQL example), skill SKILL.md `coverage` table row, README.md "What's exercised" + Use section. Minor changeset noting the `SCHEMA_VERSION` bump + the explicit re-ingest needed after upgrade. Plan deleted per `docs/README.md` Rule 3. | All docs consistent; agents see the new tables on next `codemap agents init`. | + +## Performance considerations + +- **Ingest cost** — one `JSON.parse` + linear scan of statement maps. A 1000-file Istanbul payload is ~100 KB; parse + ingest in <50 ms (read benchmark: `query_baselines` `INSERT` of comparable shape benches at ~1 µs / row). +- **Read cost** — `coverage` is `WITHOUT ROWID` keyed on `symbol_id`; every join is a primary-key lookup. `LEFT JOIN coverage ON c.symbol_id = s.id` adds <1 ms to recipe queries even on 10k-symbol corpora. +- **Storage** — two `INTEGER` + one `REAL` + one short `TEXT` per symbol; sub-50 bytes / row. 10k symbols = ~500 KB on disk. Negligible vs the index size (~MBs). +- **No background worker.** Ingest is single-pass; reindex is unaffected. + +## Alternatives considered + +| Candidate | Why not | +| -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Add `coverage_pct REAL` + `is_runtime_hot INTEGER` to `symbols` directly** | Per D1: shape-coupling, NULL ambiguity, schema-bump amplification. The original sketch ([research/fallow.md § C.11](../research/fallow.md#tier-c--ship-eventually-months-high-payoff-large-surface)) listed this — D1 supersedes. | +| **Auto-detect `coverage/coverage-final.json` during `codemap` runs** | Per D4: cadence mismatch + auto-probe surface. The auto-detect path is also harder to reason about ("why does this index include coverage on machine A but not B?"); explicit verb is unambiguous. | +| **Ship LCOV ingester in v1 alongside Istanbul** | Doubles the test surface for marginal value — every modern coverage tool emits Istanbul JSON natively; LCOV is the legacy path. Defer to v1.x; add `--source lcov` once a consumer asks. | +| **Embed runtime coverage tracer (V8 / Istanbul beacons)** | Out of scope per [research/fallow.md § D.16](../research/fallow.md#defer--skip) and [roadmap.md § Non-goals](../roadmap.md#non-goals-v1). This plan reads artifacts; it does not produce them. | +| **`coverage` rows keyed by `(file_path, line_start)` instead of `symbol_id`** | Decouples coverage from symbol identity — robust to symbol renames between ingest and reindex. Rejected because the killer recipe is "join coverage to symbols," and a composite key forces every join to repeat `(file_path, line_start)` instead of `symbol_id`. CASCADE on `symbols(id)` handles renames. | +| **Separate MCP `coverage` tool returning `{symbol, pct, hit, total}` envelopes** | Per D9: the column-in-`query`-results path is composable with every existing recipe + ad-hoc SQL; a standalone tool would force a parallel surface for one column. Revisit if SQL composition proves too verbose for agents. | +| **Inline coverage into `codemap audit` v1 (`--delta coverage`)** | Per D10: the plan stays small. Audit-side delta is a clean follow-up once the raw schema lands. | +| **Persist coverage in a sibling JSON file (`/coverage.json`)** | Forces every consumer to re-implement the join; loses SQL composability. The whole point of Codemap is "it's a SQL index" — keep coverage in the same DB. | + +## Out of scope + +- **Branch / function / line coverage breakdowns** — D5; v1.x once a consumer asks with a concrete query. +- **Coverage diff against baseline** — `--save-baseline` / `--baseline` already covers arbitrary query result snapshots; coverage queries inherit it for free. A first-class `coverage_diff` would duplicate. +- **Coverage trend over time** — adjacent to telemetry; not in v1. Consumers can `--save-baseline coverage-snapshot-2026-05-04` periodically. +- **CI verdict / threshold logic** — same condition as `codemap audit verdict`: defer until a consumer ships a `jq` script that proves the threshold shape. +- **Auto-running tests** — Codemap reads coverage artifacts; it doesn't invoke `bun test --coverage`. Test orchestration is the user's CI. +- **Source-map-aware coverage** — Istanbul's `statementMap` is post-transform; coverage rows reflect the compiled file's structure. Source-map walking to original `.ts` lines is deferred (most projects with TS test runners already emit Istanbul against the pre-compile source via `vitest` / `bun test` instrumentation). diff --git a/docs/roadmap.md b/docs/roadmap.md index d6ebfd1..9ad5a8e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -35,6 +35,7 @@ Codemap stays a structural-index primitive that other tools can consume. Out of ## Backlog +- [ ] **Static coverage ingestion** — `codemap ingest-coverage ` reads Istanbul `coverage-final.json` into a `coverage` table, joinable to `symbols` for "what's structurally dead AND untested?" queries. Plan: [plans/coverage-ingestion.md](./plans/coverage-ingestion.md). Adapted from [research/fallow.md § C.11](./research/fallow.md#tier-c--ship-eventually-months-high-payoff-large-surface). - [ ] **`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 From 0f74347f57463f3819b7cb6d77f24337288cac8f Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Mon, 4 May 2026 09:46:33 +0300 Subject: [PATCH 2/6] =?UTF-8?q?docs(plan):=20fact-check=20fixes=20?= =?UTF-8?q?=E2=80=94=20drop=20hallucinated=20SQL/projection/runner=20claim?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-audit against the actual codebase surfaced four claims that didn't hold: 1. Killer recipe SQL referenced `callee_id` — `calls` is name-keyed (`callee_name TEXT`, no symbol-id FK; see `db.ts` `CallRow`). Rewrote the "no callers" predicate as `NOT EXISTS (… WHERE callee_name = s.name)`. 2. D7 claimed line-range projection is "the same `markers` already uses" — `markers` is line-pinned (`line_number INTEGER`), no projection. Reworded as "novel for this plan" with the actual mechanic spelled out. 3. D3 listed `bun test --coverage` as an Istanbul JSON emitter — `bun test --help` shows only `text` / `lcov` reporters today. Removed bun from the Istanbul-emitters list; left vitest/jest/c8/nyc with the explicit reporter flags they need. 4. D12 contradicted D6 ("rows absent until re-ingest" vs "rows survive `--full`"). Reconciled: empty is the correct initial state on first bump; subsequent bumps preserve via the `dropAll()` exclusion. Quoted the `lessons.md` policy verbatim instead of paraphrasing. --- docs/plans/coverage-ingestion.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/plans/coverage-ingestion.md b/docs/plans/coverage-ingestion.md index d56b244..7404f65 100644 --- a/docs/plans/coverage-ingestion.md +++ b/docs/plans/coverage-ingestion.md @@ -10,11 +10,13 @@ The killer recipe this unlocks: ```sql -- "What's structurally dead AND untested?" — single query, two evidence axes. +-- `calls` is name-keyed (no symbol-id FK, see `db.ts` `CallRow`), so the +-- "no callers" predicate is a NOT EXISTS subquery on `callee_name`. SELECT s.name, s.file_path, c.coverage_pct FROM symbols s LEFT JOIN coverage c ON c.symbol_id = s.id WHERE s.is_exported = 1 - AND s.id NOT IN (SELECT callee_id FROM calls WHERE callee_id IS NOT NULL) + AND NOT EXISTS (SELECT 1 FROM calls WHERE callee_name = s.name) AND COALESCE(c.coverage_pct, 0) = 0 ORDER BY s.file_path, s.line_start; ``` @@ -81,16 +83,16 @@ Decoupled from `codemap` (the index command) on purpose (D4) — coverage runs o | --- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | D1 | **Separate `coverage` table, not columns on `symbols`.** Resolves the open question in [research/fallow.md § 6](../research/fallow.md#6-open-questions). Three reasons: (a) coverage shape evolves independently of structural columns (per-branch, per-function, per-line metrics layered later) — denormalising churns `SCHEMA_VERSION` every time; (b) `symbols` rows exist for every TS / TSX file; coverage rows only for tested files — joining via `LEFT JOIN coverage` keeps NULL semantics explicit; (c) `coverage` survives a `--full` reindex because it lives next to `query_baselines` (intentional `dropAll()` exclusion — see [`db.ts` `query_baselines` comment](../../src/db.ts) for precedent). | | D2 | **Two tables — symbol-level + file-level rollup.** File-level rollup is a single-table scan instead of `GROUP BY symbols.file_path` over a JOIN. The denormalisation cost is one extra UPSERT per file at ingest time; the read-side win is every "files ranked by coverage" query becomes a primary-key scan on `file_coverage`. Mirrors `dependencies` (composite PK) vs `imports` (raw rows) split. | -| D3 | **Istanbul JSON in v1; LCOV in v1.x; c8 raw V8 traces never.** Istanbul JSON (`coverage-final.json`) is the universal IR — `c8`, `nyc`, `vitest`, `jest`, `bun test --coverage` all emit it. LCOV (`lcov.info`) is the second-most-common format; ingester is ~50 LoC of regex parsing — defer to v1.x once Istanbul lands. Raw V8 traces (`*.cpu.profile` / `coverage-c8/*.json`) are out of scope — they're Fallow's paid moat and require a runtime tracer Codemap doesn't ship. | +| D3 | **Istanbul JSON in v1; LCOV in v1.x; c8 raw V8 traces never.** Istanbul JSON (`coverage-final.json`) is the dominant IR for Node-side TS coverage — `c8`, `nyc`, `vitest --coverage --coverage.reporter=json`, `jest --coverage --coverageReporters=json` all emit it. `bun test --coverage` ships LCOV / text reporters today (verified via `bun test --help`); LCOV ingester is the second tracer once Istanbul lands. Raw V8 traces (`*.cpu.profile` / `coverage-c8/*.json`) are out of scope — they're Fallow's paid moat and require a runtime tracer Codemap doesn't ship. | | D4 | **One-shot `codemap ingest-coverage `, NOT auto-detected during `codemap` runs.** Reasons: (a) `codemap` is sub-100ms cold-start; auto-probing for `coverage/coverage-final.json` adds a `stat` and grows the surface for "where did codemap look for coverage?"; (b) coverage cadence (once per test run) is decoupled from index cadence (every file edit) — coupling them means stale coverage on every save; (c) explicit verb makes the agent's mental model trivial: "tests ran → `codemap ingest-coverage` → `codemap query`". `codemap watch` does NOT auto-ingest coverage on `coverage-final.json` change (separate concern; revisit if demand materialises). | | D5 | **Statement coverage only in v1.** Istanbul tracks statement / branch / function / line coverage separately. `coverage_pct` is **statement** coverage (the most stable signal across runners — function coverage misses anonymous closures, branch coverage explodes for switch / ternary). v1.x can add `branch_coverage_pct` / `function_coverage_pct` columns once a real consumer asks. Don't pre-emptively widen. | | D6 | **Coverage rows survive `--full` reindex.** `coverage` and `file_coverage` join the `query_baselines` precedent — intentionally absent from `dropAll()` so a `--full` rebuild doesn't nuke the user's last ingest. `ON DELETE CASCADE` on `symbols(id)` / `files(path)` handles the "symbol no longer exists" case automatically when those rows reinsert. Re-running `codemap ingest-coverage` is the user's explicit "refresh" verb. | -| D7 | **Symbol mapping by `(file_path, line_start ≤ stmt_line ≤ line_end)`.** Istanbul's `statementMap` is line-keyed; we project per-statement hits onto the enclosing symbol via the same line-range projection `markers` already uses. Symbols whose range covers ≥1 statement get a `coverage` row; symbols whose range covers 0 statements (interface declarations, type aliases) don't — they appear as `NULL` in `LEFT JOIN coverage`. No fuzzy matching, no source-map walking — plain BETWEEN. | +| D7 | **Symbol mapping by `(file_path, line_start ≤ stmt_line ≤ line_end)`.** Istanbul's `statementMap` is line-keyed; we project per-statement hits onto the enclosing symbol via a plain BETWEEN over `symbols.line_start` / `symbols.line_end`. (No prior table uses this projection — `markers` are line-pinned and `calls` are name-keyed; the projection is novel for this plan.) Symbols whose range covers ≥1 statement get a `coverage` row; symbols whose range covers 0 statements (interface declarations, type aliases) don't — they appear as `NULL` in `LEFT JOIN coverage`. No fuzzy matching, no source-map walking. | | D8 | **Path normalisation: project-relative, forward-slashed.** Istanbul writes absolute paths; we strip `/` and replace `\\` with `/` to match `files.path`. Files outside the project root land in `skipped.unmatched_files`. Same projection `toProjectRelative()` (in `validate-engine`) already does — reuse the helper instead of rewriting it. | | D9 | **MCP / HTTP exposure: column in `query` results, NOT a separate `coverage` tool.** The killer recipe (top of this doc) is one SQL query — `query` / `query_recipe` already returns `coverage_pct` as a column when the SELECT asks for it. A standalone `coverage` MCP tool would duplicate the surface; revisit only if a consumer ships a wrapper script that proves the SQL ergonomic gap is real. | | D10 | **`codemap audit` integration deferred.** Adding `--delta coverage` to `audit` is the natural next step (flag "files where `coverage_pct` dropped >5% vs `--base`") but layered on top of D1–D9. Track as v1.x backlog; ship the ingester + raw schema first. | | D11 | **Ingester lives in `application/coverage-engine.ts`.** Same engine-vs-CLI split as `impact` / `audit`: pure ingester (`ingestCoverage({db, source, payload}) → IngestResult`) consumed by `cli/cmd-ingest-coverage.ts`. No MCP / HTTP transport in v1 (D9). Engine is unit-testable against fixture Istanbul JSON without spinning up the CLI. | -| D12 | **Schema bump = minor changeset.** Adds two tables (`coverage`, `file_coverage`) + three meta keys; doesn't break any existing query. Per [`.agents/lessons.md`](../../.agents/lessons.md) "changesets bump policy", new tables = minor; the `SCHEMA_VERSION` bump auto-rebuilds the index on next `codemap` run. Coverage rows are then absent until the user runs `ingest-coverage` again — documented in the changeset. | +| D12 | **Schema bump = minor changeset.** Adds two tables (`coverage`, `file_coverage`) + three meta keys; doesn't break any existing query. Per [`.agents/lessons.md`](../../.agents/lessons.md) "changesets bump policy" (verbatim: _reserve minor for schema-breaking changes that force a `.codemap.db` rebuild — matches 0.2.0 precedent: new tables/columns/`SCHEMA_VERSION` bump_), new tables + `SCHEMA_VERSION` bump = minor. The bump triggers `dropAll()` on next `codemap` run; coverage tables are absent on existing installs until first ingest (no migration needed — empty is the correct initial state). Subsequent `SCHEMA_VERSION` bumps preserve coverage data via the `dropAll()` exclusion (D6). | ## Tracer-bullet plan From 3e796f84df1c084e7971f2ffe44a72ab973fdea9 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Mon, 4 May 2026 10:00:56 +0300 Subject: [PATCH 3/6] =?UTF-8?q?docs(plan):=20v2=20=E2=80=94=20fix=20CASCAD?= =?UTF-8?q?E=20hazard=20+=20innermost-wins=20projection=20+=20nits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-grilling found two real schema design holes that would block execution: 1. **D6 CASCADE hazard.** Original draft keyed `coverage` on `symbol_id REFERENCES symbols(id) ON DELETE CASCADE`. Every `--full` reindex calls `dropAll()` → drops `symbols` → CASCADE wipes coverage, regardless of whether `coverage` itself was excluded from `dropAll()`. Recreated `symbols` get fresh auto-increment IDs anyway → coverage permanently lost without re-ingest. Fix: natural-key PK `(file_path, name, line_start)` — no FK to `symbols.id`. Survives the `symbols` drop-recreate cycle. Trade-off: orphan rows when files are deleted; cleaned by one explicit `DELETE FROM coverage WHERE file_path NOT IN (SELECT path FROM files)` after every ingest. 2. **D7 overlapping symbols.** Original draft: `line_start ≤ stmt_line ≤ line_end` matches every enclosing scope. With nested symbols (class methods inside classes, closures inside functions), one Istanbul statement projects onto 3+ symbols, inflating `total_statements` 2-3×. Fix: innermost-wins via `(line_end - line_start) ASC LIMIT 1`. New `skipped.statements_no_symbol` counter for statements that fall outside every symbol range (top-level expressions, side-effect imports). Nits cleared in the same pass: - D2: drop `file_coverage` rollup table from v1 (aggregateable via GROUP BY on the symbol-level table; doubling sources of truth without a benchmark is premature). Promote to v1.x with a real query. - D11: spec the `total_statements = 0 → coverage_pct IS NULL` edge case + document the cross-file name-collision lossiness in the killer recipe. - Drop `--prune` flag (orphan cleanup is unconditional, no flag needed). - Drop per-row `source` column (single meta key sufficient; one ingest at a time). - Update killer recipe SQL to use the natural-key 3-column join. - Drop made-up "~50 LoC LCOV ingester" estimate and "<50 ms / <1 ms / ~500 KB" performance numbers (no benchmark backed them). - Tracer 1 / 2 / 3 acceptance criteria updated to match the new schema. Plan is now ready for tracer-1 implementation. CodeRabbit pass deferred (rate-limited 57m). --- docs/plans/coverage-ingestion.md | 142 +++++++++++++++++-------------- 1 file changed, 78 insertions(+), 64 deletions(-) diff --git a/docs/plans/coverage-ingestion.md b/docs/plans/coverage-ingestion.md index 7404f65..485d9a8 100644 --- a/docs/plans/coverage-ingestion.md +++ b/docs/plans/coverage-ingestion.md @@ -11,10 +11,15 @@ The killer recipe this unlocks: ```sql -- "What's structurally dead AND untested?" — single query, two evidence axes. -- `calls` is name-keyed (no symbol-id FK, see `db.ts` `CallRow`), so the --- "no callers" predicate is a NOT EXISTS subquery on `callee_name`. +-- "no callers" predicate is name-only / lossy across cross-file collisions +-- (acceptable v1 limitation — see D11). The `coverage` join uses the natural +-- key (file_path, name, line_start) so it survives `--full` reindex (D6). SELECT s.name, s.file_path, c.coverage_pct FROM symbols s -LEFT JOIN coverage c ON c.symbol_id = s.id +LEFT JOIN coverage c + ON c.file_path = s.file_path + AND c.name = s.name + AND c.line_start = s.line_start WHERE s.is_exported = 1 AND NOT EXISTS (SELECT 1 FROM calls WHERE callee_name = s.name) AND COALESCE(c.coverage_pct, 0) = 0 @@ -32,105 +37,114 @@ Today an agent has to run two tools (`codemap` + a coverage reader) and join in ## Sketched layout -### Schema (D1, D2) +### Schema (D1, D6 natural-key fix) ```sql --- New table; symbols-side denormalisation rejected (see D1). +-- Single table; symbols-side denormalisation rejected (D1). Natural-key PK +-- (file_path, name, line_start) — NOT a FK to symbols.id — so rows survive +-- the symbols-table drop-and-recreate cycle on every `--full` reindex (D6). +-- file_coverage rollup deferred to v1.x (D2). CREATE TABLE coverage ( - symbol_id INTEGER NOT NULL REFERENCES symbols(id) ON DELETE CASCADE, - coverage_pct REAL NOT NULL, -- 0.0 – 100.0; statement coverage (see D5) - hit_lines INTEGER NOT NULL, - total_lines INTEGER NOT NULL, - source TEXT NOT NULL, -- ingester id, e.g. "istanbul" / "lcov" (D3) - PRIMARY KEY (symbol_id) + file_path TEXT NOT NULL, + name TEXT NOT NULL, + line_start INTEGER NOT NULL, + coverage_pct REAL, -- NULL when total_statements = 0 (D5 edge) + hit_statements INTEGER NOT NULL, + total_statements INTEGER NOT NULL, + PRIMARY KEY (file_path, name, line_start) ) STRICT, WITHOUT ROWID; --- File-level rollup so "rank files by coverage" doesn't have to GROUP BY symbols. -CREATE TABLE file_coverage ( - file_path TEXT NOT NULL REFERENCES files(path) ON DELETE CASCADE, - coverage_pct REAL NOT NULL, - hit_lines INTEGER NOT NULL, - total_lines INTEGER NOT NULL, - source TEXT NOT NULL, - PRIMARY KEY (file_path) -) STRICT, WITHOUT ROWID; +-- Index that mirrors the typical join shape `symbols.{file_path,name,line_start}`. +CREATE INDEX idx_coverage_file_name ON coverage(file_path, name); -- Meta row: timestamp + source path of the last successful ingest. Lets agents -- check freshness without a separate verb. NULL when no coverage ever ingested. -INSERT INTO meta (key, value) VALUES ('coverage_last_ingested_at', ''); -INSERT INTO meta (key, value) VALUES ('coverage_last_ingested_path', ''); +-- `source` lives here (single ingest at a time), not as a per-row column. +INSERT INTO meta (key, value) VALUES ('coverage_last_ingested_at', ''); +INSERT INTO meta (key, value) VALUES ('coverage_last_ingested_path', ''); INSERT INTO meta (key, value) VALUES ('coverage_last_ingested_source', 'istanbul'); ``` -`ON DELETE CASCADE` on `symbols(id)` and `files(path)` means re-indexing automatically drops the coverage rows whose symbols/files no longer exist. **Stale-coverage handling falls out for free** (D6). +**Why no FK / CASCADE:** `symbols.id` is `INTEGER PRIMARY KEY AUTOINCREMENT`; on `--full` reindex `dropAll()` drops `symbols` and `createTables()` recreates it with fresh IDs. A FK with CASCADE would wipe every coverage row on every full rebuild even if `coverage` itself were excluded from `dropAll()` — see D6 for the full unwind. Natural key sidesteps it. Orphan rows (file deleted from project) get cleaned by an explicit one-statement sweep at the end of every ingest: + +```sql +DELETE FROM coverage WHERE file_path NOT IN (SELECT path FROM files); +``` ### CLI ```text -codemap ingest-coverage [--source istanbul|lcov] [--prune] [--json] +codemap ingest-coverage [--source istanbul|lcov] [--json] ``` -- `` — required. Path to `coverage-final.json` (Istanbul, default), `lcov.info` (LCOV, v1.x), or directory containing `coverage-final.json`. +- `` — required. Path to `coverage-final.json` (Istanbul, default) or directory containing one. LCOV (`lcov.info`) deferred to v1.x (D3). - `--source` — optional. Auto-detected from filename / extension; flag overrides. -- `--prune` — also `DELETE FROM coverage` rows whose `symbol_id` no longer matches a symbol that has 1+ statement in the ingested coverage map (handles "symbol still exists but nothing covers it"). Default off; `coverage_pct = 0` rows are valid evidence. -- `--json` — emit `{ingested: {symbols: N, files: M}, skipped: {unmatched_files: K}, source: "istanbul"}` envelope on stdout. +- `--json` — emit `{ingested: {symbols: N, files: M}, skipped: {unmatched_files: K, statements_no_symbol: S}, pruned_orphans: O, source: "istanbul"}` envelope on stdout. + +No `--prune` flag — orphan cleanup is unconditional after every ingest (one DELETE; cheap; the only time coverage rows for a deleted file would be valid evidence is "this file used to exist" which is git's job, not codemap's). -Decoupled from `codemap` (the index command) on purpose (D4) — coverage runs once per `bun test --coverage` invocation, not once per file edit. +Decoupled from `codemap` (the index command) on purpose (D4) — coverage runs once per test invocation, not once per file edit. ## Decisions -| # | Decision | -| --- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| D1 | **Separate `coverage` table, not columns on `symbols`.** Resolves the open question in [research/fallow.md § 6](../research/fallow.md#6-open-questions). Three reasons: (a) coverage shape evolves independently of structural columns (per-branch, per-function, per-line metrics layered later) — denormalising churns `SCHEMA_VERSION` every time; (b) `symbols` rows exist for every TS / TSX file; coverage rows only for tested files — joining via `LEFT JOIN coverage` keeps NULL semantics explicit; (c) `coverage` survives a `--full` reindex because it lives next to `query_baselines` (intentional `dropAll()` exclusion — see [`db.ts` `query_baselines` comment](../../src/db.ts) for precedent). | -| D2 | **Two tables — symbol-level + file-level rollup.** File-level rollup is a single-table scan instead of `GROUP BY symbols.file_path` over a JOIN. The denormalisation cost is one extra UPSERT per file at ingest time; the read-side win is every "files ranked by coverage" query becomes a primary-key scan on `file_coverage`. Mirrors `dependencies` (composite PK) vs `imports` (raw rows) split. | -| D3 | **Istanbul JSON in v1; LCOV in v1.x; c8 raw V8 traces never.** Istanbul JSON (`coverage-final.json`) is the dominant IR for Node-side TS coverage — `c8`, `nyc`, `vitest --coverage --coverage.reporter=json`, `jest --coverage --coverageReporters=json` all emit it. `bun test --coverage` ships LCOV / text reporters today (verified via `bun test --help`); LCOV ingester is the second tracer once Istanbul lands. Raw V8 traces (`*.cpu.profile` / `coverage-c8/*.json`) are out of scope — they're Fallow's paid moat and require a runtime tracer Codemap doesn't ship. | -| D4 | **One-shot `codemap ingest-coverage `, NOT auto-detected during `codemap` runs.** Reasons: (a) `codemap` is sub-100ms cold-start; auto-probing for `coverage/coverage-final.json` adds a `stat` and grows the surface for "where did codemap look for coverage?"; (b) coverage cadence (once per test run) is decoupled from index cadence (every file edit) — coupling them means stale coverage on every save; (c) explicit verb makes the agent's mental model trivial: "tests ran → `codemap ingest-coverage` → `codemap query`". `codemap watch` does NOT auto-ingest coverage on `coverage-final.json` change (separate concern; revisit if demand materialises). | -| D5 | **Statement coverage only in v1.** Istanbul tracks statement / branch / function / line coverage separately. `coverage_pct` is **statement** coverage (the most stable signal across runners — function coverage misses anonymous closures, branch coverage explodes for switch / ternary). v1.x can add `branch_coverage_pct` / `function_coverage_pct` columns once a real consumer asks. Don't pre-emptively widen. | -| D6 | **Coverage rows survive `--full` reindex.** `coverage` and `file_coverage` join the `query_baselines` precedent — intentionally absent from `dropAll()` so a `--full` rebuild doesn't nuke the user's last ingest. `ON DELETE CASCADE` on `symbols(id)` / `files(path)` handles the "symbol no longer exists" case automatically when those rows reinsert. Re-running `codemap ingest-coverage` is the user's explicit "refresh" verb. | -| D7 | **Symbol mapping by `(file_path, line_start ≤ stmt_line ≤ line_end)`.** Istanbul's `statementMap` is line-keyed; we project per-statement hits onto the enclosing symbol via a plain BETWEEN over `symbols.line_start` / `symbols.line_end`. (No prior table uses this projection — `markers` are line-pinned and `calls` are name-keyed; the projection is novel for this plan.) Symbols whose range covers ≥1 statement get a `coverage` row; symbols whose range covers 0 statements (interface declarations, type aliases) don't — they appear as `NULL` in `LEFT JOIN coverage`. No fuzzy matching, no source-map walking. | -| D8 | **Path normalisation: project-relative, forward-slashed.** Istanbul writes absolute paths; we strip `/` and replace `\\` with `/` to match `files.path`. Files outside the project root land in `skipped.unmatched_files`. Same projection `toProjectRelative()` (in `validate-engine`) already does — reuse the helper instead of rewriting it. | -| D9 | **MCP / HTTP exposure: column in `query` results, NOT a separate `coverage` tool.** The killer recipe (top of this doc) is one SQL query — `query` / `query_recipe` already returns `coverage_pct` as a column when the SELECT asks for it. A standalone `coverage` MCP tool would duplicate the surface; revisit only if a consumer ships a wrapper script that proves the SQL ergonomic gap is real. | -| D10 | **`codemap audit` integration deferred.** Adding `--delta coverage` to `audit` is the natural next step (flag "files where `coverage_pct` dropped >5% vs `--base`") but layered on top of D1–D9. Track as v1.x backlog; ship the ingester + raw schema first. | -| D11 | **Ingester lives in `application/coverage-engine.ts`.** Same engine-vs-CLI split as `impact` / `audit`: pure ingester (`ingestCoverage({db, source, payload}) → IngestResult`) consumed by `cli/cmd-ingest-coverage.ts`. No MCP / HTTP transport in v1 (D9). Engine is unit-testable against fixture Istanbul JSON without spinning up the CLI. | -| D12 | **Schema bump = minor changeset.** Adds two tables (`coverage`, `file_coverage`) + three meta keys; doesn't break any existing query. Per [`.agents/lessons.md`](../../.agents/lessons.md) "changesets bump policy" (verbatim: _reserve minor for schema-breaking changes that force a `.codemap.db` rebuild — matches 0.2.0 precedent: new tables/columns/`SCHEMA_VERSION` bump_), new tables + `SCHEMA_VERSION` bump = minor. The bump triggers `dropAll()` on next `codemap` run; coverage tables are absent on existing installs until first ingest (no migration needed — empty is the correct initial state). Subsequent `SCHEMA_VERSION` bumps preserve coverage data via the `dropAll()` exclusion (D6). | +| # | Decision | +| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| D1 | **Separate `coverage` table, not columns on `symbols`.** Resolves the open question in [research/fallow.md § 6](../research/fallow.md#6-open-questions). Three reasons: (a) coverage shape evolves independently of structural columns (per-branch, per-function, per-line metrics layered later) — denormalising churns `SCHEMA_VERSION` every time; (b) `symbols` rows exist for every TS / TSX file; coverage rows only for tested files — joining via `LEFT JOIN coverage` keeps NULL semantics explicit; (c) lifecycle independence — `symbols` re-creates on every `--full` reindex with fresh auto-increment IDs; the natural-key PK in `coverage` (D6) survives that churn without re-ingest. | +| D2 | **One table in v1; defer `file_coverage` rollup to v1.x.** `SELECT file_path, SUM(hit_statements), SUM(total_statements) FROM coverage GROUP BY file_path` answers "files ranked by coverage" with a single index-scan on `idx_coverage_file_name`. A separate rollup table doubles the source-of-truth surface and adds an UPSERT path; promote only after a real consumer query proves the GROUP BY is the bottleneck (no benchmark today suggests it is). Same "don't pre-emptively widen" discipline as D5. | +| D3 | **Istanbul JSON in v1; LCOV in v1.x; c8 raw V8 traces never.** Istanbul JSON (`coverage-final.json`) is the dominant IR for Node-side TS coverage — `c8`, `nyc`, `vitest --coverage --coverage.reporter=json`, `jest --coverage --coverageReporters=json` all emit it. `bun test --coverage` ships LCOV / text reporters today (verified via `bun test --help`); LCOV ingester is the second tracer once Istanbul lands. Raw V8 traces (`*.cpu.profile` / `coverage-c8/*.json`) are out of scope — they're Fallow's paid moat and require a runtime tracer Codemap doesn't ship. | +| D4 | **One-shot `codemap ingest-coverage `, NOT auto-detected during `codemap` runs.** Reasons: (a) `codemap` is sub-100ms cold-start; auto-probing for `coverage/coverage-final.json` adds a `stat` and grows the surface for "where did codemap look for coverage?"; (b) coverage cadence (once per test run) is decoupled from index cadence (every file edit) — coupling them means stale coverage on every save; (c) explicit verb makes the agent's mental model trivial: "tests ran → `codemap ingest-coverage` → `codemap query`". `codemap watch` does NOT auto-ingest coverage on `coverage-final.json` change (separate concern; revisit if demand materialises). | +| D5 | **Statement coverage only in v1.** Istanbul tracks statement / branch / function / line coverage separately. `coverage_pct` is **statement** coverage (the most stable signal across runners — function coverage misses anonymous closures, branch coverage explodes for switch / ternary). v1.x can add `branch_coverage_pct` / `function_coverage_pct` columns once a real consumer asks. Don't pre-emptively widen. | +| D6 | **Natural-key PK `(file_path, name, line_start)` — no FK to `symbols.id`.** Earlier draft used `symbol_id REFERENCES symbols(id) ON DELETE CASCADE`; CodeRabbit-style self-audit caught that this would wipe every coverage row on every `--full` reindex (because `dropAll()` drops `symbols` → CASCADE fires → recreated `symbols` get fresh auto-increment IDs that don't match the old ones anyway). Natural key sidesteps the entire CASCADE hazard. Trade-off: orphan rows when a file is deleted from the project; cleaned by a single `DELETE FROM coverage WHERE file_path NOT IN (SELECT path FROM files)` at the end of every ingest. `coverage` is excluded from `dropAll()` (joins the `query_baselines` precedent) so the natural-key rows persist across `--full`. Re-ingest is the user's explicit refresh verb only when test results actually changed. | +| D7 | **Symbol mapping: innermost-wins line-range projection.** Istanbul's `statementMap` is line-keyed; we project per-statement hits onto the **innermost** enclosing symbol — `line_start ≤ stmt_line ≤ line_end` ordered by `(line_end - line_start) ASC LIMIT 1`. Innermost-wins is required because symbols nest (class methods inside classes, closures inside functions); attributing one statement to all enclosing scopes would inflate `total_statements` 2-3× on real codebases. Symbols whose range covers 0 statements (interfaces, type aliases) get no coverage row → `NULL` in `LEFT JOIN coverage`. (No prior table uses this projection — `markers` are line-pinned, `calls` are name-keyed; the projection is novel for this plan.) Statements that fall outside every symbol range (top-level expression statements, side-effect imports) increment `skipped.statements_no_symbol` for observability — no row written. | +| D8 | **Path normalisation: project-relative, forward-slashed.** Istanbul writes absolute paths; we strip `/` and replace `\\` with `/` to match `files.path`. Files outside the project root land in `skipped.unmatched_files`. Same projection `toProjectRelative()` (in `validate-engine`) already does — reuse the helper instead of rewriting it. | +| D9 | **MCP / HTTP exposure: column in `query` results, NOT a separate `coverage` tool.** The killer recipe (top of this doc) is one SQL query — `query` / `query_recipe` already returns `coverage_pct` as a column when the SELECT asks for it. A standalone `coverage` MCP tool would duplicate the surface; revisit only if a consumer ships a wrapper script that proves the SQL ergonomic gap is real. | +| D10 | **`codemap audit` integration deferred.** Adding `--delta coverage` to `audit` is the natural next step (flag "files where `coverage_pct` dropped >5% vs `--base`") but layered on top of D1–D9. Track as v1.x backlog; ship the ingester + raw schema first. | +| D11 | **Ingester lives in `application/coverage-engine.ts`.** Same engine-vs-CLI split as `impact` / `audit`: pure ingester (`ingestCoverage({db, source, payload}) → IngestResult`) consumed by `cli/cmd-ingest-coverage.ts`. No MCP / HTTP transport in v1 (D9). Engine is unit-testable against fixture Istanbul JSON without spinning up the CLI. Edge cases the engine guards: `total_statements = 0 → coverage_pct = NULL` (not 0; "untested" and "no testable code" are different); name-collision tolerance: same `(file_path, name, line_start)` is unique by construction (two functions can't start on the same line), so the natural-key PK never collides — but cross-file name collisions (`init` in `a.ts` and `b.ts`) mean the killer recipe's `callee_name = s.name` predicate is name-only / lossy in v1. Documented in the recipe comment, not fixed in v1 (would require adding `caller_file` joins). | +| D12 | **Schema bump = minor changeset.** Adds one table (`coverage`) + three meta keys; doesn't break any existing query. Per [`.agents/lessons.md`](../../.agents/lessons.md) "changesets bump policy" (verbatim: _reserve minor for schema-breaking changes that force a `.codemap.db` rebuild — matches 0.2.0 precedent: new tables/columns/`SCHEMA_VERSION` bump_), new tables + `SCHEMA_VERSION` bump = minor. The bump triggers `dropAll()` on next `codemap` run; the `coverage` table is absent on existing installs until first ingest (no migration needed — empty is the correct initial state). Subsequent `SCHEMA_VERSION` bumps preserve coverage data via the `dropAll()` exclusion (D6). | ## Tracer-bullet plan Per [`tracer-bullets.mdc`](../../.cursor/rules/tracer-bullets.mdc) — vertical slices, each shippable on its own. -| # | Tracer | Acceptance | -| --- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | -| 1 | **Schema + bump** in `src/db.ts`. Add `coverage` + `file_coverage` table DDL; bump `SCHEMA_VERSION`; add both tables to the `query_baselines`-style "preserve across `--full`" exclusion in `dropAll()`. New `meta` keys (`coverage_last_ingested_at` / `_path` / `_source`) need no DDL — `meta` is `(key, value)` already. Unit test (`src/db.test.ts`) inserts + reads back one row of each shape; verifies CASCADE on `DELETE FROM symbols`. | Tables exist; CASCADE works; `--full` reindex preserves coverage rows. | -| 2 | **Istanbul ingester engine** in `src/application/coverage-engine.ts` (new). `ingestCoverage({db, payload, source: "istanbul", projectRoot}) → {ingested, skipped, source}`. Pure: parses the Istanbul JSON shape (`{ [absPath]: {statementMap, s, fnMap, f, branchMap, b, path} }`), maps statement coverage (`s` counter) to symbols via the line-range projection (D7), upserts `coverage` and `file_coverage`, writes meta keys. Reuses `toProjectRelative` from `validate-engine` (D8). Unit tests cover: fresh ingest, re-ingest (UPSERT idempotence), unmatched file (skipped), symbol with 0 statements (NULL row), absolute → relative path normalisation, Windows-path handling. | Pure engine; deterministic upsert; no FS / process side effects beyond `db.run`. | -| 3 | **CLI verb** `cli/cmd-ingest-coverage.ts` — parses `` + `--source` + `--prune` + `--json`, reads the file (`Bun.file` on Bun, `readFile` on Node — same split as `config.ts`), dispatches to `coverage-engine`, renders the result. `main.ts` dispatcher gains the verb between existing entries. Help text in `bootstrap.ts` lists the new command. Unit tests cover: file-not-found, malformed JSON, source mismatch (`--source lcov` on a `coverage-final.json`), `--json` envelope shape. | `bun src/index.ts ingest-coverage fixtures/coverage/coverage-final.json` writes rows; `--help` lists the verb. | -| 4 | **Fixture coverage data** — `fixtures/minimal/coverage/coverage-final.json` (small Istanbul shape covering the existing fixture symbols partially: e.g. `usePermissions` 100%, `legacyClient` 0%, `now` 50%). Golden query under `fixtures/golden/minimal/coverage-deprecated.json` exercises the killer recipe ("`@deprecated` symbols with `coverage_pct = 0`"). Adds the fixture row to `fixtures/minimal/README.md` "What's exercised" table. The pre-enriched `legacyClient` / `now` / `epochMs` deprecated symbols (PR #55) directly test the join axis — no new fixture symbols needed beyond the coverage JSON itself. | Golden recipe passes; documents how a real consumer wires `bun test --coverage` into the workflow. | -| 5 | **Doc + agent rule + skill + changeset + plan deletion** — `docs/architecture.md` § Persistence wiring (new tables, ingester engine, CLI verb), `docs/glossary.md` (`coverage_pct`, `file_coverage`, "Istanbul JSON", "static coverage ingestion"), `.agents/rules/codemap.md` + `templates/agents/rules/codemap.md` (Rule 10 lockstep — new trigger pattern: "What's untested AND structurally dead?", new SQL example), skill SKILL.md `coverage` table row, README.md "What's exercised" + Use section. Minor changeset noting the `SCHEMA_VERSION` bump + the explicit re-ingest needed after upgrade. Plan deleted per `docs/README.md` Rule 3. | All docs consistent; agents see the new tables on next `codemap agents init`. | +| # | Tracer | Acceptance | +| --- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| 1 | **Schema + bump** in `src/db.ts`. Add the `coverage` table DDL (natural-key PK per D6 — no FK to `symbols.id`); bump `SCHEMA_VERSION`; add `coverage` to the `query_baselines`-style "preserve across `--full`" exclusion in `dropAll()`. Add `idx_coverage_file_name`. New `meta` keys (`coverage_last_ingested_at` / `_path` / `_source`) need no DDL — `meta` is `(key, value)` already. Unit test (`src/db.test.ts`) inserts + reads back one row; verifies the table survives a `dropAll()` + `createSchema()` round-trip; verifies the orphan-cleanup DELETE removes rows whose `file_path` is no longer in `files`. | Table exists; survives `--full`; orphan sweep works. | +| 2 | **Istanbul ingester engine** in `src/application/coverage-engine.ts` (new). `ingestCoverage({db, payload, projectRoot}) → {ingested, skipped, pruned_orphans, source}`. Pure: parses the Istanbul JSON shape (`{ [absPath]: {statementMap, s, fnMap, f, branchMap, b, path} }`); maps each statement to the **innermost** enclosing symbol via the SQL `(line_end - line_start) ASC LIMIT 1` projection (D7); upserts `coverage` keyed on `(file_path, name, line_start)`; writes the three meta keys; runs the orphan-cleanup DELETE. Computes `coverage_pct = total_statements > 0 ? hit / total * 100 : NULL`. Reuses `toProjectRelative` from `validate-engine` (D8). Unit tests cover: fresh ingest; re-ingest (UPSERT idempotence); unmatched file → `skipped.unmatched_files`; statement outside every symbol → `skipped.statements_no_symbol`; symbol with 0 statements → no row; nested closure → innermost wins; `total_statements = 0` → `coverage_pct IS NULL`; orphan cleanup after a file deletion. | Pure engine; deterministic upsert; no FS / process side effects beyond `db.run`. | +| 3 | **CLI verb** `cli/cmd-ingest-coverage.ts` — parses `` + `--source` (auto-detect default) + `--json`, reads the file (`Bun.file` on Bun, `readFile` on Node — same split as `config.ts`), dispatches to `coverage-engine`, renders the result. `main.ts` dispatcher gains the verb between existing entries. Help text in `bootstrap.ts` lists the new command. Unit tests cover: file-not-found, malformed JSON, source-mismatch error (`--source lcov` reserved for v1.x → v1 returns "lcov ingester not yet implemented"), `--json` envelope shape (including `pruned_orphans`). | `bun src/index.ts ingest-coverage fixtures/coverage/coverage-final.json` writes rows; `--help` lists the verb. | +| 4 | **Fixture coverage data** — `fixtures/minimal/coverage/coverage-final.json` (small Istanbul shape covering the existing fixture symbols partially: e.g. `usePermissions` 100%, `legacyClient` 0%, `now` 50%). Golden query under `fixtures/golden/minimal/coverage-deprecated.json` exercises the killer recipe ("`@deprecated` symbols with `coverage_pct = 0`"). Adds the fixture row to `fixtures/minimal/README.md` "What's exercised" table. The pre-enriched `legacyClient` / `now` / `epochMs` deprecated symbols (PR #55) directly test the join axis — no new fixture symbols needed beyond the coverage JSON itself. | Golden recipe passes; documents how a real consumer wires `bun test --coverage` into the workflow. | +| 5 | **Doc + agent rule + skill + changeset + plan deletion** — `docs/architecture.md` § Persistence wiring (new tables, ingester engine, CLI verb), `docs/glossary.md` (`coverage_pct`, `file_coverage`, "Istanbul JSON", "static coverage ingestion"), `.agents/rules/codemap.md` + `templates/agents/rules/codemap.md` (Rule 10 lockstep — new trigger pattern: "What's untested AND structurally dead?", new SQL example), skill SKILL.md `coverage` table row, README.md "What's exercised" + Use section. Minor changeset noting the `SCHEMA_VERSION` bump + the explicit re-ingest needed after upgrade. Plan deleted per `docs/README.md` Rule 3. | All docs consistent; agents see the new tables on next `codemap agents init`. | ## Performance considerations -- **Ingest cost** — one `JSON.parse` + linear scan of statement maps. A 1000-file Istanbul payload is ~100 KB; parse + ingest in <50 ms (read benchmark: `query_baselines` `INSERT` of comparable shape benches at ~1 µs / row). -- **Read cost** — `coverage` is `WITHOUT ROWID` keyed on `symbol_id`; every join is a primary-key lookup. `LEFT JOIN coverage ON c.symbol_id = s.id` adds <1 ms to recipe queries even on 10k-symbol corpora. -- **Storage** — two `INTEGER` + one `REAL` + one short `TEXT` per symbol; sub-50 bytes / row. 10k symbols = ~500 KB on disk. Negligible vs the index size (~MBs). +- **Ingest cost** — one `JSON.parse` + linear scan of statement maps + one symbol-projection SQL per statement. Per-statement projection is the hot path; backed by `idx_symbols_file` it's an index-bounded lookup. No published benchmark yet — call out as a measurement target during tracer 2. +- **Read cost** — `coverage` is `WITHOUT ROWID` on `(file_path, name, line_start)`. The killer recipe's `LEFT JOIN coverage ON file_path AND name AND line_start` is a 3-column composite-PK lookup per row; `idx_coverage_file_name` covers the prefix. +- **Storage** — three INTEGER + one REAL + two short TEXT per row; small. Real disk impact dominated by symbol count, not coverage shape. - **No background worker.** Ingest is single-pass; reindex is unaffected. ## Alternatives considered -| Candidate | Why not | -| -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| **Add `coverage_pct REAL` + `is_runtime_hot INTEGER` to `symbols` directly** | Per D1: shape-coupling, NULL ambiguity, schema-bump amplification. The original sketch ([research/fallow.md § C.11](../research/fallow.md#tier-c--ship-eventually-months-high-payoff-large-surface)) listed this — D1 supersedes. | -| **Auto-detect `coverage/coverage-final.json` during `codemap` runs** | Per D4: cadence mismatch + auto-probe surface. The auto-detect path is also harder to reason about ("why does this index include coverage on machine A but not B?"); explicit verb is unambiguous. | -| **Ship LCOV ingester in v1 alongside Istanbul** | Doubles the test surface for marginal value — every modern coverage tool emits Istanbul JSON natively; LCOV is the legacy path. Defer to v1.x; add `--source lcov` once a consumer asks. | -| **Embed runtime coverage tracer (V8 / Istanbul beacons)** | Out of scope per [research/fallow.md § D.16](../research/fallow.md#defer--skip) and [roadmap.md § Non-goals](../roadmap.md#non-goals-v1). This plan reads artifacts; it does not produce them. | -| **`coverage` rows keyed by `(file_path, line_start)` instead of `symbol_id`** | Decouples coverage from symbol identity — robust to symbol renames between ingest and reindex. Rejected because the killer recipe is "join coverage to symbols," and a composite key forces every join to repeat `(file_path, line_start)` instead of `symbol_id`. CASCADE on `symbols(id)` handles renames. | -| **Separate MCP `coverage` tool returning `{symbol, pct, hit, total}` envelopes** | Per D9: the column-in-`query`-results path is composable with every existing recipe + ad-hoc SQL; a standalone tool would force a parallel surface for one column. Revisit if SQL composition proves too verbose for agents. | -| **Inline coverage into `codemap audit` v1 (`--delta coverage`)** | Per D10: the plan stays small. Audit-side delta is a clean follow-up once the raw schema lands. | -| **Persist coverage in a sibling JSON file (`/coverage.json`)** | Forces every consumer to re-implement the join; loses SQL composability. The whole point of Codemap is "it's a SQL index" — keep coverage in the same DB. | +| Candidate | Why not | +| -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Add `coverage_pct REAL` + `is_runtime_hot INTEGER` to `symbols` directly** | Per D1: shape-coupling, NULL ambiguity, schema-bump amplification. The original sketch ([research/fallow.md § C.11](../research/fallow.md#tier-c--ship-eventually-months-high-payoff-large-surface)) listed this — D1 supersedes. | +| **Auto-detect `coverage/coverage-final.json` during `codemap` runs** | Per D4: cadence mismatch + auto-probe surface. The auto-detect path is also harder to reason about ("why does this index include coverage on machine A but not B?"); explicit verb is unambiguous. | +| **Ship LCOV ingester in v1 alongside Istanbul** | Doubles the test surface for marginal value — every modern coverage tool emits Istanbul JSON natively; LCOV is the legacy path. Defer to v1.x; add `--source lcov` once a consumer asks. | +| **Embed runtime coverage tracer (V8 / Istanbul beacons)** | Out of scope per [research/fallow.md § D.16](../research/fallow.md#defer--skip) and [roadmap.md § Non-goals](../roadmap.md#non-goals-v1). This plan reads artifacts; it does not produce them. | +| **`coverage` rows keyed on `symbol_id` with `ON DELETE CASCADE`** | Original draft. Rejected per D6: `dropAll()` drops `symbols` on every `--full`, CASCADE wipes coverage, recreated `symbols` get fresh auto-increment IDs anyway → coverage permanently lost without re-ingest. The natural-key approach (D6) sidesteps the entire FK/CASCADE hazard at the cost of one explicit orphan-cleanup DELETE per ingest. | +| **Separate `file_coverage` rollup table in v1** | Per D2: aggregateable in one indexed `GROUP BY` on the symbol-level table; doubling the source-of-truth surface without a benchmark is premature. Promote in v1.x once a real query proves the cost. | +| **Separate MCP `coverage` tool returning `{symbol, pct, hit, total}` envelopes** | Per D9: the column-in-`query`-results path is composable with every existing recipe + ad-hoc SQL; a standalone tool would force a parallel surface for one column. Revisit if SQL composition proves too verbose for agents. | +| **Inline coverage into `codemap audit` v1 (`--delta coverage`)** | Per D10: the plan stays small. Audit-side delta is a clean follow-up once the raw schema lands. | +| **Persist coverage in a sibling JSON file (`/coverage.json`)** | Forces every consumer to re-implement the join; loses SQL composability. The whole point of Codemap is "it's a SQL index" — keep coverage in the same DB. | ## Out of scope - **Branch / function / line coverage breakdowns** — D5; v1.x once a consumer asks with a concrete query. +- **`file_coverage` rollup table** — D2; v1.x with a benchmark. +- **LCOV ingester** — D3; v1.x once Istanbul lands. +- **Cross-file callee disambiguation** — D11; the killer recipe's `callee_name = s.name` is name-only / lossy in v1. v1.x can add a `caller_file_path` column to `calls` if a real consumer needs the precision. - **Coverage diff against baseline** — `--save-baseline` / `--baseline` already covers arbitrary query result snapshots; coverage queries inherit it for free. A first-class `coverage_diff` would duplicate. -- **Coverage trend over time** — adjacent to telemetry; not in v1. Consumers can `--save-baseline coverage-snapshot-2026-05-04` periodically. +- **Coverage trend over time** — adjacent to telemetry; not in v1. Consumers can `--save-baseline coverage-snapshot-` periodically. - **CI verdict / threshold logic** — same condition as `codemap audit verdict`: defer until a consumer ships a `jq` script that proves the threshold shape. -- **Auto-running tests** — Codemap reads coverage artifacts; it doesn't invoke `bun test --coverage`. Test orchestration is the user's CI. -- **Source-map-aware coverage** — Istanbul's `statementMap` is post-transform; coverage rows reflect the compiled file's structure. Source-map walking to original `.ts` lines is deferred (most projects with TS test runners already emit Istanbul against the pre-compile source via `vitest` / `bun test` instrumentation). +- **`--delta coverage` in `codemap audit`** — D10; layered on top of the v1 schema. +- **Auto-running tests** — Codemap reads coverage artifacts; it doesn't invoke any test runner. Test orchestration is the user's CI. +- **Source-map-aware coverage** — Istanbul's `statementMap` is post-transform; coverage rows reflect the compiled file's structure. Source-map walking to original `.ts` lines is deferred (most TS runners — `vitest`, `jest` — instrument the pre-compile source already). +- **`codemap watch` auto-reingest** — D4 explicitly excludes; revisit only if a consumer writes a watcher that re-runs `bun test --coverage` automatically. From 86b7f697e7f9b403bc46eba8de84925c5c8563b8 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Mon, 4 May 2026 10:02:32 +0300 Subject: [PATCH 4/6] docs(plan): tighten Bun-native API references (file read + perf note) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan correctly inherits the established Node vs Bun runtime split, but the single tracer-3 reference understated it. Now: - Tracer 3 cites `packaging.md § Node vs Bun` as the canonical pattern source instead of pointing at config.ts in passing. - Performance section calls out the actual lever — `Bun.file(path).json()` uses Bun's native JSON parser, materially faster than V8 `JSON.parse` on multi-MB Istanbul payloads (real coverage files for medium codebases routinely hit several MB). No new Bun-native API surfaces are added — the feature doesn't need globbing, file writes, spawn, or hashing beyond what the existing engines already use through their abstractions. --- docs/plans/coverage-ingestion.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/plans/coverage-ingestion.md b/docs/plans/coverage-ingestion.md index 485d9a8..5b2c29d 100644 --- a/docs/plans/coverage-ingestion.md +++ b/docs/plans/coverage-ingestion.md @@ -110,13 +110,13 @@ Per [`tracer-bullets.mdc`](../../.cursor/rules/tracer-bullets.mdc) — vertical | --- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | | 1 | **Schema + bump** in `src/db.ts`. Add the `coverage` table DDL (natural-key PK per D6 — no FK to `symbols.id`); bump `SCHEMA_VERSION`; add `coverage` to the `query_baselines`-style "preserve across `--full`" exclusion in `dropAll()`. Add `idx_coverage_file_name`. New `meta` keys (`coverage_last_ingested_at` / `_path` / `_source`) need no DDL — `meta` is `(key, value)` already. Unit test (`src/db.test.ts`) inserts + reads back one row; verifies the table survives a `dropAll()` + `createSchema()` round-trip; verifies the orphan-cleanup DELETE removes rows whose `file_path` is no longer in `files`. | Table exists; survives `--full`; orphan sweep works. | | 2 | **Istanbul ingester engine** in `src/application/coverage-engine.ts` (new). `ingestCoverage({db, payload, projectRoot}) → {ingested, skipped, pruned_orphans, source}`. Pure: parses the Istanbul JSON shape (`{ [absPath]: {statementMap, s, fnMap, f, branchMap, b, path} }`); maps each statement to the **innermost** enclosing symbol via the SQL `(line_end - line_start) ASC LIMIT 1` projection (D7); upserts `coverage` keyed on `(file_path, name, line_start)`; writes the three meta keys; runs the orphan-cleanup DELETE. Computes `coverage_pct = total_statements > 0 ? hit / total * 100 : NULL`. Reuses `toProjectRelative` from `validate-engine` (D8). Unit tests cover: fresh ingest; re-ingest (UPSERT idempotence); unmatched file → `skipped.unmatched_files`; statement outside every symbol → `skipped.statements_no_symbol`; symbol with 0 statements → no row; nested closure → innermost wins; `total_statements = 0` → `coverage_pct IS NULL`; orphan cleanup after a file deletion. | Pure engine; deterministic upsert; no FS / process side effects beyond `db.run`. | -| 3 | **CLI verb** `cli/cmd-ingest-coverage.ts` — parses `` + `--source` (auto-detect default) + `--json`, reads the file (`Bun.file` on Bun, `readFile` on Node — same split as `config.ts`), dispatches to `coverage-engine`, renders the result. `main.ts` dispatcher gains the verb between existing entries. Help text in `bootstrap.ts` lists the new command. Unit tests cover: file-not-found, malformed JSON, source-mismatch error (`--source lcov` reserved for v1.x → v1 returns "lcov ingester not yet implemented"), `--json` envelope shape (including `pruned_orphans`). | `bun src/index.ts ingest-coverage fixtures/coverage/coverage-final.json` writes rows; `--help` lists the verb. | +| 3 | **CLI verb** `cli/cmd-ingest-coverage.ts` — parses `` + `--source` (auto-detect default) + `--json`, reads the JSON via the established runtime split (`Bun.file(path).json()` on Bun for the native-parser perf win on large coverage payloads; `readFile + JSON.parse` on Node) — see [`packaging.md § Node vs Bun`](../packaging.md#node-vs-bun) for the canonical pattern, mirrors `config.ts`. Dispatches to `coverage-engine`, renders the result. `main.ts` dispatcher gains the verb between existing entries. Help text in `bootstrap.ts` lists the new command. Unit tests cover: file-not-found, malformed JSON, source-mismatch error (`--source lcov` reserved for v1.x → v1 returns "lcov ingester not yet implemented"), `--json` envelope shape (including `pruned_orphans`). | `bun src/index.ts ingest-coverage fixtures/coverage/coverage-final.json` writes rows; `--help` lists the verb. | | 4 | **Fixture coverage data** — `fixtures/minimal/coverage/coverage-final.json` (small Istanbul shape covering the existing fixture symbols partially: e.g. `usePermissions` 100%, `legacyClient` 0%, `now` 50%). Golden query under `fixtures/golden/minimal/coverage-deprecated.json` exercises the killer recipe ("`@deprecated` symbols with `coverage_pct = 0`"). Adds the fixture row to `fixtures/minimal/README.md` "What's exercised" table. The pre-enriched `legacyClient` / `now` / `epochMs` deprecated symbols (PR #55) directly test the join axis — no new fixture symbols needed beyond the coverage JSON itself. | Golden recipe passes; documents how a real consumer wires `bun test --coverage` into the workflow. | | 5 | **Doc + agent rule + skill + changeset + plan deletion** — `docs/architecture.md` § Persistence wiring (new tables, ingester engine, CLI verb), `docs/glossary.md` (`coverage_pct`, `file_coverage`, "Istanbul JSON", "static coverage ingestion"), `.agents/rules/codemap.md` + `templates/agents/rules/codemap.md` (Rule 10 lockstep — new trigger pattern: "What's untested AND structurally dead?", new SQL example), skill SKILL.md `coverage` table row, README.md "What's exercised" + Use section. Minor changeset noting the `SCHEMA_VERSION` bump + the explicit re-ingest needed after upgrade. Plan deleted per `docs/README.md` Rule 3. | All docs consistent; agents see the new tables on next `codemap agents init`. | ## Performance considerations -- **Ingest cost** — one `JSON.parse` + linear scan of statement maps + one symbol-projection SQL per statement. Per-statement projection is the hot path; backed by `idx_symbols_file` it's an index-bounded lookup. No published benchmark yet — call out as a measurement target during tracer 2. +- **Ingest cost** — one JSON parse + linear scan of statement maps + one symbol-projection SQL per statement. Per-statement projection is the hot path; backed by `idx_symbols_file` it's an index-bounded lookup. On Bun, the JSON parse uses `Bun.file(path).json()` (native parser, materially faster than V8 `JSON.parse` on multi-MB Istanbul payloads); on Node it's `readFile + JSON.parse`. No published benchmark yet — measurement target during tracer 2. - **Read cost** — `coverage` is `WITHOUT ROWID` on `(file_path, name, line_start)`. The killer recipe's `LEFT JOIN coverage ON file_path AND name AND line_start` is a 3-column composite-PK lookup per row; `idx_coverage_file_name` covers the prefix. - **Storage** — three INTEGER + one REAL + two short TEXT per row; small. Real disk impact dominated by symbol count, not coverage shape. - **No background worker.** Ingest is single-pass; reindex is unaffected. From 9e52ffa2e6270e666983dcab4d9bae8293242fd4 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Mon, 4 May 2026 10:40:28 +0300 Subject: [PATCH 5/6] =?UTF-8?q?docs(plan):=20v3=20=E2=80=94=20ship=20LCOV?= =?UTF-8?q?=20in=20v1=20+=20drop=20--source=20flag=20+=20bundle=20killer?= =?UTF-8?q?=20recipe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "fully capable, no half-way APIs" principle reshapes three things: 1. **LCOV ingester ships in v1** alongside Istanbul. Original draft deferred LCOV to v1.x, which would exclude `bun test --coverage` users — i.e. codemap's own primary runtime. That's the textbook half-baked surface the principle bans. Two parser front-ends share one `upsertCoverageRows` core; LCOV is regex tokenizing over `SF:` / `DA:` / `end_of_record`. Tracer 2 splits into 2a (shared core + Istanbul parser) and 2b (LCOV parser), both writing identical normalised CoverageRow[] into the same upsert path. 2. **`--source istanbul|lcov` flag dropped.** Auto-detection from extension (`.json` → istanbul, `.info` → lcov, directory → probe both, error on ambiguous) is unambiguous; a flag for "tell codemap what it can already see" is API noise. Misnamed files can be renamed (one-liner) cheaper than codemap can grow a flag. 3. **Killer recipe ships as bundled `untested-and-dead.{sql,md}`** in `templates/recipes/`. Per the recipes-as-content registry (PR #37), the high-value queries become first-class agent surface. A buried doc snippet would be invisible to agents at session start; the bundled recipe shows up in `--recipes-json` and gets a `codemap query --recipe untested-and-dead` direct invocation. Tracer 4 also fans out: Istanbul + LCOV fixtures cover the same partial coverage shape; three golden recipes (`coverage-istanbul.json`, `coverage-lcov.json`, `untested-and-dead.json`) prove format equivalence. Out-of-scope, alternatives, performance section, title, and goal statement all updated to match. --- docs/plans/coverage-ingestion.md | 68 ++++++++++++++++---------------- 1 file changed, 35 insertions(+), 33 deletions(-) diff --git a/docs/plans/coverage-ingestion.md b/docs/plans/coverage-ingestion.md index 5b2c29d..d484c8b 100644 --- a/docs/plans/coverage-ingestion.md +++ b/docs/plans/coverage-ingestion.md @@ -1,10 +1,10 @@ -# Static coverage ingestion (`coverage-final.json` → `coverage` table) +# Static coverage ingestion (Istanbul JSON + LCOV → `coverage` table) > **Status:** in design (no code) · **Backlog:** [research/fallow.md § C.11](../research/fallow.md#tier-c--ship-eventually-months-high-payoff-large-surface). Delete this file when shipped (per [`docs/README.md` Rule 3](../README.md)). ## Goal -Ingest Istanbul-format coverage JSON (`coverage-final.json`) into the codemap index so structural queries can compose coverage filters in pure SQL — without bolting Codemap to a runtime tracer or paid coverage stack. +Ingest static coverage artifacts — Istanbul JSON (`coverage-final.json`) and LCOV (`lcov.info`) — into the codemap index so structural queries can compose coverage filters in pure SQL, without bolting Codemap to a runtime tracer or paid coverage stack. Both formats land in v1 so every coverage tool (vitest / jest / c8 / nyc / `bun test --coverage`) is a first-class consumer on day one. The killer recipe this unlocks: @@ -31,7 +31,7 @@ Today an agent has to run two tools (`codemap` + a coverage reader) and join in ## Why - **Codemap is structural-only today.** Every "is this dead?" query has a structural false-positive rate (the §0 fallow audit found an 8-file widget pack with non-zero structural fan-in but zero runtime usage). Coverage is the **complementary evidence axis** — `structural fan-in = 0` AND `runtime coverage = 0` is the high-confidence "dead" predicate. -- **Static ingestion is free.** Istanbul-format JSON is the universal output format for `c8`, `nyc`, `vitest --coverage`, `jest --coverage`, `bun test --coverage`. We're not building a coverage tracer — we're reading the artifact those tools already produce. +- **Static ingestion is free.** Istanbul JSON (`vitest`, `jest`, `c8 --reporter=json`, `nyc`) and LCOV (`bun test --coverage`, `c8 --reporter=lcov`, every legacy stack) cover the entire test-runner ecosystem. We're not building a coverage tracer — we're reading the artifacts those tools already produce. - **Fallow's runtime intelligence is paid.** Static coverage ingestion gets ~80% of the agent value (the "is X dead?" predicate above) without entering Fallow's V8/production-beacon territory (explicit non-goal per [research/fallow.md § D.16](../research/fallow.md#defer--skip)). - **Composes with `codemap impact`.** `impact --direction up --depth 0` returns callers; joining `coverage` on the result tells the agent "this symbol has 12 callers but only 2 of them are hit by tests" — refactor risk in one query. @@ -62,7 +62,7 @@ CREATE INDEX idx_coverage_file_name ON coverage(file_path, name); -- `source` lives here (single ingest at a time), not as a per-row column. INSERT INTO meta (key, value) VALUES ('coverage_last_ingested_at', ''); INSERT INTO meta (key, value) VALUES ('coverage_last_ingested_path', ''); -INSERT INTO meta (key, value) VALUES ('coverage_last_ingested_source', 'istanbul'); +INSERT INTO meta (key, value) VALUES ('coverage_last_ingested_format', 'istanbul'); -- or 'lcov' ``` **Why no FK / CASCADE:** `symbols.id` is `INTEGER PRIMARY KEY AUTOINCREMENT`; on `--full` reindex `dropAll()` drops `symbols` and `createTables()` recreates it with fresh IDs. A FK with CASCADE would wipe every coverage row on every full rebuild even if `coverage` itself were excluded from `dropAll()` — see D6 for the full unwind. Natural key sidesteps it. Orphan rows (file deleted from project) get cleaned by an explicit one-statement sweep at the end of every ingest: @@ -74,49 +74,51 @@ DELETE FROM coverage WHERE file_path NOT IN (SELECT path FROM files); ### CLI ```text -codemap ingest-coverage [--source istanbul|lcov] [--json] +codemap ingest-coverage [--json] ``` -- `` — required. Path to `coverage-final.json` (Istanbul, default) or directory containing one. LCOV (`lcov.info`) deferred to v1.x (D3). -- `--source` — optional. Auto-detected from filename / extension; flag overrides. -- `--json` — emit `{ingested: {symbols: N, files: M}, skipped: {unmatched_files: K, statements_no_symbol: S}, pruned_orphans: O, source: "istanbul"}` envelope on stdout. +- `` — required. One of: `coverage-final.json` (Istanbul), `lcov.info` (LCOV), or a directory — engine probes the directory for either filename, errors if both or neither are present (no precedence guessing — explicit is better than implicit). +- `--json` — emit `{ingested: {symbols: N, files: M}, skipped: {unmatched_files: K, statements_no_symbol: S}, pruned_orphans: O, format: "istanbul"|"lcov"}` envelope on stdout. -No `--prune` flag — orphan cleanup is unconditional after every ingest (one DELETE; cheap; the only time coverage rows for a deleted file would be valid evidence is "this file used to exist" which is git's job, not codemap's). +**No `--source` flag** — format is auto-detected from extension (`.json` → istanbul, `.info` → lcov). Adding a flag for a format the engine can detect is API noise; if a user ever has a misnamed file, they can rename it (one-liner) cheaper than codemap can grow a flag for it. + +**No `--prune` flag** — orphan cleanup is unconditional after every ingest (one DELETE; cheap; the only time coverage rows for a deleted file would be valid evidence is "this file used to exist" which is git's job, not codemap's). Decoupled from `codemap` (the index command) on purpose (D4) — coverage runs once per test invocation, not once per file edit. ## Decisions -| # | Decision | -| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| D1 | **Separate `coverage` table, not columns on `symbols`.** Resolves the open question in [research/fallow.md § 6](../research/fallow.md#6-open-questions). Three reasons: (a) coverage shape evolves independently of structural columns (per-branch, per-function, per-line metrics layered later) — denormalising churns `SCHEMA_VERSION` every time; (b) `symbols` rows exist for every TS / TSX file; coverage rows only for tested files — joining via `LEFT JOIN coverage` keeps NULL semantics explicit; (c) lifecycle independence — `symbols` re-creates on every `--full` reindex with fresh auto-increment IDs; the natural-key PK in `coverage` (D6) survives that churn without re-ingest. | -| D2 | **One table in v1; defer `file_coverage` rollup to v1.x.** `SELECT file_path, SUM(hit_statements), SUM(total_statements) FROM coverage GROUP BY file_path` answers "files ranked by coverage" with a single index-scan on `idx_coverage_file_name`. A separate rollup table doubles the source-of-truth surface and adds an UPSERT path; promote only after a real consumer query proves the GROUP BY is the bottleneck (no benchmark today suggests it is). Same "don't pre-emptively widen" discipline as D5. | -| D3 | **Istanbul JSON in v1; LCOV in v1.x; c8 raw V8 traces never.** Istanbul JSON (`coverage-final.json`) is the dominant IR for Node-side TS coverage — `c8`, `nyc`, `vitest --coverage --coverage.reporter=json`, `jest --coverage --coverageReporters=json` all emit it. `bun test --coverage` ships LCOV / text reporters today (verified via `bun test --help`); LCOV ingester is the second tracer once Istanbul lands. Raw V8 traces (`*.cpu.profile` / `coverage-c8/*.json`) are out of scope — they're Fallow's paid moat and require a runtime tracer Codemap doesn't ship. | -| D4 | **One-shot `codemap ingest-coverage `, NOT auto-detected during `codemap` runs.** Reasons: (a) `codemap` is sub-100ms cold-start; auto-probing for `coverage/coverage-final.json` adds a `stat` and grows the surface for "where did codemap look for coverage?"; (b) coverage cadence (once per test run) is decoupled from index cadence (every file edit) — coupling them means stale coverage on every save; (c) explicit verb makes the agent's mental model trivial: "tests ran → `codemap ingest-coverage` → `codemap query`". `codemap watch` does NOT auto-ingest coverage on `coverage-final.json` change (separate concern; revisit if demand materialises). | -| D5 | **Statement coverage only in v1.** Istanbul tracks statement / branch / function / line coverage separately. `coverage_pct` is **statement** coverage (the most stable signal across runners — function coverage misses anonymous closures, branch coverage explodes for switch / ternary). v1.x can add `branch_coverage_pct` / `function_coverage_pct` columns once a real consumer asks. Don't pre-emptively widen. | -| D6 | **Natural-key PK `(file_path, name, line_start)` — no FK to `symbols.id`.** Earlier draft used `symbol_id REFERENCES symbols(id) ON DELETE CASCADE`; CodeRabbit-style self-audit caught that this would wipe every coverage row on every `--full` reindex (because `dropAll()` drops `symbols` → CASCADE fires → recreated `symbols` get fresh auto-increment IDs that don't match the old ones anyway). Natural key sidesteps the entire CASCADE hazard. Trade-off: orphan rows when a file is deleted from the project; cleaned by a single `DELETE FROM coverage WHERE file_path NOT IN (SELECT path FROM files)` at the end of every ingest. `coverage` is excluded from `dropAll()` (joins the `query_baselines` precedent) so the natural-key rows persist across `--full`. Re-ingest is the user's explicit refresh verb only when test results actually changed. | -| D7 | **Symbol mapping: innermost-wins line-range projection.** Istanbul's `statementMap` is line-keyed; we project per-statement hits onto the **innermost** enclosing symbol — `line_start ≤ stmt_line ≤ line_end` ordered by `(line_end - line_start) ASC LIMIT 1`. Innermost-wins is required because symbols nest (class methods inside classes, closures inside functions); attributing one statement to all enclosing scopes would inflate `total_statements` 2-3× on real codebases. Symbols whose range covers 0 statements (interfaces, type aliases) get no coverage row → `NULL` in `LEFT JOIN coverage`. (No prior table uses this projection — `markers` are line-pinned, `calls` are name-keyed; the projection is novel for this plan.) Statements that fall outside every symbol range (top-level expression statements, side-effect imports) increment `skipped.statements_no_symbol` for observability — no row written. | -| D8 | **Path normalisation: project-relative, forward-slashed.** Istanbul writes absolute paths; we strip `/` and replace `\\` with `/` to match `files.path`. Files outside the project root land in `skipped.unmatched_files`. Same projection `toProjectRelative()` (in `validate-engine`) already does — reuse the helper instead of rewriting it. | -| D9 | **MCP / HTTP exposure: column in `query` results, NOT a separate `coverage` tool.** The killer recipe (top of this doc) is one SQL query — `query` / `query_recipe` already returns `coverage_pct` as a column when the SELECT asks for it. A standalone `coverage` MCP tool would duplicate the surface; revisit only if a consumer ships a wrapper script that proves the SQL ergonomic gap is real. | -| D10 | **`codemap audit` integration deferred.** Adding `--delta coverage` to `audit` is the natural next step (flag "files where `coverage_pct` dropped >5% vs `--base`") but layered on top of D1–D9. Track as v1.x backlog; ship the ingester + raw schema first. | -| D11 | **Ingester lives in `application/coverage-engine.ts`.** Same engine-vs-CLI split as `impact` / `audit`: pure ingester (`ingestCoverage({db, source, payload}) → IngestResult`) consumed by `cli/cmd-ingest-coverage.ts`. No MCP / HTTP transport in v1 (D9). Engine is unit-testable against fixture Istanbul JSON without spinning up the CLI. Edge cases the engine guards: `total_statements = 0 → coverage_pct = NULL` (not 0; "untested" and "no testable code" are different); name-collision tolerance: same `(file_path, name, line_start)` is unique by construction (two functions can't start on the same line), so the natural-key PK never collides — but cross-file name collisions (`init` in `a.ts` and `b.ts`) mean the killer recipe's `callee_name = s.name` predicate is name-only / lossy in v1. Documented in the recipe comment, not fixed in v1 (would require adding `caller_file` joins). | -| D12 | **Schema bump = minor changeset.** Adds one table (`coverage`) + three meta keys; doesn't break any existing query. Per [`.agents/lessons.md`](../../.agents/lessons.md) "changesets bump policy" (verbatim: _reserve minor for schema-breaking changes that force a `.codemap.db` rebuild — matches 0.2.0 precedent: new tables/columns/`SCHEMA_VERSION` bump_), new tables + `SCHEMA_VERSION` bump = minor. The bump triggers `dropAll()` on next `codemap` run; the `coverage` table is absent on existing installs until first ingest (no migration needed — empty is the correct initial state). Subsequent `SCHEMA_VERSION` bumps preserve coverage data via the `dropAll()` exclusion (D6). | +| # | Decision | +| --- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| D1 | **Separate `coverage` table, not columns on `symbols`.** Resolves the open question in [research/fallow.md § 6](../research/fallow.md#6-open-questions). Three reasons: (a) coverage shape evolves independently of structural columns (per-branch, per-function, per-line metrics layered later) — denormalising churns `SCHEMA_VERSION` every time; (b) `symbols` rows exist for every TS / TSX file; coverage rows only for tested files — joining via `LEFT JOIN coverage` keeps NULL semantics explicit; (c) lifecycle independence — `symbols` re-creates on every `--full` reindex with fresh auto-increment IDs; the natural-key PK in `coverage` (D6) survives that churn without re-ingest. | +| D2 | **One table in v1; defer `file_coverage` rollup to v1.x.** `SELECT file_path, SUM(hit_statements), SUM(total_statements) FROM coverage GROUP BY file_path` answers "files ranked by coverage" with a single index-scan on `idx_coverage_file_name`. A separate rollup table doubles the source-of-truth surface and adds an UPSERT path; promote only after a real consumer query proves the GROUP BY is the bottleneck (no benchmark today suggests it is). Same "don't pre-emptively widen" discipline as D5. | +| D3 | **Both Istanbul JSON and LCOV in v1; c8 raw V8 traces never.** Earlier draft deferred LCOV to v1.x; reversed because shipping a coverage feature that excludes `bun test --coverage` users (which ships LCOV / text reporters only — verified via `bun test --help`) is half-baked for codemap's own primary runtime, and the "no half-way APIs" principle bans it. Istanbul JSON (`coverage-final.json`) covers `c8`, `nyc`, `vitest --coverage --coverage.reporter=json`, `jest --coverage --coverageReporters=json`. LCOV (`lcov.info`) covers `bun test --coverage`, `c8 --reporter=lcov`, every legacy stack. Together: every modern coverage tool emits at least one of these — no consumer left waiting. Two parser front-ends share one `upsertCoverageRows()` core, so the second format costs ~1 LoC per parser plus the regex tokenizer for LCOV. Raw V8 traces (`*.cpu.profile` / `coverage-c8/*.json`) stay out of scope — Fallow's paid moat, requires a runtime tracer Codemap doesn't ship. | +| D4 | **One-shot `codemap ingest-coverage `, NOT auto-detected during `codemap` runs.** Reasons: (a) `codemap` is sub-100ms cold-start; auto-probing for `coverage/coverage-final.json` adds a `stat` and grows the surface for "where did codemap look for coverage?"; (b) coverage cadence (once per test run) is decoupled from index cadence (every file edit) — coupling them means stale coverage on every save; (c) explicit verb makes the agent's mental model trivial: "tests ran → `codemap ingest-coverage` → `codemap query`". `codemap watch` does NOT auto-ingest coverage on `coverage-final.json` change (separate concern; revisit if demand materialises). | +| D5 | **Statement coverage only in v1.** Istanbul tracks statement / branch / function / line coverage separately. `coverage_pct` is **statement** coverage (the most stable signal across runners — function coverage misses anonymous closures, branch coverage explodes for switch / ternary). v1.x can add `branch_coverage_pct` / `function_coverage_pct` columns once a real consumer asks. Don't pre-emptively widen. | +| D6 | **Natural-key PK `(file_path, name, line_start)` — no FK to `symbols.id`.** Earlier draft used `symbol_id REFERENCES symbols(id) ON DELETE CASCADE`; CodeRabbit-style self-audit caught that this would wipe every coverage row on every `--full` reindex (because `dropAll()` drops `symbols` → CASCADE fires → recreated `symbols` get fresh auto-increment IDs that don't match the old ones anyway). Natural key sidesteps the entire CASCADE hazard. Trade-off: orphan rows when a file is deleted from the project; cleaned by a single `DELETE FROM coverage WHERE file_path NOT IN (SELECT path FROM files)` at the end of every ingest. `coverage` is excluded from `dropAll()` (joins the `query_baselines` precedent) so the natural-key rows persist across `--full`. Re-ingest is the user's explicit refresh verb only when test results actually changed. | +| D7 | **Symbol mapping: innermost-wins line-range projection.** Istanbul's `statementMap` is line-keyed; we project per-statement hits onto the **innermost** enclosing symbol — `line_start ≤ stmt_line ≤ line_end` ordered by `(line_end - line_start) ASC LIMIT 1`. Innermost-wins is required because symbols nest (class methods inside classes, closures inside functions); attributing one statement to all enclosing scopes would inflate `total_statements` 2-3× on real codebases. Symbols whose range covers 0 statements (interfaces, type aliases) get no coverage row → `NULL` in `LEFT JOIN coverage`. (No prior table uses this projection — `markers` are line-pinned, `calls` are name-keyed; the projection is novel for this plan.) Statements that fall outside every symbol range (top-level expression statements, side-effect imports) increment `skipped.statements_no_symbol` for observability — no row written. | +| D8 | **Path normalisation: project-relative, forward-slashed.** Istanbul writes absolute paths; we strip `/` and replace `\\` with `/` to match `files.path`. Files outside the project root land in `skipped.unmatched_files`. Same projection `toProjectRelative()` (in `validate-engine`) already does — reuse the helper instead of rewriting it. | +| D9 | **MCP / HTTP exposure: column in `query` results, NOT a separate `coverage` tool.** The killer recipe (top of this doc) is one SQL query — `query` / `query_recipe` already returns `coverage_pct` as a column when the SELECT asks for it. A standalone `coverage` MCP tool would duplicate the surface; revisit only if a consumer ships a wrapper script that proves the SQL ergonomic gap is real. | +| D10 | **`codemap audit` integration deferred.** Adding `--delta coverage` to `audit` is the natural next step (flag "files where `coverage_pct` dropped >5% vs `--base`") but layered on top of D1–D9. Track as v1.x backlog; ship the ingester + raw schema first. | +| D11 | **Ingester lives in `application/coverage-engine.ts`.** Same engine-vs-CLI split as `impact` / `audit`: pure ingester (`ingestCoverage({db, source, payload}) → IngestResult`) consumed by `cli/cmd-ingest-coverage.ts`. No MCP / HTTP transport in v1 (D9). Engine is unit-testable against fixture Istanbul JSON without spinning up the CLI. Edge cases the engine guards: `total_statements = 0 → coverage_pct = NULL` (not 0; "untested" and "no testable code" are different); name-collision tolerance: same `(file_path, name, line_start)` is unique by construction (two functions can't start on the same line), so the natural-key PK never collides — but cross-file name collisions (`init` in `a.ts` and `b.ts`) mean the killer recipe's `callee_name = s.name` predicate is name-only / lossy in v1. Documented in the recipe comment, not fixed in v1 (would require adding `caller_file` joins). | +| D12 | **Schema bump = minor changeset.** Adds one table (`coverage`) + three meta keys; doesn't break any existing query. Per [`.agents/lessons.md`](../../.agents/lessons.md) "changesets bump policy" (verbatim: _reserve minor for schema-breaking changes that force a `.codemap.db` rebuild — matches 0.2.0 precedent: new tables/columns/`SCHEMA_VERSION` bump_), new tables + `SCHEMA_VERSION` bump = minor. The bump triggers `dropAll()` on next `codemap` run; the `coverage` table is absent on existing installs until first ingest (no migration needed — empty is the correct initial state). Subsequent `SCHEMA_VERSION` bumps preserve coverage data via the `dropAll()` exclusion (D6). | ## Tracer-bullet plan Per [`tracer-bullets.mdc`](../../.cursor/rules/tracer-bullets.mdc) — vertical slices, each shippable on its own. -| # | Tracer | Acceptance | -| --- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | -| 1 | **Schema + bump** in `src/db.ts`. Add the `coverage` table DDL (natural-key PK per D6 — no FK to `symbols.id`); bump `SCHEMA_VERSION`; add `coverage` to the `query_baselines`-style "preserve across `--full`" exclusion in `dropAll()`. Add `idx_coverage_file_name`. New `meta` keys (`coverage_last_ingested_at` / `_path` / `_source`) need no DDL — `meta` is `(key, value)` already. Unit test (`src/db.test.ts`) inserts + reads back one row; verifies the table survives a `dropAll()` + `createSchema()` round-trip; verifies the orphan-cleanup DELETE removes rows whose `file_path` is no longer in `files`. | Table exists; survives `--full`; orphan sweep works. | -| 2 | **Istanbul ingester engine** in `src/application/coverage-engine.ts` (new). `ingestCoverage({db, payload, projectRoot}) → {ingested, skipped, pruned_orphans, source}`. Pure: parses the Istanbul JSON shape (`{ [absPath]: {statementMap, s, fnMap, f, branchMap, b, path} }`); maps each statement to the **innermost** enclosing symbol via the SQL `(line_end - line_start) ASC LIMIT 1` projection (D7); upserts `coverage` keyed on `(file_path, name, line_start)`; writes the three meta keys; runs the orphan-cleanup DELETE. Computes `coverage_pct = total_statements > 0 ? hit / total * 100 : NULL`. Reuses `toProjectRelative` from `validate-engine` (D8). Unit tests cover: fresh ingest; re-ingest (UPSERT idempotence); unmatched file → `skipped.unmatched_files`; statement outside every symbol → `skipped.statements_no_symbol`; symbol with 0 statements → no row; nested closure → innermost wins; `total_statements = 0` → `coverage_pct IS NULL`; orphan cleanup after a file deletion. | Pure engine; deterministic upsert; no FS / process side effects beyond `db.run`. | -| 3 | **CLI verb** `cli/cmd-ingest-coverage.ts` — parses `` + `--source` (auto-detect default) + `--json`, reads the JSON via the established runtime split (`Bun.file(path).json()` on Bun for the native-parser perf win on large coverage payloads; `readFile + JSON.parse` on Node) — see [`packaging.md § Node vs Bun`](../packaging.md#node-vs-bun) for the canonical pattern, mirrors `config.ts`. Dispatches to `coverage-engine`, renders the result. `main.ts` dispatcher gains the verb between existing entries. Help text in `bootstrap.ts` lists the new command. Unit tests cover: file-not-found, malformed JSON, source-mismatch error (`--source lcov` reserved for v1.x → v1 returns "lcov ingester not yet implemented"), `--json` envelope shape (including `pruned_orphans`). | `bun src/index.ts ingest-coverage fixtures/coverage/coverage-final.json` writes rows; `--help` lists the verb. | -| 4 | **Fixture coverage data** — `fixtures/minimal/coverage/coverage-final.json` (small Istanbul shape covering the existing fixture symbols partially: e.g. `usePermissions` 100%, `legacyClient` 0%, `now` 50%). Golden query under `fixtures/golden/minimal/coverage-deprecated.json` exercises the killer recipe ("`@deprecated` symbols with `coverage_pct = 0`"). Adds the fixture row to `fixtures/minimal/README.md` "What's exercised" table. The pre-enriched `legacyClient` / `now` / `epochMs` deprecated symbols (PR #55) directly test the join axis — no new fixture symbols needed beyond the coverage JSON itself. | Golden recipe passes; documents how a real consumer wires `bun test --coverage` into the workflow. | -| 5 | **Doc + agent rule + skill + changeset + plan deletion** — `docs/architecture.md` § Persistence wiring (new tables, ingester engine, CLI verb), `docs/glossary.md` (`coverage_pct`, `file_coverage`, "Istanbul JSON", "static coverage ingestion"), `.agents/rules/codemap.md` + `templates/agents/rules/codemap.md` (Rule 10 lockstep — new trigger pattern: "What's untested AND structurally dead?", new SQL example), skill SKILL.md `coverage` table row, README.md "What's exercised" + Use section. Minor changeset noting the `SCHEMA_VERSION` bump + the explicit re-ingest needed after upgrade. Plan deleted per `docs/README.md` Rule 3. | All docs consistent; agents see the new tables on next `codemap agents init`. | +| # | Tracer | Acceptance | +| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| 1 | **Schema + bump** in `src/db.ts`. Add the `coverage` table DDL (natural-key PK per D6 — no FK to `symbols.id`); bump `SCHEMA_VERSION`; add `coverage` to the `query_baselines`-style "preserve across `--full`" exclusion in `dropAll()`. Add `idx_coverage_file_name`. New `meta` keys (`coverage_last_ingested_at` / `_path` / `_format`) need no DDL — `meta` is `(key, value)` already. Unit test (`src/db.test.ts`) inserts + reads back one row; verifies the table survives a `dropAll()` + `createSchema()` round-trip; verifies the orphan-cleanup DELETE removes rows whose `file_path` is no longer in `files`. | Table exists; survives `--full`; orphan sweep works. | +| 2a | **Shared engine core** in `src/application/coverage-engine.ts` (new). `upsertCoverageRows({db, rows, projectRoot}) → {ingested, skipped, pruned_orphans}` — format-agnostic: takes a normalised `CoverageRow[]` (`{file_path, line, hit_count}`-shape), maps each row to the **innermost** enclosing symbol via the SQL `(line_end - line_start) ASC LIMIT 1` projection (D7), upserts `coverage` keyed on `(file_path, name, line_start)`, writes the meta keys, runs the orphan-cleanup DELETE. Computes `coverage_pct = total_statements > 0 ? hit / total * 100 : NULL`. Reuses `toProjectRelative` from `validate-engine` (D8). Plus `ingestIstanbul({db, payload, projectRoot})` — parses the Istanbul shape (`{ [absPath]: {statementMap, s, fnMap, f, branchMap, b, path} }`), normalises to `CoverageRow[]`, calls `upsertCoverageRows`. Unit tests cover both the shared core (fresh ingest, re-ingest UPSERT idempotence, unmatched file, statement-outside-symbol, nested-closure-innermost-wins, `total_statements = 0 → NULL`, orphan cleanup) and the Istanbul parser (statement-map shape, absolute-path normalisation, Windows-path handling). | Both pieces pure; deterministic upsert; Istanbul parser tested in isolation against fixture JSON. | +| 2b | **LCOV parser** in `src/application/coverage-engine.ts` — `ingestLcov({db, payload, projectRoot})`. Pure regex tokenizer over the LCOV record format (`SF:` / `DA:,` / `end_of_record`); normalises to the same `CoverageRow[]`, calls `upsertCoverageRows` — zero new write-side code. Unit tests cover: well-formed LCOV with multiple `SF` records, `DA` lines with hit / miss / both, malformed records (missing `end_of_record` → error), unmatched file. Real `bun test --coverage` output captured into a fixture for round-trip testing. | Pure parser; passes round-trip on a real `bun test --coverage` artifact. | +| 3 | **CLI verb** `cli/cmd-ingest-coverage.ts` — parses `` + `--json`. Auto-detects format: file ending `.json` → istanbul, `.info` → lcov, directory → probes for `coverage-final.json` / `lcov.info` (errors if both or neither). Reads JSON via the established runtime split (`Bun.file(path).json()` on Bun for the native-parser perf win; `readFile + JSON.parse` on Node — see [`packaging.md § Node vs Bun`](../packaging.md#node-vs-bun), mirrors `config.ts`); reads LCOV via `Bun.file(path).text()` / `readFile`. Dispatches to `ingestIstanbul` or `ingestLcov`. `main.ts` dispatcher gains the verb between existing entries. Help text in `bootstrap.ts` lists the new command. Unit tests cover: file-not-found, malformed JSON / LCOV, ambiguous directory (both files), empty directory, `--json` envelope shape. | `bun src/index.ts ingest-coverage ` and `` both write rows; `--help` lists the verb. | +| 4 | **Fixture coverage data + bundled killer recipe** — ship two fixtures: `fixtures/minimal/coverage/coverage-final.json` (Istanbul) and `fixtures/minimal/coverage/lcov.info` (LCOV) covering the same partial coverage shape (e.g. `usePermissions` 100%, `legacyClient` 0%, `now` 50%). Bundled recipe `templates/recipes/untested-and-dead.{sql,md}` exposes the killer recipe (top-of-doc query) as `codemap query --recipe untested-and-dead` — first-class agent surface, not a buried doc snippet. Golden recipes under `fixtures/golden/minimal/`: `coverage-istanbul.json`, `coverage-lcov.json`, `untested-and-dead.json` (all assert against the same expected row set, proving Istanbul ↔ LCOV equivalence). Adds the fixture rows + bundled recipe to `fixtures/minimal/README.md` "What's exercised" table. The pre-enriched `legacyClient` / `now` / `epochMs` deprecated symbols (PR #55) directly test the join axis. | Both ingesters produce identical golden rows; bundled recipe queryable via `--recipe untested-and-dead`; `--recipes-json` lists it. | +| 5 | **Doc + agent rule + skill + changeset + plan deletion** — `docs/architecture.md` § Persistence wiring (new table, ingester engine, CLI verb, both formats), `docs/glossary.md` (`coverage_pct`, "Istanbul JSON", "LCOV", "static coverage ingestion", "killer recipe `untested-and-dead`"), `.agents/rules/codemap.md` + `templates/agents/rules/codemap.md` (Rule 10 lockstep — new trigger pattern: "What's untested AND structurally dead?", `--recipe untested-and-dead` example, table reference for `coverage`), skill `SKILL.md` `coverage` table row, README.md "What's exercised" + Use section (`bun test --coverage` + `vitest --coverage` worked examples). Minor changeset (per D12). Plan deleted per `docs/README.md` Rule 3. | All docs consistent; agents see `coverage` table + bundled recipe on next `codemap agents init`. | ## Performance considerations -- **Ingest cost** — one JSON parse + linear scan of statement maps + one symbol-projection SQL per statement. Per-statement projection is the hot path; backed by `idx_symbols_file` it's an index-bounded lookup. On Bun, the JSON parse uses `Bun.file(path).json()` (native parser, materially faster than V8 `JSON.parse` on multi-MB Istanbul payloads); on Node it's `readFile + JSON.parse`. No published benchmark yet — measurement target during tracer 2. +- **Ingest cost** — one parse pass (Istanbul JSON or LCOV) + linear scan of statements + one symbol-projection SQL per statement. Per-statement projection is the hot path; backed by `idx_symbols_file` it's an index-bounded lookup. On Bun, JSON parsing uses `Bun.file(path).json()` (native parser, materially faster than V8 `JSON.parse` on multi-MB Istanbul payloads); on Node it's `readFile + JSON.parse`. LCOV is line-by-line text on both runtimes. No published benchmark yet — measurement target during tracer 2. - **Read cost** — `coverage` is `WITHOUT ROWID` on `(file_path, name, line_start)`. The killer recipe's `LEFT JOIN coverage ON file_path AND name AND line_start` is a 3-column composite-PK lookup per row; `idx_coverage_file_name` covers the prefix. - **Storage** — three INTEGER + one REAL + two short TEXT per row; small. Real disk impact dominated by symbol count, not coverage shape. - **No background worker.** Ingest is single-pass; reindex is unaffected. @@ -127,7 +129,8 @@ Per [`tracer-bullets.mdc`](../../.cursor/rules/tracer-bullets.mdc) — vertical | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Add `coverage_pct REAL` + `is_runtime_hot INTEGER` to `symbols` directly** | Per D1: shape-coupling, NULL ambiguity, schema-bump amplification. The original sketch ([research/fallow.md § C.11](../research/fallow.md#tier-c--ship-eventually-months-high-payoff-large-surface)) listed this — D1 supersedes. | | **Auto-detect `coverage/coverage-final.json` during `codemap` runs** | Per D4: cadence mismatch + auto-probe surface. The auto-detect path is also harder to reason about ("why does this index include coverage on machine A but not B?"); explicit verb is unambiguous. | -| **Ship LCOV ingester in v1 alongside Istanbul** | Doubles the test surface for marginal value — every modern coverage tool emits Istanbul JSON natively; LCOV is the legacy path. Defer to v1.x; add `--source lcov` once a consumer asks. | +| **Defer LCOV to v1.x (Istanbul-only v1)** | Per D3: ships a coverage feature that excludes `bun test --coverage` users — half-baked for codemap's own primary runtime, banned by the "no half-way APIs" principle. Two parser front-ends + one shared upsert core costs ~one tracer's worth of code; not worth deferring. | +| **Keep `--source istanbul\|lcov` flag** | API noise: format is auto-detectable from extension (`.json` / `.info`); a flag for "tell codemap what it can already see" is over-engineered. Misnamed files can be renamed (one-liner) cheaper than codemap can grow a flag. | | **Embed runtime coverage tracer (V8 / Istanbul beacons)** | Out of scope per [research/fallow.md § D.16](../research/fallow.md#defer--skip) and [roadmap.md § Non-goals](../roadmap.md#non-goals-v1). This plan reads artifacts; it does not produce them. | | **`coverage` rows keyed on `symbol_id` with `ON DELETE CASCADE`** | Original draft. Rejected per D6: `dropAll()` drops `symbols` on every `--full`, CASCADE wipes coverage, recreated `symbols` get fresh auto-increment IDs anyway → coverage permanently lost without re-ingest. The natural-key approach (D6) sidesteps the entire FK/CASCADE hazard at the cost of one explicit orphan-cleanup DELETE per ingest. | | **Separate `file_coverage` rollup table in v1** | Per D2: aggregateable in one indexed `GROUP BY` on the symbol-level table; doubling the source-of-truth surface without a benchmark is premature. Promote in v1.x once a real query proves the cost. | @@ -139,7 +142,6 @@ Per [`tracer-bullets.mdc`](../../.cursor/rules/tracer-bullets.mdc) — vertical - **Branch / function / line coverage breakdowns** — D5; v1.x once a consumer asks with a concrete query. - **`file_coverage` rollup table** — D2; v1.x with a benchmark. -- **LCOV ingester** — D3; v1.x once Istanbul lands. - **Cross-file callee disambiguation** — D11; the killer recipe's `callee_name = s.name` is name-only / lossy in v1. v1.x can add a `caller_file_path` column to `calls` if a real consumer needs the precision. - **Coverage diff against baseline** — `--save-baseline` / `--baseline` already covers arbitrary query result snapshots; coverage queries inherit it for free. A first-class `coverage_diff` would duplicate. - **Coverage trend over time** — adjacent to telemetry; not in v1. Consumers can `--save-baseline coverage-snapshot-` periodically. From 83bf2e616a9d33f9c669c72e2b29df62e3e1bfc7 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Mon, 4 May 2026 10:47:19 +0300 Subject: [PATCH 6/6] =?UTF-8?q?docs(plan):=20v4=20=E2=80=94=20agent-journe?= =?UTF-8?q?y=20audit=20+=20bundled=20recipe=20shelf=20(D13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Walked every D / OOS / tracer item against "fully capable + agent first-class + no half-baked APIs". Found three half-baked surfaces: 1. **D2 deferral leaks "compose GROUP BY yourself" onto the agent.** Deferring the `file_coverage` table is correct (no benchmark proves it's needed) — but the agent-facing answer for "rank files by coverage" was missing. Fix: keep table deferral, ship a bundled `files-by-coverage.{sql,md}` recipe so the GROUP BY view IS first-class. 2. **D11 name-collision lossiness was acknowledged but unmitigated.** The killer recipe's `callee_name = s.name` cross-file lossiness was documented in the recipe SQL comment, but the recipe `.md` didn't give the agent any narrowing pattern. Now D11 ships three concrete narrowing patterns in the `.md` (file_path scope, default- export filter, exported-only restriction) so the agent has workable mitigations on day one. 3. **Missing recipe shelf for common agent questions.** Walking the journey: only "What's structurally dead AND untested?" had a recipe; "Rank files by coverage" and "Worst-covered exported symbols" forced ad-hoc SQL. Three recipes fully cover the agent journey end-to-end. New D13 codifies the bundled-recipe principle: every common agent question gets a `--recipe` verb. Three v1 recipes: - `untested-and-dead.{sql,md}` (killer, with name-collision mitigations) - `files-by-coverage.{sql,md}` (replaces D2's table deferral) - `worst-covered-exports.{sql,md}` (top-N agent ask) Each `.md` carries a frontmatter `actions` block (per PR #26) so agents get per-row follow-up hints. All three appear in `--recipes-json` automatically — agents discover them at session start. New "Agent journey" section makes the principle visible: a table mapping every common agent question to the v1 verb that answers it. If a row ever shows "compose SQL yourself" without a recipe, the surface is half-baked and needs a recipe before tracer 1 ships. Tracer 4 expanded: ships all three recipes + five golden snapshots (adds files-by-coverage.json + worst-covered-exports.json on top of the three existing). Tracer 5 expanded: glossary + agent rule trigger table gain three new rows. Plan now passes the principle audit end-to-end. --- docs/plans/coverage-ingestion.md | 61 ++++++++++++++++++++------------ 1 file changed, 39 insertions(+), 22 deletions(-) diff --git a/docs/plans/coverage-ingestion.md b/docs/plans/coverage-ingestion.md index d484c8b..647f209 100644 --- a/docs/plans/coverage-ingestion.md +++ b/docs/plans/coverage-ingestion.md @@ -35,6 +35,22 @@ Today an agent has to run two tools (`codemap` + a coverage reader) and join in - **Fallow's runtime intelligence is paid.** Static coverage ingestion gets ~80% of the agent value (the "is X dead?" predicate above) without entering Fallow's V8/production-beacon territory (explicit non-goal per [research/fallow.md § D.16](../research/fallow.md#defer--skip)). - **Composes with `codemap impact`.** `impact --direction up --depth 0` returns callers; joining `coverage` on the result tells the agent "this symbol has 12 callers but only 2 of them are hit by tests" — refactor risk in one query. +## Agent journey (the "first-class" axis) + +Every common agent question this feature unlocks must be a one-verb call, not "compose this JOIN yourself" — that's what "fully capable, no half-way APIs" means in practice. The v1 surface: + +| Agent asks | v1 verb | Backed by | +| ----------------------------------------- | -------------------------------------------------------------- | ------------------------ | +| "Is `legacyClient` tested?" | `query "SELECT * FROM coverage WHERE name = '…'"` | `coverage` tbl | +| "What's structurally dead AND untested?" | `query --recipe untested-and-dead` | D13 | +| "Rank files by test coverage" | `query --recipe files-by-coverage` | D13 (covers D2 deferral) | +| "Worst-covered exported symbols (top 20)" | `query --recipe worst-covered-exports` | D13 | +| "Coverage of these specific symbols" | `query "SELECT … FROM coverage WHERE name IN (…)"` | `coverage` tbl | +| "Did coverage change since base?" | `--save-baseline` + `--baseline` (existing primitive composes) | B.6 | +| "Refactor risk: callers vs coverage" | `impact ` JSON piped into a `coverage` LEFT JOIN | impact + coverage | + +Recipes 2 and 3 are added because deferring the `file_coverage` rollup table (D2) would otherwise force the agent to compose `GROUP BY` queries by hand — half-baked surface. Bundling the recipes keeps the schema lean (D2) AND the agent surface complete (D13). + ## Sketched layout ### Schema (D1, D6 natural-key fix) @@ -88,33 +104,34 @@ Decoupled from `codemap` (the index command) on purpose (D4) — coverage runs o ## Decisions -| # | Decision | -| --- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| D1 | **Separate `coverage` table, not columns on `symbols`.** Resolves the open question in [research/fallow.md § 6](../research/fallow.md#6-open-questions). Three reasons: (a) coverage shape evolves independently of structural columns (per-branch, per-function, per-line metrics layered later) — denormalising churns `SCHEMA_VERSION` every time; (b) `symbols` rows exist for every TS / TSX file; coverage rows only for tested files — joining via `LEFT JOIN coverage` keeps NULL semantics explicit; (c) lifecycle independence — `symbols` re-creates on every `--full` reindex with fresh auto-increment IDs; the natural-key PK in `coverage` (D6) survives that churn without re-ingest. | -| D2 | **One table in v1; defer `file_coverage` rollup to v1.x.** `SELECT file_path, SUM(hit_statements), SUM(total_statements) FROM coverage GROUP BY file_path` answers "files ranked by coverage" with a single index-scan on `idx_coverage_file_name`. A separate rollup table doubles the source-of-truth surface and adds an UPSERT path; promote only after a real consumer query proves the GROUP BY is the bottleneck (no benchmark today suggests it is). Same "don't pre-emptively widen" discipline as D5. | -| D3 | **Both Istanbul JSON and LCOV in v1; c8 raw V8 traces never.** Earlier draft deferred LCOV to v1.x; reversed because shipping a coverage feature that excludes `bun test --coverage` users (which ships LCOV / text reporters only — verified via `bun test --help`) is half-baked for codemap's own primary runtime, and the "no half-way APIs" principle bans it. Istanbul JSON (`coverage-final.json`) covers `c8`, `nyc`, `vitest --coverage --coverage.reporter=json`, `jest --coverage --coverageReporters=json`. LCOV (`lcov.info`) covers `bun test --coverage`, `c8 --reporter=lcov`, every legacy stack. Together: every modern coverage tool emits at least one of these — no consumer left waiting. Two parser front-ends share one `upsertCoverageRows()` core, so the second format costs ~1 LoC per parser plus the regex tokenizer for LCOV. Raw V8 traces (`*.cpu.profile` / `coverage-c8/*.json`) stay out of scope — Fallow's paid moat, requires a runtime tracer Codemap doesn't ship. | -| D4 | **One-shot `codemap ingest-coverage `, NOT auto-detected during `codemap` runs.** Reasons: (a) `codemap` is sub-100ms cold-start; auto-probing for `coverage/coverage-final.json` adds a `stat` and grows the surface for "where did codemap look for coverage?"; (b) coverage cadence (once per test run) is decoupled from index cadence (every file edit) — coupling them means stale coverage on every save; (c) explicit verb makes the agent's mental model trivial: "tests ran → `codemap ingest-coverage` → `codemap query`". `codemap watch` does NOT auto-ingest coverage on `coverage-final.json` change (separate concern; revisit if demand materialises). | -| D5 | **Statement coverage only in v1.** Istanbul tracks statement / branch / function / line coverage separately. `coverage_pct` is **statement** coverage (the most stable signal across runners — function coverage misses anonymous closures, branch coverage explodes for switch / ternary). v1.x can add `branch_coverage_pct` / `function_coverage_pct` columns once a real consumer asks. Don't pre-emptively widen. | -| D6 | **Natural-key PK `(file_path, name, line_start)` — no FK to `symbols.id`.** Earlier draft used `symbol_id REFERENCES symbols(id) ON DELETE CASCADE`; CodeRabbit-style self-audit caught that this would wipe every coverage row on every `--full` reindex (because `dropAll()` drops `symbols` → CASCADE fires → recreated `symbols` get fresh auto-increment IDs that don't match the old ones anyway). Natural key sidesteps the entire CASCADE hazard. Trade-off: orphan rows when a file is deleted from the project; cleaned by a single `DELETE FROM coverage WHERE file_path NOT IN (SELECT path FROM files)` at the end of every ingest. `coverage` is excluded from `dropAll()` (joins the `query_baselines` precedent) so the natural-key rows persist across `--full`. Re-ingest is the user's explicit refresh verb only when test results actually changed. | -| D7 | **Symbol mapping: innermost-wins line-range projection.** Istanbul's `statementMap` is line-keyed; we project per-statement hits onto the **innermost** enclosing symbol — `line_start ≤ stmt_line ≤ line_end` ordered by `(line_end - line_start) ASC LIMIT 1`. Innermost-wins is required because symbols nest (class methods inside classes, closures inside functions); attributing one statement to all enclosing scopes would inflate `total_statements` 2-3× on real codebases. Symbols whose range covers 0 statements (interfaces, type aliases) get no coverage row → `NULL` in `LEFT JOIN coverage`. (No prior table uses this projection — `markers` are line-pinned, `calls` are name-keyed; the projection is novel for this plan.) Statements that fall outside every symbol range (top-level expression statements, side-effect imports) increment `skipped.statements_no_symbol` for observability — no row written. | -| D8 | **Path normalisation: project-relative, forward-slashed.** Istanbul writes absolute paths; we strip `/` and replace `\\` with `/` to match `files.path`. Files outside the project root land in `skipped.unmatched_files`. Same projection `toProjectRelative()` (in `validate-engine`) already does — reuse the helper instead of rewriting it. | -| D9 | **MCP / HTTP exposure: column in `query` results, NOT a separate `coverage` tool.** The killer recipe (top of this doc) is one SQL query — `query` / `query_recipe` already returns `coverage_pct` as a column when the SELECT asks for it. A standalone `coverage` MCP tool would duplicate the surface; revisit only if a consumer ships a wrapper script that proves the SQL ergonomic gap is real. | -| D10 | **`codemap audit` integration deferred.** Adding `--delta coverage` to `audit` is the natural next step (flag "files where `coverage_pct` dropped >5% vs `--base`") but layered on top of D1–D9. Track as v1.x backlog; ship the ingester + raw schema first. | -| D11 | **Ingester lives in `application/coverage-engine.ts`.** Same engine-vs-CLI split as `impact` / `audit`: pure ingester (`ingestCoverage({db, source, payload}) → IngestResult`) consumed by `cli/cmd-ingest-coverage.ts`. No MCP / HTTP transport in v1 (D9). Engine is unit-testable against fixture Istanbul JSON without spinning up the CLI. Edge cases the engine guards: `total_statements = 0 → coverage_pct = NULL` (not 0; "untested" and "no testable code" are different); name-collision tolerance: same `(file_path, name, line_start)` is unique by construction (two functions can't start on the same line), so the natural-key PK never collides — but cross-file name collisions (`init` in `a.ts` and `b.ts`) mean the killer recipe's `callee_name = s.name` predicate is name-only / lossy in v1. Documented in the recipe comment, not fixed in v1 (would require adding `caller_file` joins). | -| D12 | **Schema bump = minor changeset.** Adds one table (`coverage`) + three meta keys; doesn't break any existing query. Per [`.agents/lessons.md`](../../.agents/lessons.md) "changesets bump policy" (verbatim: _reserve minor for schema-breaking changes that force a `.codemap.db` rebuild — matches 0.2.0 precedent: new tables/columns/`SCHEMA_VERSION` bump_), new tables + `SCHEMA_VERSION` bump = minor. The bump triggers `dropAll()` on next `codemap` run; the `coverage` table is absent on existing installs until first ingest (no migration needed — empty is the correct initial state). Subsequent `SCHEMA_VERSION` bumps preserve coverage data via the `dropAll()` exclusion (D6). | +| # | Decision | +| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| D1 | **Separate `coverage` table, not columns on `symbols`.** Resolves the open question in [research/fallow.md § 6](../research/fallow.md#6-open-questions). Three reasons: (a) coverage shape evolves independently of structural columns (per-branch, per-function, per-line metrics layered later) — denormalising churns `SCHEMA_VERSION` every time; (b) `symbols` rows exist for every TS / TSX file; coverage rows only for tested files — joining via `LEFT JOIN coverage` keeps NULL semantics explicit; (c) lifecycle independence — `symbols` re-creates on every `--full` reindex with fresh auto-increment IDs; the natural-key PK in `coverage` (D6) survives that churn without re-ingest. | +| D2 | **One table in v1; defer `file_coverage` rollup to v1.x — but ship the agent-facing answer as a bundled recipe.** `SELECT file_path, SUM(hit_statements), SUM(total_statements) FROM coverage GROUP BY file_path` answers "files ranked by coverage" with a single index-scan on `idx_coverage_file_name`. A separate rollup table doubles the source-of-truth surface and adds an UPSERT path; promote only after a real consumer query proves the GROUP BY is the bottleneck. The agent surface stays first-class via the bundled `files-by-coverage` recipe (D13) — no agent ever has to compose the GROUP BY by hand. Same "don't pre-emptively widen" discipline as D5; same "first-class via recipe" pattern as D13. | +| D3 | **Both Istanbul JSON and LCOV in v1; c8 raw V8 traces never.** Earlier draft deferred LCOV to v1.x; reversed because shipping a coverage feature that excludes `bun test --coverage` users (which ships LCOV / text reporters only — verified via `bun test --help`) is half-baked for codemap's own primary runtime, and the "no half-way APIs" principle bans it. Istanbul JSON (`coverage-final.json`) covers `c8`, `nyc`, `vitest --coverage --coverage.reporter=json`, `jest --coverage --coverageReporters=json`. LCOV (`lcov.info`) covers `bun test --coverage`, `c8 --reporter=lcov`, every legacy stack. Together: every modern coverage tool emits at least one of these — no consumer left waiting. Two parser front-ends share one `upsertCoverageRows()` core, so the second format costs ~1 LoC per parser plus the regex tokenizer for LCOV. Raw V8 traces (`*.cpu.profile` / `coverage-c8/*.json`) stay out of scope — Fallow's paid moat, requires a runtime tracer Codemap doesn't ship. | +| D4 | **One-shot `codemap ingest-coverage `, NOT auto-detected during `codemap` runs.** Reasons: (a) `codemap` is sub-100ms cold-start; auto-probing for `coverage/coverage-final.json` adds a `stat` and grows the surface for "where did codemap look for coverage?"; (b) coverage cadence (once per test run) is decoupled from index cadence (every file edit) — coupling them means stale coverage on every save; (c) explicit verb makes the agent's mental model trivial: "tests ran → `codemap ingest-coverage` → `codemap query`". `codemap watch` does NOT auto-ingest coverage on `coverage-final.json` change (separate concern; revisit if demand materialises). | +| D5 | **Statement coverage only in v1.** Istanbul tracks statement / branch / function / line coverage separately. `coverage_pct` is **statement** coverage (the most stable signal across runners — function coverage misses anonymous closures, branch coverage explodes for switch / ternary). v1.x can add `branch_coverage_pct` / `function_coverage_pct` columns once a real consumer asks. Don't pre-emptively widen. | +| D6 | **Natural-key PK `(file_path, name, line_start)` — no FK to `symbols.id`.** Earlier draft used `symbol_id REFERENCES symbols(id) ON DELETE CASCADE`; CodeRabbit-style self-audit caught that this would wipe every coverage row on every `--full` reindex (because `dropAll()` drops `symbols` → CASCADE fires → recreated `symbols` get fresh auto-increment IDs that don't match the old ones anyway). Natural key sidesteps the entire CASCADE hazard. Trade-off: orphan rows when a file is deleted from the project; cleaned by a single `DELETE FROM coverage WHERE file_path NOT IN (SELECT path FROM files)` at the end of every ingest. `coverage` is excluded from `dropAll()` (joins the `query_baselines` precedent) so the natural-key rows persist across `--full`. Re-ingest is the user's explicit refresh verb only when test results actually changed. | +| D7 | **Symbol mapping: innermost-wins line-range projection.** Istanbul's `statementMap` is line-keyed; we project per-statement hits onto the **innermost** enclosing symbol — `line_start ≤ stmt_line ≤ line_end` ordered by `(line_end - line_start) ASC LIMIT 1`. Innermost-wins is required because symbols nest (class methods inside classes, closures inside functions); attributing one statement to all enclosing scopes would inflate `total_statements` 2-3× on real codebases. Symbols whose range covers 0 statements (interfaces, type aliases) get no coverage row → `NULL` in `LEFT JOIN coverage`. (No prior table uses this projection — `markers` are line-pinned, `calls` are name-keyed; the projection is novel for this plan.) Statements that fall outside every symbol range (top-level expression statements, side-effect imports) increment `skipped.statements_no_symbol` for observability — no row written. | +| D8 | **Path normalisation: project-relative, forward-slashed.** Istanbul writes absolute paths; we strip `/` and replace `\\` with `/` to match `files.path`. Files outside the project root land in `skipped.unmatched_files`. Same projection `toProjectRelative()` (in `validate-engine`) already does — reuse the helper instead of rewriting it. | +| D9 | **MCP / HTTP exposure: column in `query` results, NOT a separate `coverage` tool.** The killer recipe (top of this doc) is one SQL query — `query` / `query_recipe` already returns `coverage_pct` as a column when the SELECT asks for it. A standalone `coverage` MCP tool would duplicate the surface; revisit only if a consumer ships a wrapper script that proves the SQL ergonomic gap is real. | +| D10 | **`codemap audit` integration deferred.** Adding `--delta coverage` to `audit` is the natural next step (flag "files where `coverage_pct` dropped >5% vs `--base`") but layered on top of D1–D9. Track as v1.x backlog; ship the ingester + raw schema first. | +| D11 | **Ingester lives in `application/coverage-engine.ts`.** Same engine-vs-CLI split as `impact` / `audit`: pure ingester (`ingestIstanbul` / `ingestLcov`, both calling `upsertCoverageRows`) consumed by `cli/cmd-ingest-coverage.ts`. No MCP / HTTP transport in v1 (D9). Engine is unit-testable against fixture artifacts without spinning up the CLI. Edge cases the engine guards: `total_statements = 0 → coverage_pct = NULL` (not 0; "untested" and "no testable code" are different); name-collision tolerance: same `(file_path, name, line_start)` is unique by construction (two functions can't start on the same line), so the natural-key PK never collides — but cross-file name collisions (`init` in `a.ts` and `b.ts`) mean the killer recipe's `callee_name = s.name` predicate is name-only / lossy in v1. **Mitigation shipped in v1**: the bundled `untested-and-dead.md` documents the limitation and shows three concrete narrowing patterns the agent can apply (scope by `file_path LIKE 'src/api/%'`, exclude framework re-exports via `is_default_export = 0`, restrict to `is_exported = 1` already in the recipe). v1.x can add a `caller_file_path` column to `calls` for fully-precise resolution if a consumer's narrowing pattern proves insufficient. | +| D13 | **Bundled recipe shelf — every common agent question gets a `--recipe` verb, not "write your own SQL".** Three v1 recipes in `templates/recipes/` cover the agent journey end-to-end: (1) `untested-and-dead.{sql,md}` — exported symbols with no callers and zero coverage (the killer recipe); (2) `files-by-coverage.{sql,md}` — files ranked ascending by coverage_pct, surfacing the GROUP BY view that makes D2's deferral non-blocking; (3) `worst-covered-exports.{sql,md}` — exported symbols sorted by ascending coverage_pct, with a configurable `LIMIT`-via-frontmatter for the "show me the top 20" agent ask. Each recipe `.md` carries an `actions` block (per [PR #26](https://github.com/stainless-code/codemap/pull/26)) so agents see the suggested follow-up per row (e.g. "open-deprecation-issue", "add-test-suite"). All three appear in `--recipes-json` automatically — agents discover them at session start without reading docs. | +| D12 | **Schema bump = minor changeset.** Adds one table (`coverage`) + three meta keys; doesn't break any existing query. Per [`.agents/lessons.md`](../../.agents/lessons.md) "changesets bump policy" (verbatim: _reserve minor for schema-breaking changes that force a `.codemap.db` rebuild — matches 0.2.0 precedent: new tables/columns/`SCHEMA_VERSION` bump_), new tables + `SCHEMA_VERSION` bump = minor. The bump triggers `dropAll()` on next `codemap` run; the `coverage` table is absent on existing installs until first ingest (no migration needed — empty is the correct initial state). Subsequent `SCHEMA_VERSION` bumps preserve coverage data via the `dropAll()` exclusion (D6). | ## Tracer-bullet plan Per [`tracer-bullets.mdc`](../../.cursor/rules/tracer-bullets.mdc) — vertical slices, each shippable on its own. -| # | Tracer | Acceptance | -| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | -| 1 | **Schema + bump** in `src/db.ts`. Add the `coverage` table DDL (natural-key PK per D6 — no FK to `symbols.id`); bump `SCHEMA_VERSION`; add `coverage` to the `query_baselines`-style "preserve across `--full`" exclusion in `dropAll()`. Add `idx_coverage_file_name`. New `meta` keys (`coverage_last_ingested_at` / `_path` / `_format`) need no DDL — `meta` is `(key, value)` already. Unit test (`src/db.test.ts`) inserts + reads back one row; verifies the table survives a `dropAll()` + `createSchema()` round-trip; verifies the orphan-cleanup DELETE removes rows whose `file_path` is no longer in `files`. | Table exists; survives `--full`; orphan sweep works. | -| 2a | **Shared engine core** in `src/application/coverage-engine.ts` (new). `upsertCoverageRows({db, rows, projectRoot}) → {ingested, skipped, pruned_orphans}` — format-agnostic: takes a normalised `CoverageRow[]` (`{file_path, line, hit_count}`-shape), maps each row to the **innermost** enclosing symbol via the SQL `(line_end - line_start) ASC LIMIT 1` projection (D7), upserts `coverage` keyed on `(file_path, name, line_start)`, writes the meta keys, runs the orphan-cleanup DELETE. Computes `coverage_pct = total_statements > 0 ? hit / total * 100 : NULL`. Reuses `toProjectRelative` from `validate-engine` (D8). Plus `ingestIstanbul({db, payload, projectRoot})` — parses the Istanbul shape (`{ [absPath]: {statementMap, s, fnMap, f, branchMap, b, path} }`), normalises to `CoverageRow[]`, calls `upsertCoverageRows`. Unit tests cover both the shared core (fresh ingest, re-ingest UPSERT idempotence, unmatched file, statement-outside-symbol, nested-closure-innermost-wins, `total_statements = 0 → NULL`, orphan cleanup) and the Istanbul parser (statement-map shape, absolute-path normalisation, Windows-path handling). | Both pieces pure; deterministic upsert; Istanbul parser tested in isolation against fixture JSON. | -| 2b | **LCOV parser** in `src/application/coverage-engine.ts` — `ingestLcov({db, payload, projectRoot})`. Pure regex tokenizer over the LCOV record format (`SF:` / `DA:,` / `end_of_record`); normalises to the same `CoverageRow[]`, calls `upsertCoverageRows` — zero new write-side code. Unit tests cover: well-formed LCOV with multiple `SF` records, `DA` lines with hit / miss / both, malformed records (missing `end_of_record` → error), unmatched file. Real `bun test --coverage` output captured into a fixture for round-trip testing. | Pure parser; passes round-trip on a real `bun test --coverage` artifact. | -| 3 | **CLI verb** `cli/cmd-ingest-coverage.ts` — parses `` + `--json`. Auto-detects format: file ending `.json` → istanbul, `.info` → lcov, directory → probes for `coverage-final.json` / `lcov.info` (errors if both or neither). Reads JSON via the established runtime split (`Bun.file(path).json()` on Bun for the native-parser perf win; `readFile + JSON.parse` on Node — see [`packaging.md § Node vs Bun`](../packaging.md#node-vs-bun), mirrors `config.ts`); reads LCOV via `Bun.file(path).text()` / `readFile`. Dispatches to `ingestIstanbul` or `ingestLcov`. `main.ts` dispatcher gains the verb between existing entries. Help text in `bootstrap.ts` lists the new command. Unit tests cover: file-not-found, malformed JSON / LCOV, ambiguous directory (both files), empty directory, `--json` envelope shape. | `bun src/index.ts ingest-coverage ` and `` both write rows; `--help` lists the verb. | -| 4 | **Fixture coverage data + bundled killer recipe** — ship two fixtures: `fixtures/minimal/coverage/coverage-final.json` (Istanbul) and `fixtures/minimal/coverage/lcov.info` (LCOV) covering the same partial coverage shape (e.g. `usePermissions` 100%, `legacyClient` 0%, `now` 50%). Bundled recipe `templates/recipes/untested-and-dead.{sql,md}` exposes the killer recipe (top-of-doc query) as `codemap query --recipe untested-and-dead` — first-class agent surface, not a buried doc snippet. Golden recipes under `fixtures/golden/minimal/`: `coverage-istanbul.json`, `coverage-lcov.json`, `untested-and-dead.json` (all assert against the same expected row set, proving Istanbul ↔ LCOV equivalence). Adds the fixture rows + bundled recipe to `fixtures/minimal/README.md` "What's exercised" table. The pre-enriched `legacyClient` / `now` / `epochMs` deprecated symbols (PR #55) directly test the join axis. | Both ingesters produce identical golden rows; bundled recipe queryable via `--recipe untested-and-dead`; `--recipes-json` lists it. | -| 5 | **Doc + agent rule + skill + changeset + plan deletion** — `docs/architecture.md` § Persistence wiring (new table, ingester engine, CLI verb, both formats), `docs/glossary.md` (`coverage_pct`, "Istanbul JSON", "LCOV", "static coverage ingestion", "killer recipe `untested-and-dead`"), `.agents/rules/codemap.md` + `templates/agents/rules/codemap.md` (Rule 10 lockstep — new trigger pattern: "What's untested AND structurally dead?", `--recipe untested-and-dead` example, table reference for `coverage`), skill `SKILL.md` `coverage` table row, README.md "What's exercised" + Use section (`bun test --coverage` + `vitest --coverage` worked examples). Minor changeset (per D12). Plan deleted per `docs/README.md` Rule 3. | All docs consistent; agents see `coverage` table + bundled recipe on next `codemap agents init`. | +| # | Tracer | Acceptance | +| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | **Schema + bump** in `src/db.ts`. Add the `coverage` table DDL (natural-key PK per D6 — no FK to `symbols.id`); bump `SCHEMA_VERSION`; add `coverage` to the `query_baselines`-style "preserve across `--full`" exclusion in `dropAll()`. Add `idx_coverage_file_name`. New `meta` keys (`coverage_last_ingested_at` / `_path` / `_format`) need no DDL — `meta` is `(key, value)` already. Unit test (`src/db.test.ts`) inserts + reads back one row; verifies the table survives a `dropAll()` + `createSchema()` round-trip; verifies the orphan-cleanup DELETE removes rows whose `file_path` is no longer in `files`. | Table exists; survives `--full`; orphan sweep works. | +| 2a | **Shared engine core** in `src/application/coverage-engine.ts` (new). `upsertCoverageRows({db, rows, projectRoot}) → {ingested, skipped, pruned_orphans}` — format-agnostic: takes a normalised `CoverageRow[]` (`{file_path, line, hit_count}`-shape), maps each row to the **innermost** enclosing symbol via the SQL `(line_end - line_start) ASC LIMIT 1` projection (D7), upserts `coverage` keyed on `(file_path, name, line_start)`, writes the meta keys, runs the orphan-cleanup DELETE. Computes `coverage_pct = total_statements > 0 ? hit / total * 100 : NULL`. Reuses `toProjectRelative` from `validate-engine` (D8). Plus `ingestIstanbul({db, payload, projectRoot})` — parses the Istanbul shape (`{ [absPath]: {statementMap, s, fnMap, f, branchMap, b, path} }`), normalises to `CoverageRow[]`, calls `upsertCoverageRows`. Unit tests cover both the shared core (fresh ingest, re-ingest UPSERT idempotence, unmatched file, statement-outside-symbol, nested-closure-innermost-wins, `total_statements = 0 → NULL`, orphan cleanup) and the Istanbul parser (statement-map shape, absolute-path normalisation, Windows-path handling). | Both pieces pure; deterministic upsert; Istanbul parser tested in isolation against fixture JSON. | +| 2b | **LCOV parser** in `src/application/coverage-engine.ts` — `ingestLcov({db, payload, projectRoot})`. Pure regex tokenizer over the LCOV record format (`SF:` / `DA:,` / `end_of_record`); normalises to the same `CoverageRow[]`, calls `upsertCoverageRows` — zero new write-side code. Unit tests cover: well-formed LCOV with multiple `SF` records, `DA` lines with hit / miss / both, malformed records (missing `end_of_record` → error), unmatched file. Real `bun test --coverage` output captured into a fixture for round-trip testing. | Pure parser; passes round-trip on a real `bun test --coverage` artifact. | +| 3 | **CLI verb** `cli/cmd-ingest-coverage.ts` — parses `` + `--json`. Auto-detects format: file ending `.json` → istanbul, `.info` → lcov, directory → probes for `coverage-final.json` / `lcov.info` (errors if both or neither). Reads JSON via the established runtime split (`Bun.file(path).json()` on Bun for the native-parser perf win; `readFile + JSON.parse` on Node — see [`packaging.md § Node vs Bun`](../packaging.md#node-vs-bun), mirrors `config.ts`); reads LCOV via `Bun.file(path).text()` / `readFile`. Dispatches to `ingestIstanbul` or `ingestLcov`. `main.ts` dispatcher gains the verb between existing entries. Help text in `bootstrap.ts` lists the new command. Unit tests cover: file-not-found, malformed JSON / LCOV, ambiguous directory (both files), empty directory, `--json` envelope shape. | `bun src/index.ts ingest-coverage ` and `` both write rows; `--help` lists the verb. | +| 4 | **Fixture coverage data + bundled recipe shelf (D13)** — ship two fixtures: `fixtures/minimal/coverage/coverage-final.json` (Istanbul) and `fixtures/minimal/coverage/lcov.info` (LCOV) covering the same partial coverage shape (e.g. `usePermissions` 100%, `legacyClient` 0%, `now` 50%). Three bundled recipes in `templates/recipes/` per D13: `untested-and-dead.{sql,md}` (killer recipe, with the three name-collision narrowing patterns from D11 in the `.md`), `files-by-coverage.{sql,md}` (replaces the deferred `file_coverage` table per D2), `worst-covered-exports.{sql,md}` (top-N exported symbols by ascending coverage_pct). Each `.md` includes a frontmatter `actions` block for per-row agent hints. Five golden recipes under `fixtures/golden/minimal/`: `coverage-istanbul.json`, `coverage-lcov.json` (cross-format equivalence), `untested-and-dead.json`, `files-by-coverage.json`, `worst-covered-exports.json`. Adds fixtures + all three recipes to `fixtures/minimal/README.md` "What's exercised" table. The pre-enriched `legacyClient` / `now` / `epochMs` deprecated symbols (PR #55) directly test the join axis. | Both ingesters produce identical golden rows; all three recipes queryable via `--recipe`; `--recipes-json` lists all three. | +| 5 | **Doc + agent rule + skill + changeset + plan deletion** — `docs/architecture.md` § Persistence wiring (new table, ingester engine, CLI verb, both formats, three bundled recipes), `docs/glossary.md` (`coverage_pct`, "Istanbul JSON", "LCOV", "static coverage ingestion", `untested-and-dead`, `files-by-coverage`, `worst-covered-exports`), `.agents/rules/codemap.md` + `templates/agents/rules/codemap.md` (Rule 10 lockstep — three new trigger-pattern rows: "What's untested?" → `coverage` table, "What's untested AND structurally dead?" → `--recipe untested-and-dead`, "Rank files by coverage" → `--recipe files-by-coverage`; also a `coverage` row in the table-shape table), skill `SKILL.md` `coverage` table row + recipes section update, README.md "What's exercised" + Use section (`bun test --coverage` LCOV worked example + `vitest --coverage` Istanbul worked example). Minor changeset (per D12). Plan deleted per `docs/README.md` Rule 3. | All docs consistent; agents see the `coverage` table + all three bundled recipes + new trigger patterns on next `codemap agents init`. | ## Performance considerations