diff --git a/.agents/skills/harden-pr/LEDGER.md b/.agents/skills/harden-pr/LEDGER.md index 6cdeb4b5..5e6e1584 100644 --- a/.agents/skills/harden-pr/LEDGER.md +++ b/.agents/skills/harden-pr/LEDGER.md @@ -10,6 +10,9 @@ By-design or false-positive findings — do not re-raise. - **[category]** `file:line` — label: reason ``` +- **[correctness]** `src/application/impact-engine.ts:147` — explicit inPath on single-definition symbol enables first-hop scopeFiles: by-design — matches show `--in` disambiguation (plan P2.1). +- **[correctness]** `src/application/impact-engine.ts:162` — per-file walk LIMIT before global dedup: by-design v1 — plan architecture per-defining-file walks; global limit still applies at slice. + diff --git a/.changeset/impact-inpath-homonyms.md b/.changeset/impact-inpath-homonyms.md new file mode 100644 index 00000000..226460a0 --- /dev/null +++ b/.changeset/impact-inpath-homonyms.md @@ -0,0 +1,5 @@ +--- +"@stainless-code/codemap": patch +--- + +Scope `codemap impact` and MCP/HTTP `impact` homonym symbols: `--in` / `in` disambiguates by defining file (same prefix/exact rules as `codemap show --in`); unscoped homonyms union per-defining-file call graphs instead of merging by name only. Mismatched scope returns empty `matches` with `skipped_scope`. diff --git a/README.md b/README.md index 702fa304..33eb66f4 100644 --- a/README.md +++ b/README.md @@ -204,12 +204,14 @@ codemap snippet foo --json # {matches: [{.. # Impact analysis — symbol/file blast-radius walker (callers, callees, dependents, dependencies) codemap impact handleQuery # both directions, depth 3, all compatible graphs +codemap impact dup --in src/a.ts --via calls # homonym symbol scoped to one defining file codemap impact src/db.ts --direction up # what depends on db.ts (file-level, deps + imports) codemap impact handleAudit --depth 1 --via calls # direct callers via the calls table only codemap impact runWatchLoop --json --summary | jq '.summary.nodes' # CI-gate fan-in score # Replaces hand-composed `WITH RECURSIVE` queries. Cycle-detected, depth-bounded # (default 3, --depth 0 = unbounded), limit-capped (default 500). Result envelope: -# {target, matches: [{depth, edge, kind, name?, file_path}], summary: {nodes, terminated_by}}. +# {target, matches: [...], summary: {nodes, terminated_by}, skipped_scope?}. +# Homonym symbols: unscoped unions per-defining-file graphs; --in scopes one file. # Affected tests — reverse dependency walk from changed sources → test files to run codemap affected --json # working-tree changes vs HEAD (git status + diff) diff --git a/docs/architecture.md b/docs/architecture.md index 3a3835a8..30c9fbab 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -136,7 +136,7 @@ A local SQLite database (`.codemap/index.db`) indexes the project tree and store **`src/cli/cmd-context.ts`** (argv + render) → **`handleContext`** in **`tool-handlers.ts`** → **`src/application/context-engine.ts`** (engine — **`buildContextEnvelope`**, **`classifyIntent`**, **`composeStartHere`**, **`resolveContextBudget`**, `ContextEnvelope` type). `buildContextEnvelope` composes the JSON envelope from existing recipes (legacy **`hubs`** at the bundled `fan-in` recipe default limit; budget-capped **`start_here.hub_leaders`** via **`resolveContextBudget(file_count)`**), intent-scoped `sample_markers`, `QUERY_RECIPES` catalog, **`start_here`** (inline `index-summary`, intent-ranked `query_recipe` cards, hub leaders with exported-symbol signatures — optional one-line **`include_snippets`** via CLI `--include-snippets` or MCP/HTTP `include_snippets`, path-contained disk reads with `stale`/`missing` flags), and **`index_freshness`** via **`src/application/index-freshness.ts`**. Debug `--for` / MCP `intent` biases markers toward FIXME/TODO kinds; whitespace-only intent is treated as no intent on all transports. **`classifyIntent`** maps free text to `refactor | debug | test | feature | explore | other`; **`start_here.classified_as`** is `"default"` when no intent is supplied. Hub-leader **`include_snippets`** one-liners share the adaptive **`signature_max_chars`** cap. **`--compact`** drops `hubs`, `sample_markers`, and `start_here` and emits minified JSON (non-compact pretty-prints with 2-space indent). Whitespace-only `--for` values are rejected at CLI parse time. **`include_snippets`** is a no-op when **`compact: true`**. Product-shape constraint: [No split-brain incremental index](./roadmap.md#floors-v1-product-shape). -**Impact wiring:** **`src/cli/cmd-impact.ts`** (argv — `` + `--direction up|down|both` + `--depth N` + `--via dependencies|calls|imports|all` + `--limit N` + `--summary` + `--json`; bootstrap absorbs `--root`/`--config`) + **`src/application/impact-engine.ts`** (engine — `findImpact({db, target, direction?, via?, depth?, limit?})`). Pure transport-agnostic walker over the calls + dependencies + imports graphs; **`--via calls`** walks only parse-resolved `calls` rows (`CALLS_AST_ONLY_SQL` — excludes callback-synthesis heuristics unless consumers query `calls-including-heuristic`); CLI / MCP / HTTP all dispatch the same engine function via `tool-handlers.ts`'s `handleImpact`. Target auto-resolves: contains `/` or matches `files.path` → file target; otherwise symbol (case-sensitive). Walks compatible backends per resolved kind: **symbol** → `calls` (callers / callees by `caller_name` / `callee_name`); **file** → `dependencies` (`from_path` / `to_path`) + `imports` (`file_path` / `resolved_path`, `IS NOT NULL` filter). `--via ` overrides; mismatched explicit choices land in `skipped_backends` (no error — agents see why their backend selection yielded fewer rows than expected). One `WITH RECURSIVE` per (direction, backend) combo with cycle detection via path-string `instr` check (SQLite has no native cycle predicate); JS-side merge + dedup by `(direction, kind, name?, file_path)` keeping the shallowest depth. `--depth 0` uses an unbounded sentinel (`UNBOUNDED_DEPTH_SENTINEL = 1_000_000`); cycle detection + `LIMIT` keep cyclic graphs cheap regardless. Termination reason classification: `limit` (truncated) > `depth` (any node sat at the cap) > `exhausted`. Result envelope: `{target, direction, via, depth_limit, matches: [{depth, direction, edge, kind, name?, file_path}], summary: {nodes, max_depth_reached, by_kind, terminated_by}, skipped_backends?}`. `--summary` blanks `matches` (transport bandwidth saver) but preserves `summary.nodes` so CI gates (`jq '.summary.nodes'`) still see the count. SARIF / annotations not supported (graph traversal, not findings — the parser accepts the flag combos but the engine only emits JSON). +**Impact wiring:** **`src/cli/cmd-impact.ts`** (argv — `` + `--in ` + `--direction up|down|both` + `--depth N` + `--via dependencies|calls|imports|all` + `--limit N` + `--summary` + `--json`; bootstrap absorbs `--root`/`--config`) + **`src/application/impact-engine.ts`** (engine — `findImpact({db, target, direction?, via?, depth?, limit?, inPath?})`). Pure transport-agnostic walker over the calls + dependencies + imports graphs; **`--via calls`** walks only parse-resolved `calls` rows (`CALLS_AST_ONLY_SQL` — excludes callback-synthesis heuristics unless consumers query `calls-including-heuristic`); CLI / MCP / HTTP all dispatch the same engine function via `tool-handlers.ts`'s `handleImpact` (MCP/HTTP `in` arg). Target auto-resolves: contains `/` or matches `files.path` → file target; otherwise symbol (case-sensitive). **Homonym symbols** (`matched_in.length > 1`): unscoped walks union per-defining-file call graphs (first hop scoped to each definition's call sites); `--in` / MCP `in` filters `matched_in` via show-engine prefix/exact rules — no match → empty `matches` + `skipped_scope`. Walks compatible backends per resolved kind: **symbol** → `calls` (callers / callees by `caller_name` / `callee_name`); **file** → `dependencies` (`from_path` / `to_path`) + `imports` (`file_path` / `resolved_path`, `IS NOT NULL` filter). `--via ` overrides; mismatched explicit choices land in `skipped_backends` (no error — agents see why their backend selection yielded fewer rows than expected). One `WITH RECURSIVE` per (direction, backend) combo with cycle detection via path-string `instr` check (SQLite has no native cycle predicate); JS-side merge + dedup by `(direction, kind, name?, file_path)` keeping the shallowest depth. `--depth 0` uses an unbounded sentinel (`UNBOUNDED_DEPTH_SENTINEL = 1_000_000`); cycle detection + `LIMIT` keep cyclic graphs cheap regardless. Termination reason classification: `limit` (truncated) > `depth` (any node sat at the cap) > `exhausted`. Result envelope: `{target, direction, via, depth_limit, matches: [{depth, direction, edge, kind, name?, file_path}], summary: {nodes, max_depth_reached, by_kind, terminated_by}, skipped_backends?, skipped_scope?}`. `--summary` blanks `matches` (transport bandwidth saver) but preserves `summary.nodes` so CI gates (`jq '.summary.nodes'`) still see the count. SARIF / annotations not supported (graph traversal, not findings — the parser accepts the flag combos but the engine only emits JSON). **Affected wiring:** **`src/cli/cmd-affected.ts`** (argv — positional paths / `--stdin` / `--changed-since ` / `--params test_glob|max_depth` + `--json`; bootstrap absorbs `--root`/`--config`) + **`src/application/affected-engine.ts`** (engine — `resolveAffectedChangedPaths` + `executeAffectedTests`; pure recipe composer over bundled `affected-tests` SQL). CLI / MCP / HTTP dispatch the same engine via `tool-handlers.ts`'s `handleAffected` (MCP/HTTP) and `runAffectedCmd` (CLI). Path precedence: explicit paths (CLI positional / MCP `paths` array) → CLI `--stdin` → git vs `changed_since` / `HEAD` (`paths: []` on MCP/HTTP skips git). Result envelope: JSON array of `{test_path, impact_depth, actions?}` — file paths only; CI composes the runner command. **`tryRecordRecipeRun("affected-tests")`** lives at the orchestration layer (`handleAffected` + `runAffectedCmd`), not in the engine — same boundary discipline as `query_recipe` (see [§ `recipe_recency`](#recipe_recency--per-recipe-last-run--run-count-user-data-strict-without-rowid)). Recency records only when at least one changed path was resolved and the recipe SQL ran (empty path sets return `[]` without a recency write). diff --git a/docs/glossary.md b/docs/glossary.md index e0c96f6b..9be30f91 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -322,7 +322,7 @@ Index mode that diffs against `last_indexed_commit` (git) and only re-indexes ch ### `codemap impact` / impact tool -Symbol or file blast-radius walker. CLI: `codemap impact [--direction up|down|both] [--depth N] [--via dependencies|calls|imports|all] [--limit N] [--summary] [--json]`. MCP: `impact` tool. HTTP: `POST /tool/impact`. Replaces hand-composed `WITH RECURSIVE` queries that agents struggle to write reliably. Walks compatible graphs based on resolved target kind: **symbol** targets walk `calls` (callers / callees by name); **file** targets walk `dependencies` + `imports` (`resolved_path` only). Mismatched explicit `--via` choices land in `skipped_backends` instead of failing. Cycle-detected via path-string `instr` check inside the recursive CTE; bounded by `--depth` (default 3, 0 = unbounded but still cycle-detected and limit-capped) and `--limit` (default 500). Result envelope: `{target, direction, via, depth_limit, matches: [{depth, direction, edge, kind, name?, file_path}], summary: {nodes, max_depth_reached, by_kind, terminated_by: 'depth'|'limit'|'exhausted'}}`. `--summary` trims `matches` for cheap CI gate consumption (`jq '.summary.nodes'`) but preserves the count. Pure transport-agnostic engine in `application/impact-engine.ts`; CLI / MCP / HTTP all dispatch the same `findImpact` function. `sarif` / `annotations` formats not supported (impact rows are graph traversals, not findings). +Symbol or file blast-radius walker. CLI: `codemap impact [--in ] [--direction up|down|both] [--depth N] [--via dependencies|calls|imports|all] [--limit N] [--summary] [--json]`. MCP: `impact` tool (`in` arg). HTTP: `POST /tool/impact`. Replaces hand-composed `WITH RECURSIVE` queries that agents struggle to write reliably. Walks compatible graphs based on resolved target kind: **symbol** targets walk `calls` (callers / callees by name); **file** targets walk `dependencies` + `imports` (`resolved_path` only). **Homonym symbols:** unscoped walks union per-defining-file call graphs; `--in` / MCP `in` disambiguates via show-engine prefix/exact rules — mismatch → empty `matches` + `skipped_scope`. Mismatched explicit `--via` choices land in `skipped_backends` instead of failing. Cycle-detected via path-string `instr` check inside the recursive CTE; bounded by `--depth` (default 3, 0 = unbounded but still cycle-detected and limit-capped) and `--limit` (default 500). Result envelope: `{target, direction, via, depth_limit, matches: [{depth, direction, edge, kind, name?, file_path}], summary: {nodes, max_depth_reached, by_kind, terminated_by: 'depth'|'limit'|'exhausted'}, skipped_backends?, skipped_scope?}`. `--summary` trims `matches` for cheap CI gate consumption (`jq '.summary.nodes'`) but preserves the count. Pure transport-agnostic engine in `application/impact-engine.ts`; CLI / MCP / HTTP all dispatch the same `findImpact` function. `sarif` / `annotations` formats not supported (impact rows are graph traversals, not findings). ### `import_specifiers` (table) diff --git a/docs/plans/impact-inpath-homonyms.md b/docs/plans/impact-inpath-homonyms.md deleted file mode 100644 index b075ad74..00000000 --- a/docs/plans/impact-inpath-homonyms.md +++ /dev/null @@ -1,81 +0,0 @@ -# PR 2 — impact `inPath` homonym scoping - -> **Status:** open (not started) · **PR:** 2 of 3 · **Effort:** S–M -> -> **Orchestrator:** [`security-hardening-orchestrator.md`](./security-hardening-orchestrator.md) -> -> **Motivator:** `findImpact` resolves homonym symbols but walks call graph by name only — wrong blast-radius. Align with shipped `define_in` (#165) and existing `show`/`trace` `inPath` patterns. Moat B substrate fidelity. - ---- - -## Agent start here - -**Blocked until PR 1 merges.** - -### Key touchpoints - -| File | What | -| -------------------------------------- | ---------------------------------------------------------- | -| `src/application/impact-engine.ts` | `inPath` on `FindImpactOpts`, per-file walks, `scopeFiles` | -| `src/cli/cmd-impact.ts` | `--in ` flag | -| `src/cli/cmd-composers.ts` | MCP/CLI composer wiring | -| `src/application/tool-handlers.ts` | HTTP/MCP `impact` handler | -| `src/application/mcp-server.ts` | Tool schema `in` param | -| `src/application/trace-engine.test.ts` | Homonym test patterns to mirror | - -### Architecture - -```text -findImpact({ target, inPath? }) - → resolveTarget → matched_in[] - → if inPath set and ∉ matched_in → empty + skip reason - → if homonym (|matched_in| > 1) and no inPath → walk per defining file, merge/dedup - → walkCalls: scopeFiles filters call-site file_path -``` - ---- - -## Task list - -| ID | Task | Status | Verify | -| --- | ---------------------------------------------------- | ------- | ------------------------------------------------ | -| 4.1 | `inPath?: string` on `FindImpactOpts` / `findImpact` | pending | `bun test src/application/impact-engine.test.ts` | -| 4.2 | Multi `matched_in` → per-file walks; merge/dedup | pending | homonym fixture | -| 4.3 | `inPath` ∉ `matched_in` → empty + skip reason | pending | test | -| 4.4 | Walkers: `scopeFiles` on call-site file | pending | test | -| 4.5 | CLI `codemap impact --in ` | pending | `bun test src/cli/cmd-impact.test.ts` | -| 4.6 | MCP/HTTP `impact` `in` param | pending | MCP tests | -| 4.7 | Doc lift (architecture § impact) | pending | format check | -| 4.s | Commit + PR + CI | pending | `bun run check` | - ---- - -## Pre-locked decisions - -| # | Decision | -| ---- | ------------------------------------------------------------------------------------------------------- | -| P2.1 | `inPath` semantics match `show-engine` prefix/exact rules (not `define_in` — that's write-side anchor). | -| P2.2 | Unscoped homonym → union per-file walks, not silent name-level merge. | -| P2.3 | Moat A safe — still composable graph envelope, not a verdict primitive. | - ---- - -## Acceptance - -- [ ] Homonym: unscoped walk unions per-defining-file graphs -- [ ] `inPath` outside `matched_in` → empty matches + skip reason -- [ ] CLI `--in` and MCP `in` wired -- [ ] PR merged to `main` - -### Verify - -```bash -bun test src/application/impact-engine.test.ts src/cli/cmd-impact.test.ts -bun run check -``` - ---- - -## Lifecycle - -**Close when:** PR merged. Delete this file; lift to `docs/architecture.md` § impact; update orchestrator session log. diff --git a/docs/plans/security-hardening-orchestrator.md b/docs/plans/security-hardening-orchestrator.md index 5b0937cc..e980b55a 100644 --- a/docs/plans/security-hardening-orchestrator.md +++ b/docs/plans/security-hardening-orchestrator.md @@ -21,11 +21,11 @@ ## PR schedule -| PR | Plan | Status | Blocks | -| ----- | --------------------------------------------------------------- | ---------------------------------------------------------------------- | ----------------------------------- | -| **1** | lifted → [`architecture.md`](../architecture.md) (plan retired) | **PR [#180](https://github.com/stainless-code/codemap/pull/180) open** | — | -| **2** | [`impact-inpath-homonyms.md`](./impact-inpath-homonyms.md) | **pending** | PR **1** merged | -| **3** | [`runtime-test-isolation.md`](./runtime-test-isolation.md) | **pending** | PR **1** merged (PR **2** optional) | +| PR | Plan | Status | Blocks | +| ----- | --------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ----------------------------------- | +| **1** | lifted → [`architecture.md`](../architecture.md) (plan retired) | **merged** ([#180](https://github.com/stainless-code/codemap/pull/180) · `a5caca8`) | — | +| **2** | lifted → [`architecture.md`](../architecture.md) (plan retired) | **PR open** (`fix/impact-inpath-homonyms`) | — | +| **3** | [`runtime-test-isolation.md`](./runtime-test-isolation.md) | **pending** | PR **1** merged (PR **2** optional) | | — | — | **deferred** | golden `schema.test.ts` + path guards | | — | — | **skip** | atomic `ensureStateConfig` writes | @@ -69,9 +69,10 @@ Evaluated 2026-06 against [roadmap § Floors](../roadmap.md#floors-v1-product-sh | 2026-06-10 | Triage | ROI on 7 slices; 3-PR program adopted. | | 2026-06-10 | PR 1 impl | PR **1** committed on `fix/security-hardening-wave1`; harden pass in flight. | | 2026-06-05 | PR 1 harden | `/harden-pr full` — plan retired; contracts in architecture/glossary. | -| — | PR 1 merge | _merge SHA · update status → merged_ | -| — | PR 2 start | _from `main`_ | -| — | PR 2 merge | _fill_ | +| 2026-06-05 | PR 1 merge | [#180](https://github.com/stainless-code/codemap/pull/180) → `a5caca8`. | +| 2026-06-05 | PR 2 start | `fix/impact-inpath-homonyms` — `inPath` + homonym walks in impact-engine. | +| 2026-06-05 | PR 2 harden | `/harden-pr full` — plan retired; CLI/MCP/docs parity. | +| — | PR 2 merge | _PR URL · merge SHA_ | | — | PR 3 start | _from `main`_ | | — | PR 3 merge | _fill · close orchestrator_ | diff --git a/docs/roadmap.md b/docs/roadmap.md index dbf418a1..ce87dc89 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -108,7 +108,7 @@ Predicate-as-API only — enrich row shape and audit deltas; no standalone pass/ - [ ] **`organize-imports` diff-shape recipe** — deterministic single-file import sort/group; `imports.line_number` + `source` substrate sufficient. Review-first (`auto_fixable: false`). Effort: S. - [ ] **`codemap-to-tsmorph` Path B adapter** — separate package experiment: `query_recipe` discovery → `ts-morph` / `jscodeshift` transforms for AST-shape edits codemap's substring executor defers (see [architecture § Rejected apply-path alternatives](./architecture.md#apply--input-modes-transport-and-policy)). Not an in-tree AST writer (Path A rejected). Effort: M. - [ ] **Apply write-safety hardening** — close apply TOCTOU: SHA-256 `hashContent` at phase-1 read, recheck disk hash immediately before phase-2 write (`file content changed` conflict); `fsync` temp file before `rename`; skip files with mixed CRLF/LF (`mixed line endings`). Preserves all-or-nothing on any conflict. Plan: [`plans/apply-write-safety.md`](./plans/apply-write-safety.md). Effort: L. -- [ ] **Read-surface hardening (3 PRs)** — query/HTTP/validate safety, `impact` `inPath` homonyms, runtime guards + test teardown. **Orchestrator:** [`plans/security-hardening-orchestrator.md`](./plans/security-hardening-orchestrator.md). PR1 ([#180](https://github.com/stainless-code/codemap/pull/180), lifted to [architecture](./architecture.md)) · Plans: [PR2](./plans/impact-inpath-homonyms.md) · [PR3](./plans/runtime-test-isolation.md). Effort: S–M. +- [ ] **Read-surface hardening (3 PRs)** — query/HTTP/validate safety, `impact` `inPath` homonyms, runtime guards + test teardown. **Orchestrator:** [`plans/security-hardening-orchestrator.md`](./plans/security-hardening-orchestrator.md). PR1 ([#180](https://github.com/stainless-code/codemap/pull/180), lifted to [architecture](./architecture.md)) · PR2 (`impact` homonyms, lifted to [architecture](./architecture.md)) · Plans: [PR3](./plans/runtime-test-isolation.md). Effort: S–M. - [ ] **`history` table** (deferred — revisit-triggered) — temporal queries: "when did symbol X get `@deprecated`?", "coverage trend over last 50 commits", "files that became dead this week". `audit --base ` covers the most-common temporal question (PR-scoped diff) without schema growth, so the table earns its place only when bigger questions emerge. Two shapes (per-commit snapshots ~N × DB size; append-only event log heavier CTE walks); both pay an N-reindexes backfill cost (~30s per reindex). **Revisit triggers:** two consumers ship `jq`-based "audit-runs-over-time" workflows, OR `query_baselines` evolution becomes a recurring agent need. - [ ] **`codemap audit` verdict + thresholds** (v1.x) — `verdict: "pass" | "warn" | "fail"` driven by an `audit.deltas[].{added_max, action}` field on the config object (`.codemap/config.{ts,js,json}`). 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. **Likely accelerant:** the Marketplace Action (next item) shipping is the most plausible path to firing the trigger — once `- uses: stainless-code/codemap@v1` is the dominant CI path, real `jq` threshold scripts will surface. - [ ] **GitHub Marketplace Action — publish + listing finish** — core Action implementation is in-tree: root `action.yml`, `query --ci`, `audit --format sarif` / `--ci`, package-manager detection, dogfood smoke, and opt-in `pr-comment` summary renderer have shipped. Remaining work is the release/listing slice: `MARKETPLACE.md`, `v1.0.0` / floating `v1` tags, Marketplace setup, sacrificial-repo smoke, and making `action-smoke` blocking once the Action tag exists. Action version stream is independent of CLI version (`package.json` currently drives CLI/npm version; Action publishes at its own `v1.0.0`). Plan: [`plans/github-marketplace-action.md`](./plans/github-marketplace-action.md). Effort: S. diff --git a/src/application/impact-engine.test.ts b/src/application/impact-engine.test.ts index 2171999b..0954fbbf 100644 --- a/src/application/impact-engine.test.ts +++ b/src/application/impact-engine.test.ts @@ -385,6 +385,89 @@ describe("findImpact — target resolution", () => { }); }); +describe("findImpact — inPath + homonym scoping", () => { + beforeEach(() => { + seedFile("src/a.ts"); + seedFile("src/b.ts"); + seedSymbol("dup", "src/a.ts"); + seedSymbol("dup", "src/b.ts"); + seedSymbol("onlyA", "src/a.ts"); + seedSymbol("onlyB", "src/b.ts"); + seedCall("src/a.ts", "dup", "onlyA"); + seedCall("src/b.ts", "dup", "onlyB"); + }); + + it("unscoped homonym unions per-defining-file call graphs", () => { + const r = findImpact(db, { + target: "dup", + direction: "down", + via: "calls", + }); + expect(r.matches.map((m) => m.name).sort()).toEqual(["onlyA", "onlyB"]); + }); + + it("inPath scopes homonym to one defining file", () => { + const r = findImpact(db, { + target: "dup", + direction: "down", + via: "calls", + inPath: "src/a.ts", + }); + expect(r.target.matched_in).toEqual(["src/a.ts"]); + expect(r.matches.map((m) => m.name)).toEqual(["onlyA"]); + }); + + it("inPath on unknown symbol omits skipped_scope", () => { + const r = findImpact(db, { + target: "missing", + direction: "down", + via: "calls", + inPath: "src/a.ts", + }); + expect(r.target.matched_in).toEqual([]); + expect(r.skipped_scope).toBeUndefined(); + }); + + it("inPath outside matched_in returns empty matches with skipped_scope", () => { + const r = findImpact(db, { + target: "dup", + direction: "down", + via: "calls", + inPath: "src/z.ts", + }); + expect(r.matches).toEqual([]); + expect(r.skipped_scope?.reason).toContain("src/z.ts"); + }); + + it("unscoped homonym unions per-file graphs for direction=up", () => { + seedCall("src/a.ts", "callerA", "dup"); + seedCall("src/b.ts", "callerB", "dup"); + + const r = findImpact(db, { + target: "dup", + direction: "up", + via: "calls", + }); + expect(r.matches.map((m) => m.name).sort()).toEqual(["callerA", "callerB"]); + }); + + it("inPath directory prefix scopes homonym definitions", () => { + seedFile("src/pkg/b.ts"); + seedSymbol("dup", "src/pkg/b.ts"); + seedSymbol("onlyPkg", "src/pkg/b.ts"); + seedCall("src/pkg/b.ts", "dup", "onlyPkg"); + + const r = findImpact(db, { + target: "dup", + direction: "down", + via: "calls", + inPath: "src/pkg", + }); + expect(r.target.matched_in).toEqual(["src/pkg/b.ts"]); + expect(r.matches.map((m) => m.name)).toEqual(["onlyPkg"]); + }); +}); + describe("findImpact — envelope shape + summary", () => { beforeEach(() => { seedFile("src/a.ts"); diff --git a/src/application/impact-engine.ts b/src/application/impact-engine.ts index 16f70e96..ac8ff520 100644 --- a/src/application/impact-engine.ts +++ b/src/application/impact-engine.ts @@ -1,4 +1,5 @@ import type { CodemapDatabase } from "../db"; +import { looksLikeDirectory } from "./show-engine"; /** Walk direction. `up` = callers/dependents, `down` = callees/dependencies. */ export type ImpactDirection = "up" | "down" | "both"; @@ -65,6 +66,11 @@ export interface ImpactResult { backend: "dependencies" | "calls" | "imports"; reason: string; }>; + /** + * Set when `inPath` does not match any defining file for a symbol target. + * Matches are empty; tells the agent why the scope filter yielded no graph. + */ + skipped_scope?: { reason: string }; } export interface FindImpactOpts { @@ -78,6 +84,11 @@ export interface FindImpactOpts { depth?: number | undefined; /** Default `500`. Caps total rows; truncation surfaces as `terminated_by: "limit"`. */ limit?: number | undefined; + /** + * Optional defining-file scope for symbol targets (show-engine prefix/exact + * rules — directory-shaped paths are prefix filters; otherwise exact file). + */ + inPath?: string | undefined; } const DEFAULT_DEPTH = 3; @@ -101,8 +112,32 @@ export function findImpact( const depthLimit = depthRaw === 0 ? UNBOUNDED_DEPTH_SENTINEL : depthRaw; const limit = opts.limit ?? DEFAULT_LIMIT; - const target = resolveTarget(db, opts.target); + let target = resolveTarget(db, opts.target); const { backends, skipped } = resolveBackends(viaOpt, target.kind); + + if ( + opts.inPath !== undefined && + opts.inPath.length > 0 && + target.kind === "symbol" && + target.matched_in.length > 0 + ) { + const scoped = filterMatchedInByInPath(target.matched_in, opts.inPath); + if (scoped.length === 0) { + return emptyImpactResult({ + target: { ...target, matched_in: scoped }, + direction, + backends, + depthRaw, + depthLimit, + limit, + skipped, + skippedScope: { + reason: `inPath "${opts.inPath}" does not match any defining file for symbol "${target.name}"`, + }, + }); + } + target = { ...target, matched_in: scoped }; + } const directions: Array<"up" | "down"> = direction === "both" ? ["up", "down"] : [direction]; @@ -110,8 +145,33 @@ export function findImpact( let maxDepth = 0; let depthCapped = false; + const homonymOrScoped = + target.kind === "symbol" && + target.matched_in.length > 0 && + (target.matched_in.length > 1 || + (opts.inPath !== undefined && opts.inPath.length > 0)); + for (const dir of directions) { for (const backend of backends) { + if (backend === "calls" && homonymOrScoped) { + for (const scopeFile of target.matched_in) { + const rows = walk(db, { + target, + direction: dir, + backend, + depthLimit, + rowCap: limit + 1, + scopeFiles: [scopeFile], + }); + for (const r of rows) { + if (r.depth > maxDepth) maxDepth = r.depth; + if (r.depth >= depthLimit) depthCapped = true; + allNodes.push(r); + } + } + continue; + } + const rows = walk(db, { target, direction: dir, @@ -265,6 +325,8 @@ interface WalkOpts { backend: "dependencies" | "calls" | "imports"; depthLimit: number; rowCap: number; + /** When set, only the first hop from the seed uses call sites in these files. */ + scopeFiles?: string[] | undefined; } /** @@ -287,6 +349,14 @@ function walkCalls(db: CodemapDatabase, opts: WalkOpts): ImpactNode[] { const edge: ImpactNode["edge"] = opts.direction === "up" ? "called_by" : "calls"; + const scopeFiles = opts.scopeFiles; + const scopeClause = + scopeFiles !== undefined && scopeFiles.length > 0 + ? `AND (walk.depth > 0 OR c.file_path IN (${scopeFiles.map(() => "?").join(", ")}))` + : ""; + const scopeParams = + scopeFiles !== undefined && scopeFiles.length > 0 ? scopeFiles : []; + // Seed depth = 0; `WHERE depth > 0` filters seed; `< depthLimit` is the cap. const sql = ` WITH RECURSIVE walk(node, depth, path, file_path) AS ( @@ -299,6 +369,7 @@ function walkCalls(db: CodemapDatabase, opts: WalkOpts): ImpactNode[] { WHERE (c.provenance IS NULL OR c.provenance = 'ast') AND walk.depth < ? AND instr(walk.path, char(30) || c.${joinToCol} || char(30)) = 0 + ${scopeClause} ) SELECT node, depth, file_path FROM ( @@ -315,7 +386,13 @@ function walkCalls(db: CodemapDatabase, opts: WalkOpts): ImpactNode[] { `; const rows = db .query(sql) - .all(seedName, seedName, opts.depthLimit, opts.rowCap) as Array<{ + .all( + seedName, + seedName, + opts.depthLimit, + ...scopeParams, + opts.rowCap, + ) as Array<{ node: string; depth: number; file_path: string | null; @@ -403,3 +480,46 @@ function lookupSymbolFile( .get(name) as { file_path: string } | null; return r?.file_path; } + +/** Prefix/exact filter — mirrors `findSymbolsByName` `inPath` semantics. */ +function filterMatchedInByInPath( + matchedIn: string[], + inPath: string, +): string[] { + if (looksLikeDirectory(inPath)) { + const prefix = inPath.endsWith("/") ? inPath : `${inPath}/`; + return matchedIn.filter((filePath) => filePath.startsWith(prefix)); + } + return matchedIn.filter((filePath) => filePath === inPath); +} + +function emptyImpactResult(opts: { + target: ImpactTarget; + direction: ImpactDirection; + backends: Array<"dependencies" | "calls" | "imports">; + depthRaw: number; + depthLimit: number; + limit: number; + skipped: Array<{ + backend: "dependencies" | "calls" | "imports"; + reason: string; + }>; + skippedScope: { reason: string }; +}): ImpactResult { + const result: ImpactResult = { + target: opts.target, + direction: opts.direction, + via: opts.backends, + depth_limit: opts.depthRaw, + matches: [], + summary: { + nodes: 0, + max_depth_reached: 0, + by_kind: {}, + terminated_by: "exhausted", + }, + skipped_scope: opts.skippedScope, + }; + if (opts.skipped.length > 0) result.skipped_backends = opts.skipped; + return result; +} diff --git a/src/application/mcp-server.test.ts b/src/application/mcp-server.test.ts index 923917cf..c1ef01c6 100644 --- a/src/application/mcp-server.test.ts +++ b/src/application/mcp-server.test.ts @@ -1850,6 +1850,73 @@ describe("MCP server — impact tool", () => { } }); + it("impact in scopes homonym symbols to one defining file", async () => { + const db = openDb(); + try { + db.run( + `INSERT INTO symbols (file_path, name, kind, line_start, line_end, signature, is_exported, is_default_export) + VALUES + ('src/a.ts', 'dup', 'function', 1, 1, 'function dup()', 0, 0), + ('src/b.ts', 'dup', 'function', 1, 1, 'function dup()', 0, 0), + ('src/a.ts', 'onlyA', 'function', 1, 1, 'function onlyA()', 0, 0), + ('src/b.ts', 'onlyB', 'function', 1, 1, 'function onlyB()', 0, 0)`, + ); + db.run( + `INSERT INTO calls (file_path, caller_name, caller_scope, callee_name, line_start, column_start, column_end) + VALUES ('src/a.ts', 'dup', 'function', 'onlyA', 1, 0, 1), + ('src/b.ts', 'dup', 'function', 'onlyB', 1, 0, 1)`, + ); + } finally { + closeDb(db); + } + + const { client, server } = await makeClient(); + try { + const r = await client.callTool({ + name: "impact", + arguments: { + target: "dup", + direction: "down", + via: "calls", + in: "src/a.ts", + }, + }); + const json = readJson(r); + expect(json.target.matched_in).toEqual(["src/a.ts"]); + expect(json.matches.map((m: { name: string }) => m.name)).toEqual([ + "onlyA", + ]); + } finally { + await server.close(); + } + }); + + it("impact in mismatch returns skipped_scope", async () => { + const db = openDb(); + try { + db.run( + `INSERT INTO symbols (file_path, name, kind, line_start, line_end, signature, is_exported, is_default_export) + VALUES ('src/a.ts', 'dup', 'function', 1, 1, 'function dup()', 0, 0), + ('src/b.ts', 'dup', 'function', 1, 1, 'function dup()', 0, 0)`, + ); + } finally { + closeDb(db); + } + + const { client, server } = await makeClient(); + try { + const r = await client.callTool({ + name: "impact", + arguments: { target: "dup", in: "src/z.ts" }, + }); + const json = readJson(r); + expect(json.matches).toEqual([]); + expect(json.skipped_scope?.reason).toContain("src/z.ts"); + } finally { + await server.close(); + } + }); + it("impact returns isError on non-integer depth (Zod rejects)", async () => { const { client, server } = await makeClient(); try { diff --git a/src/application/mcp-server.ts b/src/application/mcp-server.ts index c89abb36..0c8fe784 100644 --- a/src/application/mcp-server.ts +++ b/src/application/mcp-server.ts @@ -375,7 +375,7 @@ function registerImpactTool(server: McpServer): void { "impact", withToolAnnotations("impact", { description: - "Walk the dependency / calls / imports graph from and return the blast radius. Replaces composing `WITH RECURSIVE` queries by hand. Args: target (symbol name or file path), direction (up|down|both, default both), via (dependencies|calls|imports|all, default all — symbol targets walk calls; file targets walk dependencies+imports; mismatched explicit choices land in skipped_backends), depth (default 3, 0=unbounded but cycle-detected and limit-capped), limit (default 500), summary (returns target+summary only). Result envelope: {target, direction, via, depth_limit, matches: [{depth, direction, edge, kind, name?, file_path}], summary: {nodes, max_depth_reached, by_kind, terminated_by: 'depth'|'limit'|'exhausted'}}.", + "Walk the dependency / calls / imports graph from and return the blast radius. Replaces composing `WITH RECURSIVE` queries by hand. Args: target (symbol name or file path), direction (up|down|both, default both), via (dependencies|calls|imports|all, default all — symbol targets walk calls; file targets walk dependencies+imports; mismatched explicit choices land in skipped_backends), depth (default 3, 0=unbounded but cycle-detected and limit-capped), limit (default 500), in (disambiguate symbol homonyms by defining file — prefix/exact like show), summary (returns target+summary only). Homonym symbols without in union per-defining-file call graphs. Result envelope: {target, direction, via, depth_limit, matches: [{depth, direction, edge, kind, name?, file_path}], summary: {nodes, max_depth_reached, by_kind, terminated_by: 'depth'|'limit'|'exhausted'}, skipped_scope?}.", inputSchema: impactArgsSchema, }), (args) => wrapToolResult(handleImpact(args)), diff --git a/src/application/tool-handlers.ts b/src/application/tool-handlers.ts index 88a55f79..7b367763 100644 --- a/src/application/tool-handlers.ts +++ b/src/application/tool-handlers.ts @@ -872,6 +872,7 @@ export const impactArgsSchema = { via: z.enum(["dependencies", "calls", "imports", "all"]).optional(), depth: z.number().int().nonnegative().optional(), limit: z.number().int().positive().optional(), + in: z.string().min(1).optional(), summary: z.boolean().optional(), }; @@ -881,6 +882,7 @@ export interface ImpactArgs { via?: ImpactBackend; depth?: number; limit?: number; + in?: string; summary?: boolean; } @@ -888,12 +890,17 @@ export function handleImpact(args: ImpactArgs): ToolResult { try { const db = openDb(); try { + const inPath = + args.in !== undefined && args.in.length > 0 + ? toProjectRelative(getProjectRoot(), args.in) + : undefined; const result = findImpact(db, { target: args.target, direction: args.direction, via: args.via, depth: args.depth, limit: args.limit, + inPath, }); // mirrors cmd-impact.ts: trim `matches`, keep `summary.nodes`. const payload = diff --git a/src/cli/bootstrap.ts b/src/cli/bootstrap.ts index 8612b7ed..5bd38fc0 100644 --- a/src/cli/bootstrap.ts +++ b/src/cli/bootstrap.ts @@ -65,7 +65,7 @@ Graph composers (MCP trace / explore / node twins): codemap node [--kind ] [--in ] [--include-snippets] [--budget-chars N] [--compact] Impact analysis (graph walk for refactor blast-radius): - codemap impact [--direction up|down|both] [--depth N] [--via ] [--limit N] [--summary] [--json] + codemap impact [--in ] [--direction up|down|both] [--depth N] [--via ] [--limit N] [--summary] [--json] Affected tests (reverse dep walk → test files to run): codemap affected [--stdin] [--changed-since ] [--json] [...] diff --git a/src/cli/cmd-impact.test.ts b/src/cli/cmd-impact.test.ts index 7b36acbe..f29f2219 100644 --- a/src/cli/cmd-impact.test.ts +++ b/src/cli/cmd-impact.test.ts @@ -21,6 +21,7 @@ describe("parseImpactRest — happy paths", () => { via: "all", depth: 3, limit: 500, + inPath: undefined, summary: false, json: false, }); @@ -48,11 +49,34 @@ describe("parseImpactRest — happy paths", () => { via: "dependencies", depth: 5, limit: 100, + inPath: undefined, summary: true, json: true, }); }); + it("parses --in for homonym disambiguation", () => { + const r = parseImpactRest([ + "impact", + "dup", + "--in", + "src/a.ts", + "--via", + "calls", + ]); + expect(r).toEqual({ + kind: "run", + target: "dup", + direction: "both", + via: "calls", + depth: 3, + limit: 500, + inPath: "src/a.ts", + summary: false, + json: false, + }); + }); + it("accepts --depth 0 (unbounded sentinel)", () => { const r = parseImpactRest(["impact", "x", "--depth", "0"]); expect(r).toMatchObject({ kind: "run", depth: 0 }); @@ -114,4 +138,10 @@ describe("parseImpactRest — validation", () => { expect(r.kind).toBe("error"); if (r.kind === "error") expect(r.message).toMatch(/requires a value/); }); + + it("rejects --in without value", () => { + const r = parseImpactRest(["impact", "x", "--in"]); + expect(r.kind).toBe("error"); + if (r.kind === "error") expect(r.message).toMatch(/--in/); + }); }); diff --git a/src/cli/cmd-impact.ts b/src/cli/cmd-impact.ts index bba59750..ded93113 100644 --- a/src/cli/cmd-impact.ts +++ b/src/cli/cmd-impact.ts @@ -4,7 +4,9 @@ import type { ImpactDirection, ImpactResult, } from "../application/impact-engine"; +import { toProjectRelative } from "../application/validate-engine"; import { closeDb, openDb } from "../db"; +import { getProjectRoot } from "../runtime"; import { bootstrapCodemap } from "./bootstrap-codemap"; interface ImpactOpts { @@ -16,6 +18,7 @@ interface ImpactOpts { via: ImpactBackend; depth: number; limit: number; + inPath: string | undefined; summary: boolean; json: boolean; } @@ -36,7 +39,7 @@ const BACKENDS: ReadonlySet = new Set([ * Print `codemap impact` usage. */ export function printImpactCmdHelp(): void { - console.log(`Usage: codemap impact [--direction ] [--depth ] [--via ] [--limit ] [--summary] [--json] + console.log(`Usage: codemap impact [--in ] [--direction ] [--depth ] [--via ] [--limit ] [--summary] [--json] Walk the dependency / calls / imports graph from and return the blast radius — every symbol or file reachable in N hops. Replaces composing @@ -45,7 +48,9 @@ blast radius — every symbol or file reachable in N hops. Replaces composing Args: Symbol name (exact, case-sensitive) OR project-relative file path (auto-detected by '/' or by matching an - indexed files.path row). + indexed files.path row). Symbol homonyms (same name in + multiple files): unscoped walks union per-defining-file + call graphs; use --in to pick one definition. Flags: --direction up=callers/dependents, down=callees/dependencies, @@ -58,6 +63,8 @@ Flags: explicit choices land in skipped_backends. --limit Cap total result rows. Default 500. Truncation reports \`terminated_by: "limit"\` in summary. + --in Disambiguate symbol homonyms by defining file (prefix or + exact — same rules as \`codemap show --in\`). --summary Return only target + summary (skip per-node matches). --json Emit the JSON envelope. Required for --summary consumption in CI. @@ -68,10 +75,12 @@ Output (JSON, all cases): "matches": [ {depth, direction, edge, kind, name?, file_path, ...}, ... ], "summary": { "nodes": N, "max_depth_reached": N, "by_kind": {...}, "terminated_by": "depth|limit|exhausted" }, - "skipped_backends"?: [ {backend, reason}, ... ] } + "skipped_backends"?: [ {backend, reason}, ... ], + "skipped_scope"?: { "reason": "..." } } Examples: codemap impact handleQuery + codemap impact dup --in src/a.ts --via calls codemap impact src/db.ts --direction up codemap impact handleAudit --depth 1 --via calls codemap impact runWatchLoop --json --summary @@ -91,6 +100,7 @@ export function parseImpactRest(rest: string[]): via: ImpactBackend; depth: number; limit: number; + inPath: string | undefined; summary: boolean; json: boolean; } { @@ -103,6 +113,7 @@ export function parseImpactRest(rest: string[]): let via: ImpactBackend = "all"; let depth = 3; let limit = 500; + let inPath: string | undefined; let summary = false; let json = false; @@ -172,6 +183,18 @@ export function parseImpactRest(rest: string[]): i++; continue; } + if (a === "--in") { + const next = rest[i + 1]; + if (next === undefined || next.startsWith("-")) { + return { + kind: "error", + message: `codemap impact: "--in" requires a path prefix or file path.`, + }; + } + inPath = next; + i++; + continue; + } if (a === "--limit") { const next = rest[i + 1]; if (next === undefined) { @@ -213,7 +236,17 @@ export function parseImpactRest(rest: string[]): }; } - return { kind: "run", target, direction, via, depth, limit, summary, json }; + return { + kind: "run", + target, + direction, + via, + depth, + limit, + inPath, + summary, + json, + }; } /** @@ -229,12 +262,17 @@ export async function runImpactCmd(opts: ImpactOpts): Promise { const db = openDb(); let result: ImpactResult; try { + const inPath = + opts.inPath !== undefined && opts.inPath.length > 0 + ? toProjectRelative(getProjectRoot(), opts.inPath) + : undefined; result = findImpact(db, { target: opts.target, direction: opts.direction, via: opts.via, depth: opts.depth, limit: opts.limit, + inPath, }); } finally { closeDb(db, { readonly: true }); @@ -270,6 +308,9 @@ function renderTerminal(result: ImpactResult, summaryOnly: boolean): void { console.error(`# skipped backend "${s.backend}": ${s.reason}`); } } + if (result.skipped_scope !== undefined) { + console.error(`# skipped scope: ${result.skipped_scope.reason}`); + } if (!summaryOnly) { for (const m of result.matches) { const label = diff --git a/src/cli/cmd-mcp.ts b/src/cli/cmd-mcp.ts index 32717441..33a888e2 100644 --- a/src/cli/cmd-mcp.ts +++ b/src/cli/cmd-mcp.ts @@ -102,8 +102,7 @@ Tools (21; snake_case — mirrors CLI verbs where a shell twin exists): validate Hash drift rows (stale/missing/unindexed/rejected + reason). show Symbol metadata: file:line + signature. snippet Same lookup + source text from disk. - impact Symbol/file blast-radius walker (callers, callees, - dependents, dependencies). + impact Blast-radius walker; homonym symbols accept in scope. affected Reverse-dependency walk to test paths. trace Shortest call path + budget-capped snippets. explore Multi-name neighborhood survey. diff --git a/src/cli/main.ts b/src/cli/main.ts index 2194e9a3..dd569d12 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -322,6 +322,7 @@ Copies bundled agent templates into .agents/ under the project root. via: parsed.via, depth: parsed.depth, limit: parsed.limit, + inPath: parsed.inPath, summary: parsed.summary, json: parsed.json, }); diff --git a/templates/agent-content/mcp-instructions.md b/templates/agent-content/mcp-instructions.md index e287ee9b..666c5916 100644 --- a/templates/agent-content/mcp-instructions.md +++ b/templates/agent-content/mcp-instructions.md @@ -35,7 +35,7 @@ Key fields: `pending_sync` (watcher debounce queue or in-flight reindex), `commi | Field-qualified symbol discovery | **`show`** or **`snippet`** (`query` with `kind:` / `name:` / `path:` / `in:` + free text) | `find-symbol-by-kind` for kind-heavy patterns; CLI `codemap show --query '…' --print-sql` to inspect generated SQL (no MCP `print_sql` arg) | | Kind / pattern lookup | **`query_recipe`** | `find-symbol-by-kind` | | Source at symbol | **`snippet`** | same rows as `show` + disk text | -| Blast radius | **`impact`** (`target`, `direction`, `via`, `depth`) | `fan-in` for file hubs; symbol call graph via SQL or `impact` | +| Blast radius | **`impact`** (`target`, `direction`, `via`, `depth`, `in?`) — homonym symbols: unscoped unions per-defining-file graphs; `in` scopes one definition | `fan-in` for file hubs; symbol call graph via SQL or `impact` | | Call path + snippets | **`trace`** (`from`, `to`, `via?`, `max_depth?`, `budget_chars?`) — adaptive snippet caps 15k/10k/6k when omitted | `call-path` | | Type extends / implements chain | **`query_recipe`** | `type-ancestors`, `type-descendants` (`file_path` when homonyms; on `type-descendants` also scopes output to that file) | | Multi-symbol survey | **`explore`** (`names`, `depth?`, `kind?`, `budget_chars?`) — row cap always adaptive (500/250/125); snippets 15k/10k/6k when `budget_chars` omitted | `symbol-neighborhood` (once per name) | diff --git a/templates/agent-content/skill/10-recipes-context.md b/templates/agent-content/skill/10-recipes-context.md index 91e67293..6cd29fb1 100644 --- a/templates/agent-content/skill/10-recipes-context.md +++ b/templates/agent-content/skill/10-recipes-context.md @@ -50,7 +50,7 @@ Each emitted delta carries its own `base` metadata so mixed-baseline audits are - **`validate`** — `{paths?: string[]}`. SHA-256 vs `files.content_hash`; returns only out-of-sync rows (`stale` / `missing` / `unindexed` / `rejected` — fresh paths are omitted; `rejected` includes optional `reason`: `path escapes project root` | `path escapes via symlink` | `path resolves outside project root`). Output `path` keys are project-relative POSIX paths. - **`show`** — `{name, kind?, in?}` or `{query, with_fts?}`. Exact symbol lookup or field-qualified search (`kind:`, `name:`, `path:`, `in:` + free text) → `{matches, disambiguation?, warning?}`. CLI: `codemap show --query '…' [--print-sql]`. - **`snippet`** — same as `show` (`{name, kind?, in?}` or `{query, with_fts?}`) but each match also carries `source` (file text) + `stale` / `missing` flags → `{matches, disambiguation?, warning?}`. No reindex side-effects. -- **`impact`** — `{target, direction?, via?, depth?, limit?, summary?}`. Symbol/file blast-radius walker (replaces hand-composed `WITH RECURSIVE`). Auto-resolves symbol vs file target; `via` defaults to every backend compatible with the kind. +- **`impact`** — `{target, direction?, via?, depth?, limit?, in?, summary?}`. Symbol/file blast-radius walker (replaces hand-composed `WITH RECURSIVE`). Auto-resolves symbol vs file target; `via` defaults to every backend compatible with the kind. `in` disambiguates symbol homonyms (prefix/exact like `show`); unscoped homonyms union per-defining-file call graphs; mismatch → empty `matches` + `skipped_scope`. - **`trace`** — `{from, to, max_depth?, via?, budget_chars?}`. CLI: `codemap trace --from … --to …`. Shortest call path + budget-capped snippets (`call-path` recipe twin). Omitted `budget_chars` scales with indexed file count (15k / 10k / 6k). `truncated` when snippet budget hit (`truncation.snippets`); dependency hops set `snippets_skipped_reason` instead of auto-snippets. - **`explore`** — `{names, depth?, kind?, budget_chars?}`. CLI: `codemap explore …`. Multi-name neighborhood survey + snippets (`symbol-neighborhood` per deduped name). Explore row cap is always adaptive (500 / 250 / 125 by repo size); snippet budget is adaptive (15k / 10k / 6k) when `budget_chars` omitted. `truncated` when row cap and/or snippet budget hit (`truncation.rows` / `truncation.snippets`). - **`node`** — `{name, kind?, in?, include_snippets?, budget_chars?}`. CLI: `codemap node [--include-snippets]`. `show` center + scoped depth-1 neighborhood; optional center+neighbor snippets when `include_snippets: true` (adaptive `budget_chars` when omitted; `truncated` / `truncation.snippets` only then).