diff --git a/.changeset/evidence-chains-recipes.md b/.changeset/evidence-chains-recipes.md new file mode 100644 index 00000000..ae8573c2 --- /dev/null +++ b/.changeset/evidence-chains-recipes.md @@ -0,0 +1,5 @@ +--- +"@stainless-code/codemap": patch +--- + +Add `reason` and `evidence_json` columns on high-judgment recipe rows (`boundary-violations`, `deprecated-symbols`, `unimported-exports`) so agents can cite detection path before `apply` or manual edits. diff --git a/docs/architecture.md b/docs/architecture.md index 68bfd35c..26e8f994 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -190,6 +190,8 @@ Three **mutually exclusive** CLI entry shapes; all converge on `applyDiffPayload **Show / snippet wiring:** **`src/cli/show-snippet-args.ts`** (shared argv parser) + **`src/cli/show-snippet-render.ts`** (shared terminal/JSON error helpers) + **`src/cli/cmd-show.ts`** + **`src/cli/cmd-snippet.ts`** — sibling CLI verbs sharing the same parser shape (`` or **`--query ''`** + **`--with-fts`** + `--kind` + `--in ` + `--json`; show adds **`--print-sql`**) and the pure engines **`src/application/show-engine.ts`** (exact lookup + envelope builders), **`src/application/search-query-parser.ts`** + **`src/application/search-engine.ts`** (field-qualified search → parameterized SQL on `symbols`, optional `source_fts` join), and **`src/application/show-search-mode.ts`** (shared parse/normalize + FTS resolution + **`executeShowLookup`** + **`formatShowSearchSqlForQuery`** for CLI/MCP/HTTP). Exact lookup: `findSymbolsByName({db, name, kind?, inPath?})`. Query lookup: `searchSymbols({db, parsed, withFts?})`. Snippet FS read: `readSymbolSource({match, projectRoot, indexedContentHash?})` + `getIndexedContentHash(db, filePath)`. **`buildShowResult`** + **`buildSnippetResult`** envelope builders — same engines the MCP show/snippet tools call. Both verbs return the same `{matches, disambiguation?, warning?}` envelope — single match → `{matches: [{...}]}`; multi-match adds `{n, by_kind, files, hint}`; optional **`warning`** when FTS was requested but `source_fts` is empty. Snippet matches add `source` / `stale` / `missing` fields (additive — no shape divergence). **`--in `** and **`path:`** inside **`--query`** normalize through `toProjectRelative(projectRoot, p)` (from **`src/application/validate-engine.ts`**). Stale-file behavior on `snippet`: `hashContent` (from **`src/hash.ts`**) compares on-disk content against `files.content_hash`; mismatch sets `stale: true` but source IS still returned. MCP tools `show` and `snippet` register parallel to the CLI surface (see [§ MCP wiring](#cli-usage)). +**Evidence columns (high-judgment recipes):** Some bundled recipes add optional **`reason`** and **`evidence_json`** TEXT columns on each result row — factual detection path for agents, not pass/fail verdicts. Contract: [golden-queries.md § Evidence columns](./golden-queries.md#evidence-columns-high-judgment-recipes). + **Recipes wiring:** **`src/application/recipes-loader.ts`** (pure transport-agnostic loader) + **`src/application/query-recipes.ts`** (cache + public API — `getQueryRecipeSql` / `getQueryRecipeActions` / `getQueryRecipeParams` / `listQueryRecipeIds` / `listQueryRecipeCatalog` / `getQueryRecipeCatalogEntry`, shared by CLI + MCP). Recipes live as file pairs: **`.sql`** + optional **`.md`**. The loader reads `templates/recipes/` (bundled, ships in npm package next to `templates/agents/`) and `/recipes/` (project-local — default `.codemap/recipes/`; honors `--state-dir` / `CODEMAP_STATE_DIR`; root-only resolution per the registry plan, no walk-up). Project recipes win on id collision; entries that override a bundled id carry **`shadows: true`** in the catalog so agents reading `codemap://recipes` at session start see when a recipe behaves differently from the documented bundled version. Per-row **`actions`** templates and recipe **`params`** declarations live in YAML frontmatter on each `.md` — uniform shape across bundled + project. Param types are `string | number | boolean`; CLI passes values via repeatable `--params key=value[,key=value]`, MCP / HTTP pass nested `params: {key: value}` to `query_recipe`. Validation runs before SQL binding; missing / unknown / malformed params return the same `{error}` envelope as query failures. Hand-rolled YAML parser is scoped to block-list `actions:` and `params:` only (no `js-yaml` dep). Load-time validation rejects empty SQL and DML / DDL keywords (`INSERT` / `UPDATE` / `DELETE` / `DROP` / `CREATE` / `ALTER` / `ATTACH` / `DETACH` / `REPLACE` / `TRUNCATE` / `VACUUM` / `PRAGMA`) with recipe-aware error messages — defence in depth alongside the runtime `PRAGMA query_only=1` backstop in `query-engine.ts` (PR #35). `/index.db` is gitignored; `/recipes/` is NOT (verified via `git check-ignore`) — recipes are git-tracked source code authored for human review. **Tool / resource handlers (transport-agnostic):** **`src/application/tool-handlers.ts`** + **`src/application/resource-handlers.ts`** — pure functions that take the args object an MCP tool / resource URI accepts and return a discriminated **`ToolResult`** (`{ok: true, format: 'json'|'sarif'|'annotations'|'mermaid'|'diff'|'diff-json'|'codeclimate'|'badge', payload}` — badge arm also carries `badgeStyle`; `{ok: false, error}`) or a **`ResourcePayload`** (`{mimeType, text}`). MCP and HTTP both wrap the same handlers — MCP translates to `{content: [{type: "text", text}]}`, HTTP translates to `(status, body)` with the right `Content-Type`. Engine layer untouched; transport changes don't ripple into the SQL. diff --git a/docs/golden-queries.md b/docs/golden-queries.md index 9e94893f..2ee5aad2 100644 --- a/docs/golden-queries.md +++ b/docs/golden-queries.md @@ -66,6 +66,10 @@ Scenarios live in **`fixtures/golden/scenarios.json`** (Tier A) or optional **`s **Prompts** in JSON are **intent labels**, not pasted chat logs — pair with queries whose literals come from **fixture-owned** data (see [fixtures/qa/prompts.external.template.md](../fixtures/qa/prompts.external.template.md) for optional chat QA). +### Evidence columns (high-judgment recipes) + +Some bundled recipes add optional **`reason`** (TEXT) and **`evidence_json`** (TEXT, JSON array) columns on each row — factual detection path for agents, not engine verdicts. See [plans/evidence-chains-on-recipe-rows.md](./plans/evidence-chains-on-recipe-rows.md). Goldens assert these columns when the recipe ships evidence (`boundary-violations`, `deprecated-symbols`, `unimported-exports`). + --- ## Status diff --git a/docs/plans/agent-enrichment-wave.md b/docs/plans/agent-enrichment-wave.md new file mode 100644 index 00000000..03e8dfbc --- /dev/null +++ b/docs/plans/agent-enrichment-wave.md @@ -0,0 +1,94 @@ +# Agent enrichment wave — tracer workflow (plans 1–4) + +> **Status:** in-flight · **Scope:** four P2 plans ranked by consumer/agent ROI +> +> **Goal:** Ship tracer bullets that cut agent round-trips, improve answer trust, and sharpen PR/CI deltas — all Moat-A (predicate columns, no verdict primitives). +> +> **Plans (execution order):** [evidence-chains](./evidence-chains-on-recipe-rows.md) → [graph-estimated-crap](./graph-estimated-crap.md) → [coverage-deletion-confidence](./coverage-deletion-confidence.md) → [audit-delta-attribution](./audit-delta-attribution.md) + +--- + +## Shared conventions (locked) + +| Convention | Applies to | +| ------------------------------------------------------------------------------------- | ---------- | +| **Moat A** — no `pass`/`fail` engine verdict; extra columns only | All four | +| **`reason` TEXT** — machine code + short clause where useful | #1, #3 | +| **`evidence_json` TEXT** — bounded JSON array (≤3 hops) | #1 | +| **`confidence` / `coverage_source` / `attribution`** — recipe-specific enums | #2, #3, #4 | +| **Golden update per slice** — `fixtures/golden/minimal/*.json` + `scenarios.json` | All | +| **`/harden-pr lite`** after each tracer commit; **`/harden-pr full`** before PR merge | All | + +**Cross-plan synergy:** #1 `reason` on recipes complements #4 `attribution` on audit `added` rows (optional merge in evidence plan v2). #2 and #3 both touch coverage semantics — ship #2 before #3 so agents have CRAP tiers before deletion-confidence narrows rows. + +--- + +## Plan 1 — Evidence chains (`evidence-chains-on-recipe-rows.md`) + +| Slice | Deliverable | Verify | +| ----------------------------- | ------------------------------------------------------------------ | --------------------- | +| **1.0 contract** | `docs/golden-queries.md` § evidence columns; one architecture line | doc review | +| **1.1 `boundary-violations`** | `reason` + `evidence_json` in SQL; `.md` + golden | `bun run test:golden` | +| **1.2 `deprecated-symbols`** | caller hops in `evidence_json` | golden + matrix | +| **1.3 `unimported-exports`** | `re_export_chains` LEFT JOIN; `reason` variants | golden | +| **1.4 agent surface** | `templates/agent-content/rule/00-full.md` one-liner | consumer check | + +**Open decisions (locked for v1):** E.2 `evidence_json` only (not typed columns); E.1 SQL-only (no query-engine post-processor). + +--- + +## Plan 2 — Graph-estimated CRAP (`graph-estimated-crap.md`) + +| Slice | Deliverable | Verify | +| ------------------------- | --------------------------------------------------------------- | ----------------- | +| **2.0 spike** | Reachability CTE on `fixtures/minimal` (script or ad-hoc query) | manual row counts | +| **2.1 recipe** | `high-crap-score.sql` + `.md`; `scenarios.json` | `test:golden` | +| **2.2 measured override** | golden with `ingest-coverage` setup | golden matrix | +| **2.3 cross-link** | `high-complexity-untested.md` points at CRAP when no ingest | doc | + +**Grill before 2.1 if spike ambiguous:** Q1 type-only imports in walk (default: value edges only); Q2 recipe id `high-crap-score`. + +--- + +## Plan 3 — Coverage deletion confidence (`coverage-deletion-confidence.md`) + +| Slice | Deliverable | Verify | +| -------------------------- | -------------------------------------------------------------- | ------------- | +| **3.1 recipe fork** | `coverage-confirmed-dead.sql` + `.md` from `untested-and-dead` | query CLI | +| **3.2 golden no-ingest** | `confidence: medium` policy (per D.4) | `test:golden` | +| **3.3 golden with ingest** | fixture coverage → `confidence: high` | `test:golden` | +| **3.4 classifier** | intent keywords if needed | optional | + +**Grill before 3.1:** Q3 without ingest — `medium` rows vs empty + stderr (plan D.4 leans medium rows). + +--- + +## Plan 4 — Audit delta attribution (`audit-delta-attribution.md`) + +| Slice | Deliverable | Verify | +| -------------------------------- | --------------------------------------- | ---------------------- | +| **4.1 `findingKey()`** | pure helper + unit tests | `audit-engine.test.ts` | +| **4.2 `deprecated` delta** | `attribution` on `added[]` for `--base` | branch fixture test | +| **4.3 `files` + `dependencies`** | generalize key sets from base cache | tests | +| **4.4 transport parity** | MCP/HTTP envelope match CLI | handler test | +| **4.5 docs** | `architecture.md` envelope § | doc | + +--- + +## PR cadence + +| PR | Contents | Changeset | +| -------------------------- | ------------------------------------ | --------- | +| **#A Evidence wave 1** | Slices 1.0–1.1 (boundary-violations) | patch | +| **#B Evidence wave 2** | Slices 1.2–1.4 | patch | +| **#C CRAP recipe** | Plan 2 complete | patch | +| **#D Deletion confidence** | Plan 3 complete | patch | +| **#E Audit attribution** | Plan 4 complete | patch | + +Each PR: `harden-pr full` → merge. Do not batch plans 1–4 into one PR. + +--- + +## Current slice + +**Active:** Plan 1 shipped in [**PR #174**](https://github.com/stainless-code/codemap/pull/174) (awaiting merge) — next: Plan 2 spike **2.0** (`graph-estimated-crap.md`). diff --git a/docs/plans/evidence-chains-on-recipe-rows.md b/docs/plans/evidence-chains-on-recipe-rows.md index dd43d383..79acb6d1 100644 --- a/docs/plans/evidence-chains-on-recipe-rows.md +++ b/docs/plans/evidence-chains-on-recipe-rows.md @@ -35,7 +35,7 @@ Evidence is **in-SQL**, not a post-processor — same Moat-A path as the recipe. ### Tracer bullet (slice 1) -`boundary-violations`: add `reason` constant + `evidence_json` with rule tuple; one golden query. Ship before touching `unimported-exports` re-export subquery. +`boundary-violations`: add `reason` constant + `evidence_json` with rule tuple; one golden query. Ship before touching `unimported-exports` re-export subquery. **Orchestration:** [agent-enrichment-wave.md](./agent-enrichment-wave.md) § Plan 1 slice 1.1 — shipped `edeee68`. ### Out of scope (v1) @@ -98,21 +98,21 @@ Ship one recipe per wave; verify before moving to the next. ## Acceptance -- [ ] `codemap query --recipe unimported-exports --json` rows include `reason`; re-export false-positive class includes non-empty `evidence_json` when chain exists -- [ ] `boundary-violations` rows include stable `reason: boundary_deny_match` -- [ ] `deprecated-symbols` rows with callers include `evidence_json` caller hops -- [ ] Golden queries updated; no new CLI verb -- [ ] SARIF / annotations unchanged (extra columns ignored by formatters unless future mapping added) +- [x] `codemap query --recipe unimported-exports --json` rows include `reason`; re-export false-positive class includes non-empty `evidence_json` when chain exists +- [x] `boundary-violations` rows include stable `reason: boundary_deny_match` +- [x] `deprecated-symbols` rows with callers include `evidence_json` caller hops +- [x] Golden queries updated; no new CLI verb +- [x] SARIF / annotations unchanged (extra columns ignored by formatters unless future mapping added) --- ## Open decisions (impl PR) -| # | Question | -| --- | ------------------------------------------------------------------------------------------------- | -| Q1 | Single `evidence_json` vs separate typed columns (`reexport_hops`, `caller_count`)? | -| Q2 | Post-query enrichment in `query-engine` for recipes that opt in via frontmatter `evidence: true`? | -| Q3 | Include `binding_kind` from `bindings` for rename-preview synergy in v1 or v2? | +| # | Question | Lock (wave 2026-06) | +| --- | ------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| Q1 | Single `evidence_json` vs separate typed columns (`reexport_hops`, `caller_count`)? | **`evidence_json` only** (E.2) — one JSON contract per recipe row | +| Q2 | Post-query enrichment in `query-engine` for recipes that opt in via frontmatter `evidence: true`? | **SQL-only** (E.1) — no post-processor in v1 | +| Q3 | Include `binding_kind` from `bindings` for rename-preview synergy in v1 or v2? | **v2** | --- diff --git a/fixtures/golden/minimal/boundary-violations.json b/fixtures/golden/minimal/boundary-violations.json index 7b06f3d6..e67160e2 100644 --- a/fixtures/golden/minimal/boundary-violations.json +++ b/fixtures/golden/minimal/boundary-violations.json @@ -4,6 +4,8 @@ "to_path": "src/api/client.ts", "rule_name": "ui-no-api", "rule_from_glob": "src/components/**", - "rule_to_glob": "src/api/**" + "rule_to_glob": "src/api/**", + "reason": "boundary_deny_match", + "evidence_json": "[{\"rule_name\":\"ui-no-api\",\"from_glob\":\"src/components/**\",\"to_glob\":\"src/api/**\"}]" } ] diff --git a/fixtures/golden/minimal/call-resolution-stats.json b/fixtures/golden/minimal/call-resolution-stats.json index 887fce97..e12faf7e 100644 --- a/fixtures/golden/minimal/call-resolution-stats.json +++ b/fixtures/golden/minimal/call-resolution-stats.json @@ -1,7 +1,7 @@ [ { - "total_calls": 47, - "resolved_calls": 26, + "total_calls": 48, + "resolved_calls": 27, "method_calls_deferred": 20, "unresolved_queue": 1, "residual_meta": "1" diff --git a/fixtures/golden/minimal/components-touching-deprecated.json b/fixtures/golden/minimal/components-touching-deprecated.json index 568b2425..a751db23 100644 --- a/fixtures/golden/minimal/components-touching-deprecated.json +++ b/fixtures/golden/minimal/components-touching-deprecated.json @@ -1,4 +1,11 @@ [ + { + "component": "ProductCard", + "component_file": "src/components/shop/ProductCard.tsx", + "deprecated_symbol": "now", + "deprecated_file": "src/utils/date.ts", + "via": "call" + }, { "component": "ShopButton", "component_file": "src/components/shop/ShopButton.tsx", diff --git a/fixtures/golden/minimal/coverage-rows-after-ingest.json b/fixtures/golden/minimal/coverage-rows-after-ingest.json index 1f5c5f3e..20c8f2ff 100644 --- a/fixtures/golden/minimal/coverage-rows-after-ingest.json +++ b/fixtures/golden/minimal/coverage-rows-after-ingest.json @@ -2,8 +2,15 @@ { "file_path": "src/components/shop/ProductCard.tsx", "name": "ProductCard", - "hit_statements": 3, - "total_statements": 3, + "hit_statements": 2, + "total_statements": 2, + "coverage_pct": 100 + }, + { + "file_path": "src/components/shop/ProductCard.tsx", + "name": "spread", + "hit_statements": 1, + "total_statements": 1, "coverage_pct": 100 }, { diff --git a/fixtures/golden/minimal/deprecated-symbols.json b/fixtures/golden/minimal/deprecated-symbols.json index 9472f835..e15cd270 100644 --- a/fixtures/golden/minimal/deprecated-symbols.json +++ b/fixtures/golden/minimal/deprecated-symbols.json @@ -5,7 +5,9 @@ "file_path": "src/api/client.ts", "line_start": 46, "signature": "legacyClient()", - "doc_comment": "@deprecated Use `createClient({ baseUrl })` directly. Kept as a fixture for\n`deprecated-symbols` + `--format sarif` / `--format annotations` recipes." + "doc_comment": "@deprecated Use `createClient({ baseUrl })` directly. Kept as a fixture for\n`deprecated-symbols` + `--format sarif` / `--format annotations` recipes.", + "reason": "no_callers", + "evidence_json": "[]" }, { "name": "now", @@ -13,7 +15,9 @@ "file_path": "src/utils/date.ts", "line_start": 5, "signature": "now(): number", - "doc_comment": "@deprecated Use `Date.now()` directly. Kept as a fixture for the\n`deprecated-symbols` recipe golden test." + "doc_comment": "@deprecated Use `Date.now()` directly. Kept as a fixture for the\n`deprecated-symbols` recipe golden test.", + "reason": "has_callers", + "evidence_json": "[{\"kind\":\"caller\",\"name\":\"ProductCard\",\"file_path\":\"src/components/shop/ProductCard.tsx\",\"line_start\":12},{\"kind\":\"caller\",\"name\":\"ShopButton\",\"file_path\":\"src/components/shop/ShopButton.tsx\",\"line_start\":10},{\"kind\":\"caller\",\"name\":\"run\",\"file_path\":\"src/consumer.ts\",\"line_start\":27},{\"truncated\":true}]" }, { "name": "epochMs", @@ -21,6 +25,8 @@ "file_path": "src/utils/format.ts", "line_start": 5, "signature": "epochMs(): number", - "doc_comment": "@deprecated Drift detector for the SARIF / GH-annotations golden output.\nPair with `now()` in `./date.ts` to give recipes >1 row to render." + "doc_comment": "@deprecated Drift detector for the SARIF / GH-annotations golden output.\nPair with `now()` in `./date.ts` to give recipes >1 row to render.", + "reason": "has_callers", + "evidence_json": "[{\"kind\":\"caller\",\"name\":\"run\",\"file_path\":\"src/consumer.ts\",\"line_start\":30}]" } ] diff --git a/fixtures/golden/minimal/files-hashes.json b/fixtures/golden/minimal/files-hashes.json index 6e6f6825..a6976a20 100644 --- a/fixtures/golden/minimal/files-hashes.json +++ b/fixtures/golden/minimal/files-hashes.json @@ -115,9 +115,9 @@ }, { "path": "src/components/shop/ProductCard.tsx", - "content_hash": "27b25a905655574a3002adc140b36e91b95f8f1a5db0758ba172115595134122", + "content_hash": "130e72381a5b03071f22f161e178d2800c48124d1bbc33359520218ad48e3a1e", "language": "tsx", - "line_count": 23 + "line_count": 24 }, { "path": "src/components/shop/ShopButton.default.ts", diff --git a/fixtures/golden/minimal/files-largest.json b/fixtures/golden/minimal/files-largest.json index cd5cd4c3..8587c12a 100644 --- a/fixtures/golden/minimal/files-largest.json +++ b/fixtures/golden/minimal/files-largest.json @@ -43,8 +43,8 @@ }, { "path": "src/components/shop/ProductCard.tsx", - "line_count": 23, - "size": 654, + "line_count": 24, + "size": 748, "language": "tsx" }, { diff --git a/fixtures/golden/minimal/find-export-sites.json b/fixtures/golden/minimal/find-export-sites.json index 82d3099b..d6991e89 100644 --- a/fixtures/golden/minimal/find-export-sites.json +++ b/fixtures/golden/minimal/find-export-sites.json @@ -7,7 +7,7 @@ "is_re_export": 0, "re_export_source": null, "line_start": 10, - "line_end": 22, + "line_end": 23, "column_start": 16, "column_end": 27 }, diff --git a/fixtures/golden/minimal/find-jsx-usages.json b/fixtures/golden/minimal/find-jsx-usages.json index c81d8216..96bd89d3 100644 --- a/fixtures/golden/minimal/find-jsx-usages.json +++ b/fixtures/golden/minimal/find-jsx-usages.json @@ -2,8 +2,8 @@ { "file_path": "src/components/shop/ProductCard.tsx", "component_name": "article", - "line_start": 15, - "line_end": 19, + "line_start": 16, + "line_end": 20, "is_self_closing": 0, "is_fragment": 0, "is_lowercase": 1, diff --git a/fixtures/golden/minimal/find-symbol-definitions.json b/fixtures/golden/minimal/find-symbol-definitions.json index a9a9b567..52c331ca 100644 --- a/fixtures/golden/minimal/find-symbol-definitions.json +++ b/fixtures/golden/minimal/find-symbol-definitions.json @@ -4,7 +4,7 @@ "name": "ProductCard", "kind": "function", "line_start": 10, - "line_end": 22, + "line_end": 23, "name_column_start": 16, "name_column_end": 27, "parent_name": null, diff --git a/fixtures/golden/minimal/index-summary.json b/fixtures/golden/minimal/index-summary.json index eb7196e6..6b95d210 100644 --- a/fixtures/golden/minimal/index-summary.json +++ b/fixtures/golden/minimal/index-summary.json @@ -1,7 +1,7 @@ [ { "files": 42, - "symbols": 105, + "symbols": 106, "imports": 25, "components": 5, "dependencies": 23 diff --git a/fixtures/golden/minimal/index-table-stats.json b/fixtures/golden/minimal/index-table-stats.json index 785c7b4d..46cb62ab 100644 --- a/fixtures/golden/minimal/index-table-stats.json +++ b/fixtures/golden/minimal/index-table-stats.json @@ -1,7 +1,7 @@ [ { "files": 42, - "symbols": 105, + "symbols": 106, "imports": 25, "exports": 62, "components": 5, @@ -9,13 +9,13 @@ "markers": 7, "type_members": 13, "type_heritage": 11, - "calls": 47, + "calls": 48, "css_vars": 2, "css_classes": 2, "css_keyframes": 1, "scopes": 107, - "ref_count": 315, - "bindings": 272, + "ref_count": 317, + "bindings": 274, "import_specifiers": 30, "function_params": 18, "runtime_markers": 6, diff --git a/fixtures/golden/minimal/migrate-deprecated.json b/fixtures/golden/minimal/migrate-deprecated.json index 99e09c38..50d3c893 100644 --- a/fixtures/golden/minimal/migrate-deprecated.json +++ b/fixtures/golden/minimal/migrate-deprecated.json @@ -1,4 +1,13 @@ [ + { + "file_path": "src/components/shop/ProductCard.tsx", + "line_start": 12, + "line_end": 12, + "before_pattern": "now", + "after_pattern": "Date.now", + "location_kind": "call_site", + "chain_depth": 0 + }, { "file_path": "src/components/shop/ShopButton.tsx", "line_start": 10, diff --git a/fixtures/golden/minimal/migrate-jsx-prop-product-card.json b/fixtures/golden/minimal/migrate-jsx-prop-product-card.json index 9861b175..bea48680 100644 --- a/fixtures/golden/minimal/migrate-jsx-prop-product-card.json +++ b/fixtures/golden/minimal/migrate-jsx-prop-product-card.json @@ -1,8 +1,8 @@ [ { "file_path": "src/components/shop/ProductCard.tsx", - "line_start": 15, - "line_end": 15, + "line_start": 16, + "line_end": 16, "before_pattern": "data-id=", "after_pattern": "data-testid=", "location_kind": "jsx_attribute", diff --git a/fixtures/golden/minimal/refactor-risk-ranking.json b/fixtures/golden/minimal/refactor-risk-ranking.json index 3e0f913f..55a9a623 100644 --- a/fixtures/golden/minimal/refactor-risk-ranking.json +++ b/fixtures/golden/minimal/refactor-risk-ranking.json @@ -228,7 +228,7 @@ "exported_count": 1, "fan_in": 0, "avg_coverage_pct": 100, - "measured_symbols": 1, + "measured_symbols": 2, "risk_score": 0 }, { diff --git a/fixtures/golden/minimal/rename-preview-product-card.json b/fixtures/golden/minimal/rename-preview-product-card.json index f36265ff..beebb6a6 100644 --- a/fixtures/golden/minimal/rename-preview-product-card.json +++ b/fixtures/golden/minimal/rename-preview-product-card.json @@ -2,7 +2,7 @@ { "file_path": "src/components/shop/ProductCard.tsx", "line_start": 10, - "line_end": 22, + "line_end": 23, "before_pattern": "ProductCard", "after_pattern": "ProductCardNext", "location_kind": "definition", diff --git a/fixtures/golden/minimal/stale-imports.json b/fixtures/golden/minimal/stale-imports.json index eedfd061..fe51488c 100644 --- a/fixtures/golden/minimal/stale-imports.json +++ b/fixtures/golden/minimal/stale-imports.json @@ -1,11 +1 @@ -[ - { - "file_path": "src/components/shop/ProductCard.tsx", - "line_start": 2, - "line_end": 2, - "before_pattern": "import { now } from \"../../utils/date\"", - "after_pattern": "", - "location_kind": "import_line", - "chain_depth": 0 - } -] +[] diff --git a/fixtures/golden/minimal/unimported-exports.json b/fixtures/golden/minimal/unimported-exports.json index ebf34067..e8b86c33 100644 --- a/fixtures/golden/minimal/unimported-exports.json +++ b/fixtures/golden/minimal/unimported-exports.json @@ -4,272 +4,350 @@ "kind": "type", "file_path": "src/api/client.ts", "is_default": 0, - "re_export_source": null + "re_export_source": null, + "reason": "no_direct_import", + "evidence_json": "[]" }, { "name": "handshake", "kind": "value", "file_path": "src/api/client.ts", "is_default": 0, - "re_export_source": null + "re_export_source": null, + "reason": "no_direct_import", + "evidence_json": "[]" }, { "name": "legacyClient", "kind": "value", "file_path": "src/api/client.ts", "is_default": 0, - "re_export_source": null + "re_export_source": null, + "reason": "no_direct_import", + "evidence_json": "[]" }, { "name": "openSocket", "kind": "value", "file_path": "src/api/client.ts", "is_default": 0, - "re_export_source": null + "re_export_source": null, + "reason": "no_direct_import", + "evidence_json": "[]" }, { "name": "setupTransport", "kind": "value", "file_path": "src/api/client.ts", "is_default": 0, - "re_export_source": null + "re_export_source": null, + "reason": "no_direct_import", + "evidence_json": "[]" }, { "name": "useHelperA", "kind": "value", "file_path": "src/bench/homonym-consumer-a.ts", "is_default": 0, - "re_export_source": null + "re_export_source": null, + "reason": "no_direct_import", + "evidence_json": "[]" }, { "name": "useHelperB", "kind": "value", "file_path": "src/bench/homonym-consumer-b.ts", "is_default": 0, - "re_export_source": null + "re_export_source": null, + "reason": "no_direct_import", + "evidence_json": "[]" }, { "name": "MemberHost", "kind": "value", "file_path": "src/bench/jsx-member-gap.tsx", "is_default": 0, - "re_export_source": null + "re_export_source": null, + "reason": "no_direct_import", + "evidence_json": "[]" }, { "name": "PageShell", "kind": "value", "file_path": "src/bench/jsx-synthesis/PageShell.tsx", "is_default": 0, - "re_export_source": null + "re_export_source": null, + "reason": "no_direct_import", + "evidence_json": "[]" }, { "name": "UiPanel", "kind": "value", "file_path": "src/bench/jsx-ui-namespace.ts", "is_default": 0, - "re_export_source": null + "re_export_source": null, + "reason": "no_direct_import", + "evidence_json": "[]" }, { "name": "PingService", "kind": "value", "file_path": "src/bench/method-call-sites.ts", "is_default": 0, - "re_export_source": null + "re_export_source": null, + "reason": "no_direct_import", + "evidence_json": "[]" }, { "name": "staleMultiOut", "kind": "value", "file_path": "src/bench/stale-multi-import.ts", "is_default": 0, - "re_export_source": null + "re_export_source": null, + "reason": "no_direct_import", + "evidence_json": "[]" }, { "name": "ApiBridge", "kind": "value", "file_path": "src/components/shop/ApiBridge.tsx", "is_default": 0, - "re_export_source": null + "re_export_source": null, + "reason": "no_direct_import", + "evidence_json": "[]" }, { "name": "ProductCard", "kind": "value", "file_path": "src/components/shop/ProductCard.tsx", "is_default": 0, - "re_export_source": null + "re_export_source": null, + "reason": "reexport_chain_possible", + "evidence_json": "[{\"kind\":\"reexport\",\"from_file\":\"src/components/shop/index.ts\",\"to_file\":\"src/components/shop/ProductCard.tsx\",\"hops\":1,\"truncated\":0}]" }, { "name": "FormatPrice", "kind": "value", "file_path": "src/components/shop/ShopButton.tsx", "is_default": 0, - "re_export_source": null + "re_export_source": null, + "reason": "reexport_chain_possible", + "evidence_json": "[{\"kind\":\"reexport\",\"from_file\":\"src/components/shop/index.ts\",\"to_file\":\"src/components/shop/ShopButton.tsx\",\"hops\":1,\"truncated\":0}]" }, { "name": "prefetch", "kind": "value", "file_path": "src/consumer.ts", "is_default": 0, - "re_export_source": null + "re_export_source": null, + "reason": "no_direct_import", + "evidence_json": "[]" }, { "name": "run", "kind": "value", "file_path": "src/consumer.ts", "is_default": 0, - "re_export_source": null + "re_export_source": null, + "reason": "no_direct_import", + "evidence_json": "[]" }, { "name": "fixtureApiKey", "kind": "value", "file_path": "src/env.ts", "is_default": 0, - "re_export_source": null + "re_export_source": null, + "reason": "no_direct_import", + "evidence_json": "[]" }, { "name": "nodeEnv", "kind": "value", "file_path": "src/env.ts", "is_default": 0, - "re_export_source": null + "re_export_source": null, + "reason": "no_direct_import", + "evidence_json": "[]" }, { "name": "catchDirectRethrow", "kind": "value", "file_path": "src/lib/complexity-fixture.ts", "is_default": 0, - "re_export_source": null + "re_export_source": null, + "reason": "no_direct_import", + "evidence_json": "[]" }, { "name": "catchInnerArrowRethrow", "kind": "value", "file_path": "src/lib/complexity-fixture.ts", "is_default": 0, - "re_export_source": null + "re_export_source": null, + "reason": "no_direct_import", + "evidence_json": "[]" }, { "name": "deeplyNested", "kind": "value", "file_path": "src/lib/complexity-fixture.ts", "is_default": 0, - "re_export_source": null + "re_export_source": null, + "reason": "no_direct_import", + "evidence_json": "[]" }, { "name": "ignoredExport", "kind": "value", "file_path": "src/orphan.ts", "is_default": 0, - "re_export_source": null + "re_export_source": null, + "reason": "no_direct_import", + "evidence_json": "[]" }, { "name": "orphanHelper", "kind": "value", "file_path": "src/orphan.ts", "is_default": 0, - "re_export_source": null + "re_export_source": null, + "reason": "no_direct_import", + "evidence_json": "[]" }, { "name": "ImportedMammal", "kind": "type", "file_path": "src/types/heritage-qualified.ts", "is_default": 0, - "re_export_source": null + "re_export_source": null, + "reason": "no_direct_import", + "evidence_json": "[]" }, { "name": "QualifiedChild", "kind": "type", "file_path": "src/types/heritage-qualified.ts", "is_default": 0, - "re_export_source": null + "re_export_source": null, + "reason": "no_direct_import", + "evidence_json": "[]" }, { "name": "Both", "kind": "type", "file_path": "src/types/hierarchy.ts", "is_default": 0, - "re_export_source": null + "re_export_source": null, + "reason": "no_direct_import", + "evidence_json": "[]" }, { "name": "Dog", "kind": "value", "file_path": "src/types/hierarchy.ts", "is_default": 0, - "re_export_source": null + "re_export_source": null, + "reason": "no_direct_import", + "evidence_json": "[]" }, { "name": "GenericBoth", "kind": "type", "file_path": "src/types/hierarchy.ts", "is_default": 0, - "re_export_source": null + "re_export_source": null, + "reason": "no_direct_import", + "evidence_json": "[]" }, { "name": "Mammal", "kind": "type", "file_path": "src/types/hierarchy.ts", "is_default": 0, - "re_export_source": null + "re_export_source": null, + "reason": "no_direct_import", + "evidence_json": "[]" }, { "name": "Pet", "kind": "type", "file_path": "src/types/hierarchy.ts", "is_default": 0, - "re_export_source": null + "re_export_source": null, + "reason": "no_direct_import", + "evidence_json": "[]" }, { "name": "Mammal", "kind": "type", "file_path": "src/types/homonym-mammal.ts", "is_default": 0, - "re_export_source": null + "re_export_source": null, + "reason": "no_direct_import", + "evidence_json": "[]" }, { "name": "OrderStatus", "kind": "value", "file_path": "src/types/status.ts", "is_default": 0, - "re_export_source": null + "re_export_source": null, + "reason": "no_direct_import", + "evidence_json": "[]" }, { "name": "CycleA", "kind": "type", "file_path": "src/types/type-cycle.ts", "is_default": 0, - "re_export_source": null + "re_export_source": null, + "reason": "no_direct_import", + "evidence_json": "[]" }, { "name": "CycleB", "kind": "type", "file_path": "src/types/type-cycle.ts", "is_default": 0, - "re_export_source": null + "re_export_source": null, + "reason": "no_direct_import", + "evidence_json": "[]" }, { "name": "_epochSeconds", "kind": "value", "file_path": "src/utils/date.ts", "is_default": 0, - "re_export_source": null + "re_export_source": null, + "reason": "no_direct_import", + "evidence_json": "[]" }, { "name": "_hiResEpoch", "kind": "value", "file_path": "src/utils/date.ts", "is_default": 0, - "re_export_source": null + "re_export_source": null, + "reason": "no_direct_import", + "evidence_json": "[]" }, { "name": "nanoseconds", "kind": "value", "file_path": "src/utils/date.ts", "is_default": 0, - "re_export_source": null + "re_export_source": null, + "reason": "no_direct_import", + "evidence_json": "[]" }, { "name": "nowIso", "kind": "value", "file_path": "src/utils/format.ts", "is_default": 0, - "re_export_source": null + "re_export_source": null, + "reason": "no_direct_import", + "evidence_json": "[]" } ] diff --git a/fixtures/minimal/src/components/shop/ProductCard.tsx b/fixtures/minimal/src/components/shop/ProductCard.tsx index ef571dc4..12039cae 100644 --- a/fixtures/minimal/src/components/shop/ProductCard.tsx +++ b/fixtures/minimal/src/components/shop/ProductCard.tsx @@ -9,6 +9,7 @@ interface ProductCardProps { // React component fixture — JSX substrate (fragments, attrs, nesting, lowercase tags). export function ProductCard(props: ProductCardProps) { const perms = usePermissions(); + const _truncationFixture = now(); // fourth AST call site — deprecated-symbols E.3 golden const spread = { className: "card" }; return ( <> diff --git a/templates/agent-content/rule/00-full.md b/templates/agent-content/rule/00-full.md index be317494..79599d80 100644 --- a/templates/agent-content/rule/00-full.md +++ b/templates/agent-content/rule/00-full.md @@ -20,6 +20,8 @@ codemap query --recipes-json # canonical list of every bundled + p **Row count:** no cap; add `LIMIT` and `ORDER BY` when you need bounded output. On failure, stdout is `{"error": "..."}` and the process exits 1. +**Evidence columns:** Some recipe rows (e.g. `boundary-violations`, `deprecated-symbols`, `unimported-exports`) add **`reason`** and **`evidence_json`** — factual detection path for agents, not pass/fail verdicts. + ## Trigger patterns If the question matches any of these, use the index instead of grepping: diff --git a/templates/agent-content/skill/10-recipes-context.md b/templates/agent-content/skill/10-recipes-context.md index 78e59ffc..ace45f7f 100644 --- a/templates/agent-content/skill/10-recipes-context.md +++ b/templates/agent-content/skill/10-recipes-context.md @@ -16,6 +16,7 @@ Replace placeholders (`'...'`) with your module path, file glob, or symbol name. - **`--save-baseline[=]`** — snapshot the result rows to the **`query_baselines`** table inside `/index.db` (default `.codemap/index.db`; no parallel JSON files; survives `--full` and SCHEMA bumps). Name defaults to the `--recipe` id; ad-hoc SQL needs an explicit `=`. Re-saving with the same name overwrites in place. - **`--baseline[=]`** — diff the current result against the saved baseline. Output `{baseline:{...}, current_row_count, added: [...], removed: [...]}` (with `--json`) or a two-section terminal dump. Identity = per-row multiset equality (canonical `JSON.stringify` keyed frequency map; duplicates preserved). Pair with `--summary` for `{baseline:{...}, current_row_count, added: N, removed: N}`. **Mutually exclusive with `--group-by`.** - **`--baselines`** lists saved baselines (no `rows_json` payload); **`--drop-baseline `** deletes one. Both reject every other flag — they're list-only / drop-only operations. +- **Evidence columns** — high-judgment recipes (`boundary-violations`, `deprecated-symbols`, `unimported-exports`, …) may add **`reason`** and **`evidence_json`** on each row — factual detection path; parse before `apply` or deletion. - **Per-row recipe `actions`** — recipes that define an **`actions: [{type, auto_fixable?, description?, command?}]`** template append it to every row in **`--json`** output (recipe-only; ad-hoc SQL never carries actions). Rendered **`command`** lines substitute `{{param}}` from bound recipe params — param **names vary by recipe** (`old`/`new` on `rename-preview`; `old_source`/`new_source` on `migrate-import-source`; `symbol`/`replacement` on `migrate-deprecated`; see each `.md` frontmatter). Under `--baseline`, actions attach to the **`added`** rows only (the rows the agent should act on). Inspect via **`--recipes-json`**. - **Boundary violations (config-driven)** — declare `boundaries: [{name, from_glob, to_glob, action?}]` in `.codemap/config.ts` and run `codemap query --recipe boundary-violations [--format sarif|codeclimate|badge]`. GitLab CI: `--format codeclimate`; README/CI summary: `--format badge`. The `action` field defaults to `"deny"` (the only shape v1 surfaces); rules are reconciled into the `boundary_rules` table on every index pass and joined against `dependencies` via SQLite `GLOB`. - **Project-local recipes** — drop **`.sql`** (and optional **`.md`** for description body, params, and actions) into **`/recipes/`** (default `.codemap/recipes/`; honors `--state-dir` / `CODEMAP_STATE_DIR`) to make team-internal SQL a first-class CLI verb. `--recipes-json` and the `codemap://recipes` MCP resource list project recipes alongside bundled ones with **`source: "bundled" | "project"`** discriminating them. Project recipes win on id collision; entries that override a bundled id carry **`shadows: true`** so agents reading the catalog at session start know when a recipe behaves differently from the documented bundled version. `.md` supports YAML frontmatter for `params:` and per-row `actions:` — **block-list shape only** (loader's hand-rolled parser; no inline-flow `[{...}]`). Param types: `string | number | boolean`; pass values with `--params key=value[,key=value]` (repeatable; last value wins). Example: `codemap query --json --recipe find-symbol-by-kind --params kind=function,name_pattern=%Query%`. Validation: SQL is rejected at load time if it starts with DML/DDL (DELETE/DROP/UPDATE/etc.); params validate before SQL binding; runtime `PRAGMA query_only=1` is the parser-proof backstop. `/index.db` is gitignored; **`/recipes/` is NOT** — recipes are git-tracked source code authored for human review. diff --git a/templates/recipes/boundary-violations.md b/templates/recipes/boundary-violations.md index 1c69da97..17238cd8 100644 --- a/templates/recipes/boundary-violations.md +++ b/templates/recipes/boundary-violations.md @@ -8,6 +8,8 @@ actions: Surfaces resolved import edges from `dependencies` that match a `deny` rule declared under `boundaries:` in `.codemap/config.ts`. Each row is one violation: a `from_path` file (matching `rule_from_glob`) imports a `to_path` file (matching `rule_to_glob`). +Rows include **`reason`** (`boundary_deny_match`) and **`evidence_json`** (JSON array with the matched rule tuple) so agents can cite why the edge fired without re-querying `boundary_rules`. + ## Configure `.codemap/config.ts`: diff --git a/templates/recipes/boundary-violations.sql b/templates/recipes/boundary-violations.sql index fa77f969..fda977b6 100644 --- a/templates/recipes/boundary-violations.sql +++ b/templates/recipes/boundary-violations.sql @@ -3,7 +3,18 @@ SELECT d.to_path, b.name AS rule_name, b.from_glob AS rule_from_glob, - b.to_glob AS rule_to_glob + b.to_glob AS rule_to_glob, + 'boundary_deny_match' AS reason, + json_array( + json_object( + 'rule_name', + b.name, + 'from_glob', + b.from_glob, + 'to_glob', + b.to_glob + ) + ) AS evidence_json FROM dependencies d JOIN boundary_rules b ON b.action = 'deny' diff --git a/templates/recipes/deprecated-symbols.md b/templates/recipes/deprecated-symbols.md index 8f5b6fc4..81959160 100644 --- a/templates/recipes/deprecated-symbols.md +++ b/templates/recipes/deprecated-symbols.md @@ -1,7 +1,7 @@ --- actions: - type: flag-caller - description: "Warn before suggesting changes that depend on this symbol; check callers via the calls table." + description: "Warn before suggesting changes that depend on this symbol; row `evidence_json` lists up to three AST caller hops (see `reason`)." - type: apply-migrate-deprecated description: "Rewrite call/import sites — replace SYMBOL with row `name`, REPLACEMENT with the new identifier." command: codemap apply migrate-deprecated --params symbol=SYMBOL,replacement=REPLACEMENT --dry-run @@ -15,7 +15,9 @@ actions: # deprecated-symbols -Symbols whose JSDoc contains @deprecated (caller-warning candidates). +Symbols whose JSDoc contains @deprecated (caller-warning candidates). Rows include **`reason`** (`has_callers` \| `no_callers`) and **`evidence_json`** (up to three AST caller hops from `calls`; appends `{"truncated":true}` when more callers exist) so agents can gauge blast radius without a separate `find-call-sites` round-trip. + +**Name-only caller match** (same as `untested-and-dead`): `callee_name = s.name` without `file_path` — homonyms like `now()` may list callers of other symbols named `now`. Narrow with `file_path` in ad-hoc SQL when needed. ```bash codemap query --recipe deprecated-symbols --format json diff --git a/templates/recipes/deprecated-symbols.sql b/templates/recipes/deprecated-symbols.sql index 79c76bd5..462e6223 100644 --- a/templates/recipes/deprecated-symbols.sql +++ b/templates/recipes/deprecated-symbols.sql @@ -1,5 +1,66 @@ -SELECT name, kind, file_path, line_start, signature, doc_comment -FROM symbols -WHERE doc_comment LIKE '%@deprecated%' -ORDER BY file_path ASC, line_start ASC -LIMIT 50 +SELECT + s.name, + s.kind, + s.file_path, + s.line_start, + s.signature, + s.doc_comment, + CASE + WHEN EXISTS ( + SELECT 1 + FROM calls c + WHERE c.callee_name = s.name + AND (c.provenance IS NULL OR c.provenance = 'ast') + ) + THEN 'has_callers' + ELSE 'no_callers' + END AS reason, + COALESCE( + ( + SELECT + CASE + WHEN caller_total > 3 + THEN json_insert(caller_hops, '$[#]', json_object('truncated', json('true'))) + ELSE caller_hops + END + FROM ( + SELECT + ( + SELECT COUNT(*) + FROM calls c + WHERE c.callee_name = s.name + AND (c.provenance IS NULL OR c.provenance = 'ast') + ) AS caller_total, + COALESCE( + ( + SELECT json_group_array( + json_object( + 'kind', + 'caller', + 'name', + caller_name, + 'file_path', + file_path, + 'line_start', + line_start + ) + ) + FROM ( + SELECT c.caller_name, c.file_path, c.line_start + FROM calls c + WHERE c.callee_name = s.name + AND (c.provenance IS NULL OR c.provenance = 'ast') + ORDER BY c.file_path, c.line_start + LIMIT 3 + ) + ), + '[]' + ) AS caller_hops + ) + ), + '[]' + ) AS evidence_json +FROM symbols s +WHERE s.doc_comment LIKE '%@deprecated%' +ORDER BY s.file_path ASC, s.line_start ASC +LIMIT 50; diff --git a/templates/recipes/unimported-exports.md b/templates/recipes/unimported-exports.md index 59de9f8c..cecada41 100644 --- a/templates/recipes/unimported-exports.md +++ b/templates/recipes/unimported-exports.md @@ -2,22 +2,24 @@ actions: - type: review-for-deletion auto_fixable: false - description: "Export with no detectable import — candidate for deletion. VERIFY against the v1 caveats below before deleting; codemap's import-resolution doesn't follow re-export chains or `tsconfig.json` path aliases that the resolver can't resolve." + description: "Export with no detectable direct import — candidate for deletion. Check row `reason` / `evidence_json` for barrel false positives; read v1 caveats (unresolved imports, default exports) before deleting." --- Exports that have no row in `imports` referencing their file AND name. Surfaces the **direct-use-only** subset of "unused exports" — useful as a starting candidate list, but **NEVER as a "safe to delete" list** without manual verification. +Rows include **`reason`** (`no_direct_import` \| `reexport_chain_possible`) and **`evidence_json`** (barrel hops from `re_export_chains` when a re-export path may explain the false positive). + ## V1 limitations (false-positive classes) The recipe ships intentionally simple. Three known classes of false positive: -1. **Re-export chains** — codemap's `exports.re_export_source` column tracks barrel-style `export { foo } from './foo'` re-exports, but this v1 recipe **does not follow the chain**. If `src/index.ts` re-exports `bar` from `src/bar.ts`, and consumers `import { bar } from '~/'` (hitting `src/index.ts`), this recipe falsely flags `bar` in `src/bar.ts` as unimported. Workaround: filter out rows with `re_export_source IS NOT NULL` in a project-local override, OR cross-check against `barrel-files` recipe output. +1. **Re-export chains** — the recipe still matches **direct** `imports` → `exports` only; it does not walk consumers through barrels. If `src/index.ts` re-exports `bar` from `src/bar.ts`, and consumers import `bar` from the barrel, `bar` in `src/bar.ts` can still appear as unimported. Rows with a matching `re_export_chains` hop get **`reason=reexport_chain_possible`** and barrel hops in **`evidence_json`** — triage those before deletion; they are not exclusions from the result set. 2. **Unresolved imports** — when `imports.resolved_path IS NULL` (e.g. `tsconfig.json` path aliases codemap's resolver can't resolve, or external-package imports), those rows are ignored. If the unresolved import actually targets the export, it's a false positive. Codemap's resolver covers most TS / JS shapes; this is a corner case for unusual config. 3. **Default exports skipped** — `is_default = 0` filter. Default exports are commonly framework entry points (Next.js `page.tsx`, Storybook stories, `vite.config.ts`) that codemap doesn't model; flagging them produces high false-positive noise. To include them, drop the `AND e.is_default = 0` clause in a project-local override. ## What's NOT covered (orthogonal recipes) -- **Re-export chain handling** — wait for a future recipe with recursive CTE walking `re_export_source` (v1 filters `kind != 're-export'` and documents barrel false positives above). +- **Full re-export reachability** — v1 annotates likely barrel false positives via `reason` / `evidence_json`; it does not recursively prove zero consumers through every barrel path (see `barrel-chains` for chain inventory). - **Component-touching-deprecated** style cross-checks — not applicable here; this recipe is about EXPORTS, not symbol references inside files. ## Tuning axes for project-local overrides diff --git a/templates/recipes/unimported-exports.sql b/templates/recipes/unimported-exports.sql index 14d334ef..cc2588e2 100644 --- a/templates/recipes/unimported-exports.sql +++ b/templates/recipes/unimported-exports.sql @@ -5,8 +5,7 @@ -- V1 limitations (documented in unimported-exports.md): -- 1. Re-export chains: if A re-exports `bar` from B, and consumers import `bar` -- from A, the recipe doesn't follow the chain — false positive on B.bar. --- Workaround: skip rows with `kind = 're-export'` or hand-check via --- `re_export_source` column. +-- Rows with a matching `re_export_chains` hop get `reason=reexport_chain_possible`. -- 2. Unresolved imports (`resolved_path IS NULL`, e.g. tsconfig path aliases -- that codemap's resolver can't resolve) get IGNORED — false positives if -- they actually reference an export. @@ -18,22 +17,88 @@ WITH direct_uses AS ( JOIN imports i ON i.resolved_path = e.file_path CROSS JOIN json_each(i.specifiers) j WHERE j.value = e.name OR j.value = '*' +), +unimported AS ( + -- File-scope suppressions only — `exports` has no line_number column. + SELECT + e.name, + e.kind, + e.file_path, + e.is_default, + e.re_export_source + FROM exports e + LEFT JOIN suppressions s + ON s.file_path = e.file_path + AND s.recipe_id = 'unimported-exports' + AND s.line_number = 0 + WHERE e.id NOT IN (SELECT id FROM direct_uses) + AND e.is_default = 0 + AND e.kind != 're-export' + AND s.id IS NULL ) --- File-scope suppressions only — `exports` has no line_number column. SELECT - e.name, - e.kind, - e.file_path, - e.is_default, - e.re_export_source -FROM exports e -LEFT JOIN suppressions s - ON s.file_path = e.file_path - AND s.recipe_id = 'unimported-exports' - AND s.line_number = 0 -WHERE e.id NOT IN (SELECT id FROM direct_uses) - AND e.is_default = 0 - AND e.kind != 're-export' - AND s.id IS NULL -ORDER BY e.file_path, e.name + u.name, + u.kind, + u.file_path, + u.is_default, + u.re_export_source, + CASE + WHEN EXISTS ( + SELECT 1 + FROM re_export_chains r + WHERE r.to_file = u.file_path + AND r.to_name = u.name + ) + THEN 'reexport_chain_possible' + ELSE 'no_direct_import' + END AS reason, + COALESCE( + ( + SELECT + CASE + WHEN chain_total > 3 + THEN json_insert(chain_hops, '$[#]', json_object('truncated', json('true'))) + ELSE chain_hops + END + FROM ( + SELECT + ( + SELECT COUNT(*) + FROM re_export_chains r + WHERE r.to_file = u.file_path + AND r.to_name = u.name + ) AS chain_total, + COALESCE( + ( + SELECT json_group_array( + json_object( + 'kind', + 'reexport', + 'from_file', + from_file, + 'to_file', + to_file, + 'hops', + hops, + 'truncated', + truncated + ) + ) + FROM ( + SELECT r.from_file, r.to_file, r.hops, r.truncated + FROM re_export_chains r + WHERE r.to_file = u.file_path + AND r.to_name = u.name + ORDER BY r.from_file, r.hops + LIMIT 3 + ) + ), + '[]' + ) AS chain_hops + ) + ), + '[]' + ) AS evidence_json +FROM unimported u +ORDER BY u.file_path, u.name LIMIT 50