diff --git a/.changeset/analysis-route-small-fixes-bundle.md b/.changeset/analysis-route-small-fixes-bundle.md new file mode 100644 index 0000000..c285c3b --- /dev/null +++ b/.changeset/analysis-route-small-fixes-bundle.md @@ -0,0 +1,5 @@ +--- +"gitsema": minor +--- + +Phase 143: closed a grab-bag of small HTTP/CLI flag-parity gaps across `src/server/routes/analysis.ts`. `POST /analysis/merge-audit` gained `base` (merge-base override); `POST /analysis/merge-preview` gained `top`/`iterations`/`edgeThreshold`/`enhancedKeywordsN`/`useEnhancedLabels`; `POST /analysis/branch-summary` gained `enhancedLabels`/`enhancedKeywordsN` (slices `nearestConcepts[].topKeywords` in the JSON response); `POST /analysis/clusters` gained `iterations`/`edgeThreshold`/`enhancedKeywordsN`; `POST /analysis/security-scan` gained `highConfidenceOnly`; `POST /analysis/impact` gained `chunks`/`level`/`lens` (`structural`/`hybrid` now makes it a thin `blast-radius` alias, closing a prior silent divergence from the CLI's default); `POST /analysis/semantic-diff` gained `hybrid`/`bm25Weight` (also fixing a pre-existing CLI bug where `diff`'s `--hybrid`/`--bm25-weight` flags were declared but never wired to anything); `POST /analysis/semantic-blame` gained `level` (file/symbol). diff --git a/.changeset/graph-family-http-mcp-exposure.md b/.changeset/graph-family-http-mcp-exposure.md new file mode 100644 index 0000000..cac6db7 --- /dev/null +++ b/.changeset/graph-family-http-mcp-exposure.md @@ -0,0 +1,5 @@ +--- +"gitsema": minor +--- + +The `graph` command family (`callers`, `callees`, `neighbors`, `path`, `relate`, `similar`, `unused`, `cycles`, `deps`, `co-change`, `blast-radius`) is now exposed over HTTP (`POST /api/v1/graph/*`) and MCP (`graph_path`, `graph_relate`, `graph_similar`, `graph_unused`, `cycles`, `deps`, `co_change`, `blast_radius` tools; `callers`/`callees` gained HTTP routes and reuse the existing `call_graph` MCP tool), matching the CLI's existing flag surface. `graph build` remains CLI-only — it's a mutating, truncate-and-rebuild index-maintenance operation, not a query, consistent with `index vacuum`/`gc`/`rebuild-fts`/etc. diff --git a/.changeset/guide-http-route-lens.md b/.changeset/guide-http-route-lens.md new file mode 100644 index 0000000..d269786 --- /dev/null +++ b/.changeset/guide-http-route-lens.md @@ -0,0 +1,5 @@ +--- +"gitsema": minor +--- + +`POST /api/v1/guide/chat` (HTTP API) now accepts a `lens: 'semantic'|'structural'|'hybrid'` field, mirroring CLI `gitsema guide --lens` — under `structural`/`hybrid` it biases the guide agent's tool choice toward `call_graph`/`blast_radius`/`hotspots`, identically to the CLI. Remote multi-turn/session support (an HTTP equivalent of CLI `guide --interactive`) remains a deferred, unresolved design question — see `docs/feature-ideas.md`. diff --git a/.changeset/narrate-explain-http-parity.md b/.changeset/narrate-explain-http-parity.md new file mode 100644 index 0000000..207f546 --- /dev/null +++ b/.changeset/narrate-explain-http-parity.md @@ -0,0 +1,5 @@ +--- +"gitsema": minor +--- + +`POST /narrate` and `POST /explain` now accept an `evidenceOnly` field, letting HTTP callers explicitly request the same safe-by-default evidence-only mode as the CLI's `narrate`/`explain` (omitted = evidence-only, no LLM call) — both responses also gain a structured `evidence` array. `POST /explain` additionally accepts `log` (error/stack-trace context file) and `files` (search-scope glob), and both routes accept `lens`, which on `/explain` returns a `structuralContext` field (call-graph/co-change enrichment) when combined with a concrete `files` path under a `structural`/`hybrid` lens. diff --git a/.changeset/phase-148-insights-mcp-http-parity.md b/.changeset/phase-148-insights-mcp-http-parity.md new file mode 100644 index 0000000..00ea6b1 --- /dev/null +++ b/.changeset/phase-148-insights-mcp-http-parity.md @@ -0,0 +1,5 @@ +--- +"gitsema": minor +--- + +`bisect`, `refactor-candidates`, `lifecycle`, `cherry-pick-suggest`, `file-diff`, `pr-report`, `regression-gate`, `code-review`, `map`, and `heatmap` are now available as MCP tools and `POST /api/v1/insights/*` HTTP routes, not just CLI commands — AI clients and remote callers can now reach them directly. Also fixes a pre-existing bug in `refactor-candidates`' default symbol-level scan that made it error out on any index with symbol embeddings. diff --git a/.changeset/watch-list-remove-http-routes.md b/.changeset/watch-list-remove-http-routes.md new file mode 100644 index 0000000..7faa89c --- /dev/null +++ b/.changeset/watch-list-remove-http-routes.md @@ -0,0 +1,5 @@ +--- +"gitsema": minor +--- + +The HTTP API now exposes `GET /watch` (list saved watch queries) and `DELETE /watch/:name` (remove one by name), matching the CLI's `watch list`/`watch remove` — previously only `watch add`/`watch run` had HTTP routes. diff --git a/.changeset/workflow-http-route-parity.md b/.changeset/workflow-http-route-parity.md new file mode 100644 index 0000000..4212840 --- /dev/null +++ b/.changeset/workflow-http-route-parity.md @@ -0,0 +1,5 @@ +--- +"gitsema": minor +--- + +The HTTP `POST /analysis/workflow` route now supports all 8 productized workflow templates that CLI `workflow run` has (`pr-review`, `incident`, `release-audit`, `onboarding`, `ownership-intel`, `arch-drift`, `knowledge-portal`, `regression-forecast`) instead of just 3, and accepts `role`/`ref` body fields (mirroring CLI `--role`/`--ref`) generally rather than gated to a single template. diff --git a/CLAUDE.md b/CLAUDE.md index 7c60bfd..3ac8e8e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -400,8 +400,8 @@ git repo [ src/core/models/ ] types.ts — shared embedding/result type definitions ↓ [ src/cli/ ] program.ts (COMMAND_GROUPS) + register/*.ts + commands/*.ts — Commander.js CLI -[ src/mcp/ ] server.ts + tools/{search,analysis,clustering,graph,infrastructure,workflow,narrator}.ts — MCP stdio server -[ src/server/ ] app.ts + routes/{search,analysis,evolution,graph,guide,narrator,blobs,commits,projections,remote,status,watch}.ts — HTTP API server (remote backend) +[ src/mcp/ ] server.ts + tools/{search,analysis,clustering,graph,infrastructure,workflow,narrator,insights}.ts — MCP stdio server +[ src/server/ ] app.ts + routes/{search,analysis,insights,evolution,graph,guide,narrator,blobs,commits,projections,remote,status,watch}.ts — HTTP API server (remote backend) [ src/client/ ] remoteClient.ts — client for remote server mode ``` @@ -567,7 +567,7 @@ node dist/cli/index.js tools mcp The MCP server reads the same environment variables as the CLI. It runs against the `.gitsema/index.db` in the current working directory when the server is started. -**Exposed tools (38 total, registered across `src/mcp/tools/{search,analysis,clustering,infrastructure,workflow,narrator,graph}.ts`):** +**Exposed tools (56 total, registered across `src/mcp/tools/{search,analysis,clustering,infrastructure,workflow,narrator,graph,insights}.ts`):** | Tool | Description | |---|---| @@ -608,7 +608,25 @@ The MCP server reads the same environment variables as the CLI. It runs against | `call_graph` | Structural call-graph traversal — callers/callees of a symbol (Phase 108) | | `graph_neighbors` | Typed neighborhood of a graph node — any edge kinds, direction, depth (Phase 108) | | `hotspots` | Architectural risk = co-change × call-coupling × churn; `lens` selects which signals (Phase 110) | +| `graph_path` | Shortest typed path between two graph nodes (Phase 108/147) | +| `graph_relate` | Structural callers/callees + semantically similar blobs/symbols for a node (Phase 109/147) | +| `graph_similar` | Structural (Jaccard shape) + semantic similarity to a node (Phase 109/147) | +| `graph_unused` | Symbols/files with no inbound calls/imports edges (Phase 109/147) | +| `cycles` | Cycle detection over typed edges, default `imports` (Phase 107/147) | +| `deps` | Dependency/dependent closure over imports/calls/extends/implements edges (Phase 107/147) | +| `co_change` | Files that historically change together with a path (Phase 107/147) | +| `blast_radius` | Structural dependents + semantic neighbors — "what breaks if I touch this" (Phase 109/147) | | `get_skill` | Return the gitsema agent skill (usage + result-interpretation guidance for every tool) — MCP-only, so clients can ground tool usage | +| `semantic_bisect` | Binary search over commit history to find where a concept diverged from a "good" baseline (Phase 148) | +| `refactor_candidates` | Find pairs of symbols/chunks/files similar enough to be refactoring candidates (Phase 148) | +| `concept_lifecycle` | Lifecycle stages (born → growing → mature → declining → dead) of a concept over history (Phase 148) | +| `cherry_pick_suggest` | Suggest commits to cherry-pick by semantic similarity of commit messages (Phase 148) | +| `file_diff` | Semantic diff (cosine distance) between two versions of a single file (Phase 148) | +| `pr_report` | Composite PR report: semantic diff, impacted modules, change-points, reviewer suggestions (Phase 148) | +| `regression_gate` | CI gate: per-query base/head drift check against a threshold (Phase 148) | +| `code_review` | Historical analogues + regression-risk heuristic for a unified diff (Phase 148) | +| `activity_heatmap` | Count of distinct blob changes per time period, week or month (Phase 148) | +| `semantic_map` | Most recent k-means cluster snapshot + per-cluster blob-assignment counts (Phase 148) | --- diff --git a/README.md b/README.md index e7b825d..4bb674d 100644 --- a/README.md +++ b/README.md @@ -338,6 +338,9 @@ Both commands are **safe-by-default**: with no narrator model configured (or wit | `--until ` | — | Only include commits before this ref or date | | `--range ` | — | Git revision range (e.g. `v1.0..HEAD`) (`narrate` only) | | `--focus ` | `all` | Filter commits by area: `bugs`, `features`, `ops`, `security`, `deps`, `performance`, `all` (`narrate` only) | +| `--log ` | — | Path to an error log/stack-trace file to include as LLM context (`explain` only) | +| `--files ` | — | Restrict search to files matching this glob (`explain` only); currently only consumed by `--lens` structural enrichment, not commit filtering (see below) | +| `--lens ` | `semantic` | `semantic\|structural\|hybrid` (`explain` only) — under `structural`/`hybrid` with a concrete `--files` path, appends grounded call-graph/co-change context after the result | | `--format ` | `md` | Output format when narrating: `md`, `text`, `json` (legacy: prefer `--out`) | | `--out ` | — | Output spec (repeatable): `text\|json[:file]\|markdown[:file]` (overrides `--format`) | | `--max-commits ` | `500` | Maximum commits to analyse (`narrate` only) | @@ -550,18 +553,18 @@ Track semantic drift of a single file across its Git history. | Command | Description | |---|---| -| `gitsema graph build` | Build/rebuild `graph_nodes` and `edges` from `structural_refs`, `symbols`, and `blob_commits` (truncate-and-rebuild; requires prior `gitsema index --graph`) | -| `gitsema co-change [-k/--top ]` | Files that historically change together with `` | -| `gitsema deps [--reverse] [--depth ] [--edge-types ]` | Import/dependency closure of a file or symbol (default edge types: `imports,calls,extends,implements`) | -| `gitsema graph cycles [--edge-types ]` / `gitsema cycles [--edge-types ]` | Detect cycles in the structural graph (default: `imports`) | -| `gitsema graph callers [--depth ]` | Reverse `calls` traversal — who (transitively) calls `` (default depth 3, max 3) | -| `gitsema graph callees [--depth ]` | Forward `calls` traversal — what `` (transitively) calls (default depth 3, max 3) | -| `gitsema graph neighbors [--edge-types ] [--direction ] [--depth ] [--out ]` | Typed neighborhood of `` — any edge kinds by default (default depth 1, max 3) | -| `gitsema graph path [--out ]` | Shortest typed path from `` to `` (max depth 3) | -| `gitsema blast-radius [--lens ] [--depth ] [-k/--top ] [--weight-structural ] [--out ]` | What changes if I touch this — structural dependents (`calls`/`imports`/`extends`/`implements`/`references`, reverse traversal) and/or semantically similar blobs (default lens: hybrid) | -| `gitsema relate [--lens ] [-k/--top ] [--out ]` | Callers/callees (structural, depth 1) and semantically similar blobs, labeled — both lenses, lose neither (default lens: hybrid) | -| `gitsema similar [--lens ] [-k/--top ] [--weight-structural ] [--out ]` | Symbols/files with a similar call/import shape (structural, Jaccard overlap) and/or semantically similar (vector) (default lens: hybrid) | -| `gitsema unused [--edge-types ]` | Symbols/files with no inbound `calls`/`imports` edges — structural complement to `dead-concepts` | +| `gitsema graph build` | Build/rebuild `graph_nodes` and `edges` from `structural_refs`, `symbols`, and `blob_commits` (truncate-and-rebuild; requires prior `gitsema index --graph`). CLI-only — a mutating index-maintenance operation, not exposed over HTTP/MCP (Phase 147), same as `index vacuum`/`gc`/etc | +| `gitsema co-change [-k/--top ]` | Files that historically change together with ``. Also `POST /api/v1/graph/co-change` and MCP `co_change` | +| `gitsema deps [--reverse] [--depth ] [--edge-types ]` | Import/dependency closure of a file or symbol (default edge types: `imports,calls,extends,implements`). Also `POST /api/v1/graph/deps` and MCP `deps` | +| `gitsema graph cycles [--edge-types ]` / `gitsema cycles [--edge-types ]` | Detect cycles in the structural graph (default: `imports`). Also `POST /api/v1/graph/cycles` and MCP `cycles` | +| `gitsema graph callers [--depth ]` | Reverse `calls` traversal — who (transitively) calls `` (default depth 3, max 3). Also `POST /api/v1/graph/callers` and MCP `call_graph` (`direction: callers`) | +| `gitsema graph callees [--depth ]` | Forward `calls` traversal — what `` (transitively) calls (default depth 3, max 3). Also `POST /api/v1/graph/callees` and MCP `call_graph` (`direction: callees`) | +| `gitsema graph neighbors [--edge-types ] [--direction ] [--depth ] [--out ]` | Typed neighborhood of `` — any edge kinds by default (default depth 1, max 3). Also `POST /api/v1/graph/neighbors` and MCP `graph_neighbors` | +| `gitsema graph path [--out ]` | Shortest typed path from `` to `` (max depth 3). Also `POST /api/v1/graph/path` and MCP `graph_path` | +| `gitsema blast-radius [--lens ] [--depth ] [-k/--top ] [--weight-structural ] [--out ]` | What changes if I touch this — structural dependents (`calls`/`imports`/`extends`/`implements`/`references`, reverse traversal) and/or semantically similar blobs (default lens: hybrid). Also `POST /api/v1/graph/blast-radius` and MCP `blast_radius` | +| `gitsema relate [--lens ] [-k/--top ] [--out ]` | Callers/callees (structural, depth 1) and semantically similar blobs, labeled — both lenses, lose neither (default lens: hybrid). Also `POST /api/v1/graph/relate` and MCP `graph_relate` | +| `gitsema similar [--lens ] [-k/--top ] [--weight-structural ] [--out ]` | Symbols/files with a similar call/import shape (structural, Jaccard overlap) and/or semantically similar (vector) (default lens: hybrid). Also `POST /api/v1/graph/similar` and MCP `graph_similar` | +| `gitsema unused [--edge-types ]` | Symbols/files with no inbound `calls`/`imports` edges — structural complement to `dead-concepts`. Also `POST /api/v1/graph/unused` and MCP `graph_unused` | | `gitsema hotspots [--lens ] [-k/--top ] [--out ]` | Architectural risk = co-change (temporal) × call-coupling (structural) × churn — geometric mean of the signals the lens selects (default lens: hybrid). Also `POST /api/v1/graph/hotspots` and MCP `hotspots` | `--out ` on the six commands above (repeatable: `text\|json[:file]\|html[:file]\|markdown[:file]`) renders a unified subgraph view: `html` is an interactive force-graph with clickable nodes (Phase 112), `text`/`markdown` are an ASCII tree / nested bullet list. Omitting `--out` leaves each command's own default text output unchanged. @@ -571,7 +574,7 @@ Track semantic drift of a single file across its Git history. | Command | Description | |---|---| | `gitsema repos` | Manage tracked repositories for multi-repo indexing | -| `gitsema watch` | Manage saved searches and watch mode notifications | +| `gitsema watch add/list/remove/run` | Manage saved searches and watch mode notifications. Also `POST /api/v1/watch/add`, `GET /api/v1/watch`, `DELETE /api/v1/watch/:name`, `POST /api/v1/watch/run` | | `gitsema pr-report [options]` | Compose a semantic PR report: diff, impacted modules, change-points, reviewer suggestions | | `gitsema regression-gate [options]` | CI gate: fail if key concepts drift beyond threshold between two refs | | `gitsema bisect ` | Semantic git bisect — binary search to find where a concept diverged from a "good" baseline | @@ -586,7 +589,7 @@ Track semantic drift of a single file across its Git history. | Command | Description | |---|---| | `gitsema workflow list` | List available productized workflow templates | -| `gitsema workflow run ` | Run a named workflow template (`pr-review` \| `incident` \| `release-audit`) | +| `gitsema workflow run ` | Run a named workflow template (`pr-review` \| `incident` \| `release-audit` \| `onboarding` \| `ownership-intel` \| `arch-drift` \| `knowledge-portal` \| `regression-forecast`). Flags: `--file`, `--query`, `--role` (onboarding topic, alias for `--query`), `--ref` (regression-forecast base ref), `--base` (declared for pr-review, currently a no-op), `--top`, `--format`/`--dump`/`--out` | ### Repo Insights diff --git a/docs/PLAN.md b/docs/PLAN.md index 8e39647..326d272 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -5429,13 +5429,13 @@ Phase 140 should generalize or fold into that rather than starting fresh. | 139 | `evolution` (file/concept) + `hotspots` HTTP/MCP parity | Search/evolution/graph audit | | 140 | Systemic `--model`/`--text-model`/`--code-model` override triplet, missing from nearly every `analysis.ts` HTTP route | analysis.ts audit | | 141 | `author` HTTP route full parity (largest single-command gap) | analysis.ts audit | -| 142 | `workflow` HTTP route parity (3/8 → 8/8 templates, `--role`/`--ref`/`--base`) | analysis.ts audit | -| 143 | Analysis-route small-fixes bundle (merge-audit, merge-preview, branch-summary, clusters, security-scan, impact, semantic-diff, semantic-blame) | analysis.ts audit | +| 142 ✅ | `workflow` HTTP route parity (3/8 → 8/8 templates, `--role`/`--ref`/`--base`) | analysis.ts audit | +| 143 ✅ | Analysis-route small-fixes bundle (merge-audit, merge-preview, branch-summary, clusters, security-scan, impact, semantic-diff, semantic-blame) | analysis.ts audit | | 144 | `narrate`/`explain` HTTP routes: evidence-only/LLM-prose toggle, `--log`/`--files`, `--lens` | remote/watch/guide/narrator audit | -| 145 | `guide` HTTP route: `--lens` param, remote multi-turn/session support | remote/watch/guide/narrator audit | +| 145 ✅ | `guide` HTTP route: `--lens` param (shipped); remote multi-turn/session support explicitly deferred, see phase entry + `docs/feature-ideas.md` | remote/watch/guide/narrator audit | | 146 | `watch list`/`watch remove` HTTP routes (currently only `add`/`run` exist) | remote/watch/guide/narrator audit | | 147 | Graph command family HTTP/MCP exposure (`graph build/callers/callees/path/relate/similar/unused`, `cycles`, `deps`, `co-change`, `blast-radius`) | Missing-endpoints audit; closes the pre-existing `docs/parity.md` "Expose graph commands to HTTP API" roadmap item | -| 148 | Triage which remaining zero-HTTP/MCP-exposure CLI commands (`bisect`, `refactor-candidates`, `ci-diff`, `lifecycle`, `cherry-pick-suggest`, `regression-gate`, `code-review`, `cross-repo-similarity`, `pr-report`, `file-diff`, `diff`, `map`/`heatmap`/`project`) genuinely warrant exposure vs. are CLI/visualization-shaped by nature | Missing-endpoints audit | +| 148 ✅ | Triage which remaining zero-HTTP/MCP-exposure CLI commands (`bisect`, `refactor-candidates`, `ci-diff`, `lifecycle`, `cherry-pick-suggest`, `regression-gate`, `code-review`, `cross-repo-similarity`, `pr-report`, `file-diff`, `diff`, `map`/`heatmap`/`project`) genuinely warrant exposure vs. are CLI/visualization-shaped by nature — 10 landed in bucket (a) and gained MCP tools + HTTP routes, 3 in bucket (b) (CLI-shaped), 2 in bucket (c) (redundant) | Missing-endpoints audit | --- @@ -5622,7 +5622,44 @@ it in — flag any that aren't as a follow-up rather than silently stubbing. `src/core/workflow/*` (wherever templates are implemented), `docs/parity.md`, `docs/features.md`, `README.md`, test files. -**Status:** not started. +**Status:** ✅ complete. + +**Deviations from spec:** +- The workflow templates were never in a separate `src/core/workflow/*` + module — they're implemented inline in `src/cli/commands/workflow.ts`'s + `workflowCommand()`. There was no shared core function to import; the + HTTP route already computed the first 3 templates' sections inline + (calling the same underlying `computeImpact`/`computeConceptChangePoints`/ + `computeExperts`/`vectorSearch` functions the CLI calls), so the 5 new + templates were wired the same way — mirroring `workflowCommand()`'s + per-template logic directly in the route handler rather than extracting + a new shared module (kept the diff scoped to the route file per the + parallel-worktree instruction not to touch unrelated modules). +- All 5 newly-exposed templates' underlying functions + (`computeAuthorContributions`, `getActiveSession`, `computeHealthTimeline`, + `scoreDebt`, plus the already-used `embedQuery`/`vectorSearch`/ + `computeConceptChangePoints`/`computeExperts`) were **already imported and + used elsewhere in `analysis.ts`** (by the `/author`, `/health`, `/debt` + routes) — confirming they're HTTP-callable with no local-filesystem or + interactive assumptions. No follow-up flags needed for any of the 5. +- `role`/`ref` are new body fields (mirroring the CLI's `--role`/`--ref`). + `base` was already present in the schema and not gated to a specific + template at the validation layer; investigation found it's actually a + **no-op in the CLI itself** — `workflowCommand()` declares `options.base` + in its `WorkflowOptions` interface but never reads it in the function + body, for any template, including `pr-review`. Rather than inventing new + HTTP-only behavior for a flag the CLI doesn't implement, the HTTP route + now matches the CLI exactly: `base` is accepted (no validation gating) + and remains inert. Flagged in `docs/parity.md` as a known CLI-level gap, + not something this HTTP-parity phase should silently "fix" on one + interface only. +- Per-template required-field validation was added for the 3 templates that + require `query` in the CLI (`ownership-intel`, `knowledge-portal`, + `regression-forecast`) — mirroring the existing `pr-review`/`incident` + pattern already on the route. `onboarding` and `arch-drift` have no + required fields (defaults), also matching the CLI. +- Model overrides (`model`/`textModel`/`codeModel`) intentionally untouched + — already closed in Phase 140 and out of scope here. --- @@ -5653,7 +5690,33 @@ coherence fixes into one sweep. **Files likely touched:** `src/server/routes/analysis.ts`, `docs/parity.md`, test files. -**Status:** not started. +**Status:** ✅ complete. Implemented as scoped, plus two small deviations: + +- **`merge-preview`** also gained `useEnhancedLabels` (not in the original + scope list) since `enhancedKeywordsN` is a no-op in `computeClusters`/ + `computeMergeImpact` unless `useEnhancedLabels` is also true — adding the + keyword-count knob without the toggle that activates it would have shipped + a dead flag, contradicting the phase's own goal. +- **`branch-summary`**: `computeBranchSummary()` has no enhanced-labels + concept of its own — in the CLI, `--enhanced-labels`/`--enhanced-keywords-n` + only control how many of a concept's already-computed `topKeywords` are + *displayed* in the text renderer, while `--dump` JSON always returns the + full unsliced array. Since HTTP has no equivalent "text vs. JSON" split + (it's JSON-only), `POST /analysis/branch-summary` now slices + `nearestConcepts[].topKeywords` to `enhancedKeywordsN` (default 8) when + `enhancedLabels` is set, else 5 — giving the flags a real, observable + effect on the only representation this interface has. +- **`semantic-diff`/`diff`**: fixed a pre-existing CLI bug in the process — + the CLI `diff ` command already declared + `--hybrid`/`--bm25-weight` in its Commander options but never wired them + to anything (dead flags, silently ignored). `computeSemanticDiff()` + (`src/core/search/semanticDiff.ts`) gained an optional `candidateBlobs` + parameter mirroring `computeAuthorContributions`'s Phase-141 + `candidateBlobs` pattern; both the CLI and `POST /analysis/semantic-diff` + now genuinely blend BM25 via `hybridSearch()` when `hybrid` is set. + +See `docs/parity.md` §1/§2.2/§6 (Phase 143 entry) for the full flag-by-flag +breakdown. --- @@ -5680,7 +5743,45 @@ routes share narrator-config plumbing. **Files likely touched:** `src/server/routes/narrator.ts`, `docs/parity.md`, `docs/features.md`, `README.md`, test files. -**Status:** not started. +**Status:** ✅ complete. + +**Deviations from spec:** +- `NarrateBodySchema` and `ExplainBodySchema` both gained `evidenceOnly: + z.boolean().optional()`, threaded straight into `runNarrate`/`runExplain`'s + existing `evidenceOnly` option (which already defaulted to `true` — + evidence-only — so no behavior change there, just the missing schema + field). Both routes' JSON responses also gained a structured `evidence` + field (`result.evidence`) so HTTP callers get the raw commit evidence + directly instead of having to re-parse the `prose` string (which was + `JSON.stringify(events)` in evidence-only mode) — a small additive + response-shape improvement in the spirit of making the new toggle + actually useful over HTTP, not just accepted. +- `ExplainBodySchema` gained `log`/`files`, passed through to `runExplain` + exactly as the CLI does. Note: `files` is a **pre-existing CLI-side + no-op** for actual commit filtering — neither `explainCommand` nor + `runExplain()` ever restricted the git-log search by it; it's only + consumed by the Phase 111 lens-driven structural-context append. This + phase mirrors that behavior as-is (documented in `docs/parity.md` §2.2) + rather than silently fixing `runExplain()`'s search-scope logic, which + would be a behavior change beyond this phase's HTTP-route-parity scope. +- `lens` was added to both schemas. For `/explain`, it's fully functional: + `structural`/`hybrid` + a concrete `files` path triggers the same + `structuralContextForPath()`/`formatStructuralContext()` call the CLI's + `explainCommand` makes after printing its result, surfaced as a + `structuralContext` response field (plus `lens` echoed back) instead of + an appended stdout line. For `/narrate`, `lens` is accepted for CLI + flag-surface parity but is a documented **no-op** — the CLI's own + `narrateCommand` has no `--lens` option and no single-file target to + enrich (unlike `explain`'s `--files`), so there is nothing for `/narrate` + to enrich yet either. This mirrors the precedent set by Phase 139's + `hotspots` `weightStructural` no-op. +- Added `tests/serverRoutes.test.ts` coverage: 11 new cases under `POST + /api/v1/narrate — Phase 144 evidenceOnly toggle` and `POST + /api/v1/explain — Phase 144 evidenceOnly/log/files/lens` (default + evidence-only, explicit `evidenceOnly: false` is still safe with no + narrator configured, `lens`/`evidenceOnly` validation rejects bad values, + `log`/`files` accepted without erroring, `structuralContext` omitted for + the default `semantic` lens or when the graph has no matching node). --- @@ -5707,7 +5808,30 @@ chat is out of scope for now and only `--lens` ships in this phase. **Files likely touched:** `src/server/routes/guide.ts`, `docs/parity.md`, test files. -**Status:** not started. +**Status:** ✅ complete (lens only). `GuideChatBodySchema` in +`src/server/routes/guide.ts` gained `lens: 'semantic'|'structural'|'hybrid'`. +The route doesn't pass `lens` into `runGuide()`'s options (that function has +no lens parameter) — instead, mirroring CLI `guideCommand`'s `withLens()` in +`src/cli/commands/guide.ts` exactly, the route appends the identical +`"\n\n(Lens preference: — prefer the structural tools call_graph, +blast_radius, and hotspots where relevant.)"` hint suffix to the question +before calling `runGuide()`, for `structural`/`hybrid` (semantic/default +leaves the question unchanged, byte-for-byte identical to pre-Phase-145 +behavior). Covered by `tests/serverGuideRoute.test.ts`. + +**Deviation from spec — remote multi-turn/session support deliberately +deferred:** the open design question in this phase's original scope (an +HTTP equivalent of CLI `guide --interactive`/`-i`, which reuses one agent +session across multiple turns) is **not resolved or implemented** here. +This is a considered deferral, not a silent drop: building a `sessionId` +scheme (client-generated vs. server-issued, TTL, in-memory vs. persisted +session storage, cleanup/eviction policy) is a genuine cross-cutting design +question — e.g. it needs an answer for multi-process/restart durability, +and for auth (`repo_grants`/org boundaries) touching who can resume whose +session — that deserves its own design pass rather than a speculative +schema bolted onto this HTTP-parity phase. Tracked as a follow-up idea in +`docs/feature-ideas.md` ("Remote multi-turn `guide` sessions over HTTP") +for a future phase to pick up once that question is worked through. --- @@ -5726,7 +5850,21 @@ routes mirroring CLI's `watch list`/`watch remove` behavior. Update **Files likely touched:** `src/server/routes/watch.ts`, `docs/parity.md`, test files. -**Status:** not started. +**Status:** ✅ complete. Added `GET /watch` (lists all saved queries as +`{ watches: [{ id, name, query, lastRunAt, webhookUrl }] }`, newest-first — +same query and shape as CLI `watch list`) and `DELETE /watch/:name` (removes +by name, 404 with an error body if not found) to +`src/server/routes/watch.ts`. One deviation from the "or equivalent" +`DELETE /watch/:id` phrasing in the original scope: the route uses `:name`, +not a numeric `:id`, because the CLI's own `watch remove ` deletes by +`name` (the column carrying `UNIQUE NOT NULL` on `saved_queries`), not by +row id — matching CLI behavior exactly took priority over the id-shaped +route sketch. Added a `watch` row to `docs/parity.md`'s Tool Matrix (it had +no row at all before this phase) and closed out the corresponding roadmap +bullet in §6. Added HTTP route rows to `docs/features.md`'s HTTP API table +and expanded the `README.md` `watch` command-reference row. Added +integration tests to `tests/serverRoutes.test.ts` covering add → list → +remove → 404-on-repeat-remove and list-when-empty. --- @@ -5765,7 +5903,50 @@ commands). `src/mcp/tools/graph.ts`, `docs/parity.md`, `docs/features.md`, `README.md`, test files. -**Status:** not started. +**Status:** ✅ complete. Implemented as specified. `src/server/routes/graph.ts` +gained `POST` routes for `/callers`, `/callees`, `/neighbors`, `/path`, +`/relate`, `/similar`, `/unused`, `/cycles`, `/deps`, `/co-change`, and +`/blast-radius`, each a thin Zod-validated wrapper over the existing +`src/core/graph/*` query helpers (`traversal.ts`, `relate.ts`, `similar.ts`, +`unused.ts`, `cycles.ts`, `deps.ts`, `coChange.ts`, `blastRadius.ts`) — +JSON-serializing the same result shape the CLI commands already render to +text, following the pre-existing `/hotspots` route's pattern exactly (POST + +body schema, not GET, matching every other analysis route in this server). +`src/mcp/tools/graph.ts` gained `graph_path`, `graph_relate`, `graph_similar`, +`graph_unused`, `cycles`, `deps`, `co_change`, and `blast_radius` MCP tools — +the exact 8 tools scoped above. `graph callers`/`graph callees` were **not** +added as new MCP tools (deviation, not an omission): the pre-existing +`call_graph` tool already covers both directions via its `direction` +parameter, so a second tool would duplicate it; they **did** get new HTTP +routes (`/callers`, `/callees`) since HTTP had no equivalent to `call_graph` +at all and the Goal explicitly listed both as gaps. +**`graph build` decision:** kept intentionally CLI-only, not exposed as a +mutating HTTP route. Rationale (per the open design question): it truncates +and rebuilds `graph_nodes`/`edges` from `structural_refs`/`symbols`/ +`blob_commits` — a full-table-rewrite index-maintenance operation, not a +query. Auditing gitsema's other maintenance commands of the same shape +(`index vacuum`, `index gc`, `index rebuild-fts`, `index update-modules`, +`index clear-model`, `index build-vss`) confirmed **none** of them have a +real HTTP route either (`docs/parity.md`'s matrix claimed HTTP ✓ for several +of these rows, but no corresponding route exists anywhere in +`src/server/routes/` — a pre-existing documentation inaccuracy, left +uncorrected here as out of scope for this phase and not re-derived by rows +this phase doesn't own). `graph build` follows the real (not the +mis-documented) precedent: mutating maintenance stays CLI/local-only. +Since remote MCP delegation (`tools mcp --remote`) dispatches through the +same `registerGraphTools()` registrations via `protocolRouter()` +(`src/server/routes/protocol.ts`), all 8 new MCP tools work transparently +over `mcp.` remote delegation with no additional wiring. +Tests added: `tests/serverRoutes.test.ts` gained one `describe` block per new +route (not-found-resolution shape on an empty graph, lens/edge-type +validation, 400 on missing required fields) — 25 new assertions. No new +tests were added directly against the MCP tool handlers or the CLI command +wrappers, consistent with the existing precedent in this codebase that thin +CLI-command/MCP-tool plumbing over already-unit-tested core `src/core/graph/*` +functions (covered by `graphTraversal.test.ts`, `graphLens.test.ts`, +`graphFusion.test.ts`, `graphBuild.test.ts`, `subgraphView.test.ts`) does not +get its own duplicate test layer. `pnpm build && pnpm test` green (123 test +files / 1493 tests passing, 22 skipped, no regressions). --- @@ -5800,7 +5981,65 @@ a different name. Only implement (a); document (b) and (c) explicitly in `src/server/routes/*.ts`, `src/mcp/tools/*.ts`, `docs/parity.md`, test files. -**Status:** not started. +**Status:** ✅ complete. + +**Triage outcome** (see `docs/parity.md` §1 Phase 148 footnotes for full +detail): + +- **Bucket (a) — genuinely missing, implemented (10 commands):** `bisect`, + `refactor-candidates`, `lifecycle`, `cherry-pick-suggest`, `file-diff`, + `pr-report`, `regression-gate`, `code-review`, `map`, `heatmap`. Each + gained a matching MCP tool (new `src/mcp/tools/insights.ts`, registered in + `src/mcp/server.ts` and `src/server/routes/protocol.ts`'s remote-dispatch + map) and a `POST /api/v1/insights/*` HTTP route (new + `src/server/routes/insights.ts`, mounted in `src/server/app.ts`). All + accept the `{model, textModel, codeModel}` override triplet where an + embedding call is involved, via the existing + `src/server/lib/modelOverrides.ts` helper (Phase 140 convention). + `regression-gate` returns HTTP 200/422 (pass/fail), mirroring + `policy-check`'s convention. +- **Bucket (b) — CLI-shaped by nature, correctly excluded (3 commands):** + `cross-repo-similarity` (takes raw local `.gitsema/index.db` filesystem + paths — a different trust model than a remote call; `multi_repo_search` + is the safe MCP/HTTP equivalent, resolving repos via the registered + `repos` table instead), `project` (local precompute/write step that + populates the `projections` table; its read side already has `GET + /projections`, Phase 55), and `ci-diff` (GitHub PR-comment posting via + `GITHUB_TOKEN` + a CI process exit code — inherently process-shaped, not + a stateless API call). +- **Bucket (c) — redundant with an already-covered command, not + implemented (2 commands):** `diff` (its CLI registration wires directly + to `semanticDiffCommand`, the exact same function backing the + already-exposed `semantic_diff` MCP tool/HTTP route — not a distinct + implementation, just a different CLI verb for the same feature) and + `ci-diff`'s search core (`computeSemanticDiff`, same as `semantic_diff`; + its only genuinely distinct behavior is the bucket (b) CI-gate/GitHub- + comment side effects noted above). + +**Deviations from a literal reading of the phase's scope:** +- `regression-gate`'s and `code-review`'s core loops were extracted out of + their CLI command files into new `src/core/search/regressionGate.ts` / + `src/core/search/codeReview.ts` modules (not previously factored out of + the CLI layer) so CLI/MCP/HTTP genuinely share one implementation, per + `CLAUDE.md`'s design constraint #5 ("MCP layer is a thin adapter...does + not duplicate business logic") — this wasn't optional busywork, since + those two commands' logic lived entirely inline in + `src/cli/commands/{regressionGate,codeReview}.ts` before this phase. +- Found and fixed a pre-existing bug in `computeRefactorCandidates`'s + default `level: 'symbol'` SQL query (`src/core/search/refactorCandidates.ts`): + it selected `se.blob_hash`/`s.name`/`s.kind`, none of which exist + (`symbol_embeddings` has no `blob_hash` column; `symbols`' columns are + `symbol_name`/`symbol_kind`) — so the CLI `refactor-candidates` command + itself has always thrown on any database with symbol embeddings present. + Found via real test coverage added for the new `refactor_candidates` + MCP tool/route; fixed rather than shipping a route that surfaces a + pre-existing crash. +- Also corrected two pre-existing `docs/parity.md` inaccuracies unrelated + to new work: the Guide column was wrongly "—" for `cross-repo-similarity`, + `map`, and `heatmap` (all three already had Guide tools in + `guideTools.ts` before this phase), and `project`'s HTTP column was + wrongly "✓" (conflating the adjacent `GET /projections` read route with + `project` itself, which has no route of its own). --- diff --git a/docs/feature-ideas.md b/docs/feature-ideas.md index 6a4c0c7..b48aca3 100644 --- a/docs/feature-ideas.md +++ b/docs/feature-ideas.md @@ -2,7 +2,7 @@ This document tracks upcoming feature ideas that are **not yet in active development** (not in `PLAN.md`) and haven't been **fully designed** (no design file). It's a staging area for "what now?" questions and medium-term product direction. -**Last updated:** 2026-07-01 (added the hierarchical prose/document chunker idea, raised during Phase 136 planning — distinct per-level search result lists) +**Last updated:** 2026-07-02 (added the remote multi-turn `guide` HTTP session idea, deferred from Phase 145's `--lens`/session scope) **Audience:** Developers considering next phases; product planning > **Note:** As of this update, the LSP/MCP remote-delegation foundation this @@ -645,6 +645,91 @@ A new `--chunker prose` strategy, orthogonal to today's three: --- +## Remote Multi-Turn `guide` Sessions Over HTTP + +### Problem +- Raised during Phase 145 (`guide` HTTP route: `--lens` and remote session + support, `docs/PLAN.md`). CLI `gitsema guide --interactive`/`-i` runs a + multi-turn REPL: it creates one `GuideSession` (`createGuideSession()` in + `src/cli/commands/guide.ts`) up front and reuses it across every turn the + user types, so the agent loop's conversation history persists turn to + turn. `POST /api/v1/guide/chat` (`src/server/routes/guide.ts`) has no + equivalent — every request is single-shot: `runGuide()` is called with no + `session` option, so it creates and immediately tears down + (`destroyGuideSession()`) a fresh session per call. A remote caller who + wants a multi-turn conversation (e.g. a chat UI backed by the HTTP API) + has no way to get the CLI's reused-session behavior today. +- This is not a flag gap — there's no missing schema field to bolt on, the + way `lens` was for the same phase. It's a missing **session concept**: + something has to identify "these N requests are one conversation" and + something has to hold the resulting session's state (or at least its + conversation history) between requests, on a server that may be handling + many concurrent repos/callers and may restart. +- Phase 145 shipped the `lens` half of its scope and explicitly deferred + this half rather than bolt on an underdesigned session scheme — see that + phase's Status note in `docs/PLAN.md`. + +### Intended Behavior +No design committed yet. The rough shape, sketched for discussion: +- A `sessionId` the client either generates (e.g. a UUID it mints itself + and passes on every call) or receives from the server on the first call + (e.g. `POST /guide/chat` with no `sessionId` starts a new session and + returns one in the response for the client to pass on subsequent turns). +- Server-side storage for the live `GuideSession` (or, if full session + objects aren't practical to keep across a process restart, at minimum + the conversation-history array the agent loop needs) keyed by + `sessionId`, with a **TTL** so abandoned sessions are reclaimed instead of + leaking memory indefinitely — mirrors the existing `sessions` table's + 30-day idle-window pattern (`GITSEMA_SESSION_TTL_DAYS`, Phase 122) for a + precedent, though a guide session's natural TTL is likely much shorter + (minutes, not days). +- A decision on **storage location**: in-process memory (simple, but breaks + multi-turn continuity across a server restart or in a multi-instance + deployment behind a load balancer) vs. a persisted store (survives + restarts/works across instances, but adds a new table and a + serialize/deserialize story for whatever state `GuideSession`/the + chattydeer agent loop carries). +- Auth/authorization scoping: who can resume a given `sessionId`? If + sessions are just an opaque client-held token with no user binding, any + caller who has the id can continue the conversation — probably fine for a + single-tenant deployment, but needs an explicit answer once `repo_grants`/ + org boundaries (Phases 122–125) are in the picture for a multi-tenant + server. +- An eviction/cleanup policy (background sweep vs. lazy-expire-on-access) + and a matching `DELETE /api/v1/guide/sessions/:sessionId` (or similar) to + let a client end a conversation early and free resources. + +### Design Gaps +- [ ] Client-generated vs. server-issued `sessionId` — which is simpler and + safer to reason about for concurrent/multi-tenant callers? +- [ ] In-memory vs. persisted session storage, and if persisted, a schema + shape (new table, or reuse `settings`-style key-value storage) for + whatever state needs to survive a restart. +- [ ] TTL length and eviction mechanism (sweep vs. lazy expiry). +- [ ] Auth/session-ownership model once multi-tenant orgs/grants are + considered — does a session need a `userId`/`repoId` binding, or stay + a bare opaque token? +- [ ] Whether the existing `GuideSession` type (`src/cli/commands/guide.ts`) + is even a good fit for cross-request reuse as-is, or whether the HTTP + path needs its own lighter-weight session representation (e.g. just + the conversation-history array, not a live provider/session handle). + +### Effort Estimate +- Medium — the `lens`-style schema addition this phase shipped was small; + this is a genuine new subsystem (session lifecycle, storage, TTL/eviction, + and an auth-scoping decision) layered on top of the existing single-shot + `runGuide()` plumbing, which itself needs no changes (it already accepts + an optional `session` to reuse, per its CLI interactive-mode caller). + +### Prerequisites +- None blocking to start designing. `runGuide()`'s `session?: GuideSession` + parameter and `createGuideSession()`/`destroyGuideSession()` lifecycle + helpers already exist and are exercised today by CLI `--interactive` — a + design pass just needs to decide how an HTTP caller identifies and the + server stores/reclaims one of these across requests. + +--- + ## Related Issues & Documents - **Parity tracking:** See `docs/parity.md` for tool availability across interfaces diff --git a/docs/features.md b/docs/features.md index a556fe5..21ce7f0 100644 --- a/docs/features.md +++ b/docs/features.md @@ -213,6 +213,9 @@ All search uses the **text embedding model** (not the code model) to embed queri | **Structural enrichment of fusion commands (Phase 110)** | `code-review`, `triage`, `explain`, and `guide` gain `--lens`: under `structural`/`hybrid` they surface grounded call-graph/co-change context (`structuralContextForPath()` — "N callers, co-changes with X 80%", and for triage the cascade planner). `--lens semantic` (default) leaves output byte-for-byte unchanged. New guide/MCP tools `call_graph`, `blast_radius`, `hotspots` let AI agents navigate structurally | | **Lens coverage & parity sweep (Phase 111)** | Every command where more than one lens is meaningful exposes the shared `--lens` (`addLensOption()`), with §7.3 defaults enforced uniformly (existing → `semantic`, graph-native → `structural`, fusion → `hybrid`) and per-hit lens labeling across text/JSON renderers. A mechanical parity test (`tests/lensParity.test.ts`, mirroring `docsSync`) introspects the Commander program + GUIDE_TOOLS + MCP/HTTP source to guarantee coverage stays uniform | | **Unified graph UI — HTML force-graph + CLI subgraph view (Phase 112)** | `gitsema graph neighbors`, `gitsema graph path`, `gitsema blast-radius`, `gitsema relate`, `gitsema similar`, and `gitsema hotspots` all gain `--out ` (`text\|json[:file]\|html[:file]\|markdown[:file]`) rendering a shared `RenderableSubgraph` (`src/core/graph/subgraphView.ts`) built from `GraphStore.subgraph()`/the command's own traversal result. `--out html` (`renderGraphHtml()`, `src/core/viz/htmlRenderer-graph.ts`) is an interactive canvas force-graph (reusing the `htmlRenderer-clusters.ts` physics/`safeJson()`/`BASE_CSS` pattern); clicking a node opens a sidebar with its kind/path/risk weight and copyable "suggested commands" (`suggestedCommands()`) deep-linking into other per-command HTML views (e.g. `file-evolution --out html:evolution.html`). `--out text` (`renderGraphTree()`, `src/cli/lib/graphRender.ts`) renders the same subgraph as a cycle-safe indented ASCII tree for terminal-only workflows; `--out markdown` (`renderGraphMarkdown()`) renders a nested bullet list. Passing no `--out` leaves every command's pre-existing default text output unchanged | +| **`narrate`/`explain` HTTP route parity — evidence-only toggle + context flags (Phase 144)** | `POST /api/v1/narrate` and `POST /api/v1/explain` gain `evidenceOnly: boolean`, mirroring CLI `narrate`/`explain`'s safe-by-default `--narrate`/`--evidence-only` toggle (omitted = evidence-only, no LLM call — matching `runNarrate`/`runExplain`'s own default); both responses now also carry a structured `evidence` array alongside `prose`. `POST /explain` additionally gains `log` (error/stack-trace file content as LLM context, mirroring `--log `) and `files` (mirroring `--files `, though — like the CLI — it isn't actually used to filter commits, only consumed by lens enrichment below). Both routes gain `lens: 'semantic'\|'structural'\|'hybrid'`; on `/explain`, `structural`/`hybrid` + a concrete `files` path returns a `structuralContext` field (grounded call-graph/co-change context via `structuralContextForPath()`, same mechanism as Phase 110's CLI enrichment), while on `/narrate` it's accepted for flag-surface parity but currently a no-op (no single-file enrichment target) | +| **Graph command family HTTP/MCP exposure (Phase 147)** | Closes the long-standing HTTP/MCP gap for the rest of the `graph` command family: `POST /api/v1/graph/{callers,callees,neighbors,path,relate,similar,unused,cycles,deps,co-change,blast-radius}` HTTP routes and MCP tools `graph_path`, `graph_relate`, `graph_similar`, `graph_unused`, `cycles`, `deps`, `co_change`, `blast_radius` — thin Zod-validated / schema-validated wrappers over the existing `src/core/graph/*` query helpers, JSON-serializing the same result shapes the CLI commands already render to text. `graph callers`/`graph callees` gained HTTP routes but not new MCP tools — the pre-existing `call_graph` tool already covers both directions via its `direction` param. `graph build` remains intentionally CLI-only: it truncates and rebuilds `graph_nodes`/`edges`, a mutating index-maintenance operation like `index vacuum`/`gc`/`rebuild-fts`/etc, none of which have an HTTP route | +| **Remaining CLI-only command triage — MCP/HTTP exposure (Phase 148)** | `gitsema bisect`, `refactor-candidates`, `lifecycle`, `cherry-pick-suggest`, `file-diff`, `pr-report`, `regression-gate`, `code-review`, `heatmap`, and `map` gained MCP tools (`src/mcp/tools/insights.ts`) and `POST /api/v1/insights/*` HTTP routes — the last CLI commands that had neither. `regression-gate`'s and `code-review`'s core logic moved into `src/core/search/regressionGate.ts` / `src/core/search/codeReview.ts` so CLI/MCP/HTTP share one implementation. `cross-repo-similarity` (raw local filesystem `.gitsema/index.db` paths — a different trust model than a remote call), `project` (local precompute/write step; its read side already has `GET /projections`), and `ci-diff` (GitHub PR-comment posting + CI process exit code) were judged CLI-shaped by design and intentionally not exposed; `diff` was found to be the exact same implementation as the already-exposed `semantic-diff`, not a separate feature. See `docs/parity.md` §1 footnotes for the full per-command rationale. Also fixed a pre-existing bug in `computeRefactorCandidates`'s default `level: 'symbol'` query (referenced a nonexistent `symbol_embeddings.blob_hash` column, so it threw on any DB with symbol embeddings present) | --- @@ -249,20 +252,24 @@ Start with `gitsema tools serve [--port n] [--key token] [--ui]`. | `POST /api/v1/commits`, `POST /api/v1/commits/mark-indexed` | Commit metadata | | `POST /api/v1/search`, `POST /api/v1/search/first-seen` | Search — full CLI `search`/`first-seen` flag parity since Phase 138, including per-level `resultsByLevel` responses and multi-repo `repos` | | `POST /api/v1/evolution/file`, `POST /api/v1/evolution/concept` | Evolution | +| `POST /api/v1/narrate` | Repository development history — evidence-only by default (`evidenceOnly: false` to call the configured narrator LLM instead, Phase 144); `lens` accepted for CLI flag-surface parity (currently a no-op — see Phase 144) | +| `POST /api/v1/explain` | Bug/error/topic timeline — same `evidenceOnly` toggle, plus `log`/`files` context fields and a `lens`-driven `structuralContext` response field when `files` resolves to a graph node (Phase 144) | +| `POST /api/v1/graph/hotspots` | Architectural risk ranking (Phase 110) | +| `POST /api/v1/graph/{callers,callees,neighbors,path,relate,similar,unused,cycles,deps,co-change,blast-radius}` | Structural knowledge-graph traversal/analysis routes — one per `graph`/top-level CLI graph command, following `/hotspots`'s pattern (Phase 147). `graph build` is intentionally **not** exposed here (mutating truncate-and-rebuild op — CLI-only, same as `index vacuum`/`gc`/etc) | | `POST /api/v1/remote/index` | Remote repo indexing | | `GET /api/v1/remote/jobs/metrics`, `GET /api/v1/remote/jobs/:id/progress` | Job progress | -| `POST /api/v1/analysis/clusters` | Clustering — accepts `{model, textModel, codeModel}` overrides for CLI/HTTP flag parity (Phase 140), though `computeClusters()` doesn't filter by model so behavior is unchanged today | +| `POST /api/v1/analysis/clusters` | Clustering — accepts `{model, textModel, codeModel}` overrides for CLI/HTTP flag parity (Phase 140), though `computeClusters()` doesn't filter by model so behavior is unchanged today; full `iterations`/`edgeThreshold`/`enhancedKeywordsN` flag parity with the CLI (Phase 143), alongside the pre-existing `useEnhancedLabels` | | `POST /api/v1/analysis/change-points` | Change-point detection — accepts `{model, textModel, codeModel}` embedding overrides (Phase 140) | | `POST /api/v1/analysis/author` | Author attribution — full CLI flag parity (Phase 141): `since`, `detail`, `includeCommits`, `hybrid`, `bm25Weight` all wired through, plus `{model, textModel, codeModel}` embedding overrides (Phase 140); response is `{ authors, commits? }` | -| `POST /api/v1/analysis/impact` | Impact analysis — accepts `{model, textModel, codeModel}` embedding overrides (Phase 140) | -| `POST /api/v1/analysis/semantic-diff` | Semantic diff — accepts `{model, textModel, codeModel}` embedding overrides (Phase 140) | -| `POST /api/v1/analysis/semantic-blame` | Semantic blame — accepts `{model, textModel, codeModel}` embedding overrides (Phase 140) | +| `POST /api/v1/analysis/impact` | Impact analysis — accepts `{model, textModel, codeModel}` embedding overrides (Phase 140); `chunks`/`level` and, most notably, `lens` (`semantic`\|`structural`\|`hybrid`) for full CLI parity (Phase 143) — `structural`/`hybrid` makes this route a thin `blast-radius` alias, closing a prior silent divergence where HTTP only ever did semantic-lens impact analysis | +| `POST /api/v1/analysis/semantic-diff` | Semantic diff — accepts `{model, textModel, codeModel}` embedding overrides (Phase 140); `hybrid`/`bm25Weight` (Phase 143) blend BM25 keyword matching into candidate selection via `hybridSearch()` + `computeSemanticDiff()`'s new `candidateBlobs` parameter — this also fixed a pre-existing CLI bug where `diff`'s `--hybrid`/`--bm25-weight` flags were declared but never wired to anything | +| `POST /api/v1/analysis/semantic-blame` | Semantic blame — accepts `{model, textModel, codeModel}` embedding overrides (Phase 140); `level` (`file`\|`symbol`, Phase 143) as an alternate spelling of the pre-existing `searchSymbols` boolean, matching the CLI's flag surface | | `POST /api/v1/analysis/dead-concepts` | Dead-concept detection | -| `POST /api/v1/analysis/merge-audit` | Merge audit | -| `POST /api/v1/analysis/merge-preview` | Merge preview | -| `POST /api/v1/analysis/branch-summary` | Branch summary | +| `POST /api/v1/analysis/merge-audit` | Merge audit — `base` (Phase 143) overrides merge-base detection, mirroring CLI `--base` | +| `POST /api/v1/analysis/merge-preview` | Merge preview — `top`/`iterations`/`edgeThreshold`/`enhancedKeywordsN`/`useEnhancedLabels` (Phase 143) for CLI cluster-flag parity | +| `POST /api/v1/analysis/branch-summary` | Branch summary — `enhancedLabels`/`enhancedKeywordsN` (Phase 143) slice `nearestConcepts[].topKeywords` in the JSON response, mirroring the CLI's text-mode keyword-count behavior | | `POST /api/v1/analysis/experts` | Experts / reviewer suggestions (Phase 61) | -| `POST /api/v1/analysis/security-scan` | Vulnerability pattern similarity scan (Phase 43) | +| `POST /api/v1/analysis/security-scan` | Vulnerability pattern similarity scan (Phase 43) — `highConfidenceOnly` (Phase 143) filters to `confidence === 'high'` findings only | | `POST /api/v1/analysis/health` | Time-bucketed health timeline (Phase 44) | | `POST /api/v1/analysis/debt` | Technical debt scoring (Phase 45) | | `POST /api/v1/analysis/doc-gap` | Documentation gap analysis (Phase 38) | @@ -270,10 +277,14 @@ Start with `gitsema tools serve [--port n] [--key token] [--ui]`. | `POST /api/v1/analysis/triage` | Incident triage bundle (Phase 65) — accepts `{model, textModel, codeModel}` embedding overrides (Phase 140) | | `POST /api/v1/analysis/policy-check` | Automated CI gate checks (Phase 66) | | `POST /api/v1/analysis/ownership` | Ownership heatmap by concept (Phase 67) | -| `POST /api/v1/analysis/workflow` | Workflow template runner — `pr-review \| incident \| release-audit` (Phase 68); accepts `{model, textModel, codeModel}` embedding overrides (Phase 140) | +| `POST /api/v1/analysis/workflow` | Workflow template runner — all 8 productized CLI templates (`pr-review`, `incident`, `release-audit`, `onboarding`, `ownership-intel`, `arch-drift`, `knowledge-portal`, `regression-forecast`; Phase 68, expanded from 3 to all 8 in Phase 142); accepts `role`/`ref`/`base` generally (not gated to one template — `base` remains a no-op, mirroring the CLI's own unused `--base` flag) and `{model, textModel, codeModel}` embedding overrides (Phase 140) | | `POST /api/v1/analysis/eval` | Inline retrieval evaluation harness — P@k, R@k, MRR (Phase 64) | | `POST /api/v1/analysis/multi-repo-search` | **Deprecated** (Phase 138) — search across multiple registered repos; use `POST /api/v1/search` with a `repos` body param instead, which merges multi-repo results into the full search flag surface. Kept as a thin unchanged-shape alias, see `docs/deprecations.md` | -| `POST /api/v1/protocol/:operation` | Generic LSP/MCP remote-delegation dispatch — `mcp.` runs any of the 38 MCP tools, `lsp.` runs any of the 9 LSP data methods, both via the existing local dispatch (no duplicated logic) (Phase 113; `lsp.codeLens` added Phase 115) | +| `POST /api/v1/guide/chat` | Ask the gitsema guide agent a question — `{question, model?, guideModelId?, includeContext?, lens?, byok?}`, response `{answer, contextUsed, llmEnabled, roundtrips?, toolCallsUsed?}`. `lens: 'semantic'\|'structural'\|'hybrid'` (Phase 145) mirrors CLI `guide --lens` byte-for-byte — it appends the same "(Lens preference: ...)" hint suffix to the question before the agent loop runs, biasing tool choice toward `call_graph`/`blast_radius`/`hotspots` under `structural`/`hybrid`. Remote multi-turn/session support (an HTTP equivalent of CLI `guide --interactive`) remains deferred — see `docs/PLAN.md` Phase 145 and `docs/feature-ideas.md` | +| `POST /api/v1/watch/add`, `POST /api/v1/watch/run` | Save a named watch query / run all saved queries and return new matches (Phase 53) | +| `GET /api/v1/watch`, `DELETE /api/v1/watch/:name` | List all saved watch queries / remove one by name — full CLI `watch list`/`watch remove` parity (Phase 146) | +| `POST /api/v1/insights/bisect`, `/lifecycle`, `/refactor-candidates`, `/cherry-pick-suggest`, `/file-diff`, `/pr-report`, `/regression-gate`, `/code-review`, `/heatmap`, `/map` | **Phase 148** — CLI commands (`bisect`, `lifecycle`, `refactor-candidates`, `cherry-pick-suggest`, `file-diff`, `pr-report`, `regression-gate`, `code-review`, `heatmap`, `map`) that previously had no HTTP route or MCP tool at all; all accept `{model, textModel, codeModel}` overrides where embedding is used. `regression-gate` returns HTTP 200/422 (pass/fail), same convention as `policy-check` | +| `POST /api/v1/protocol/:operation` | Generic LSP/MCP remote-delegation dispatch — `mcp.` runs any of the 56 MCP tools, `lsp.` runs any of the 9 LSP data methods, both via the existing local dispatch (no duplicated logic) (Phase 113; `lsp.codeLens` added Phase 115; `graph.ts` tools added Phase 147, `insights.ts` tools added Phase 148) | | `GET /api/v1/capabilities` | Capabilities manifest (Phase 64) | | `POST /api/v1/auth/login` | Username/password → session token (Phase 122) | | `POST /api/v1/auth/logout` | Revoke the session token used to call it (Phase 122) | @@ -612,6 +623,28 @@ Start with `gitsema tools mcp`. All tools share the same core logic as the CLI. | `workflow_run` | Run a named workflow template (`pr-review` \| `incident` \| `release-audit`) | | `call_graph` | Structural call-graph traversal — callers/callees of a symbol (Phase 108) | | `graph_neighbors` | Typed neighborhood of a graph node — any edge kinds, direction, depth (Phase 108) | +| `hotspots` | Architectural risk = co-change × call-coupling × churn; `lens` selects which signals (Phase 110) | +| `graph_path` | Shortest typed path between two graph nodes (Phase 108/147) | +| `graph_relate` | Structural callers/callees + semantically similar blobs/symbols for a node (Phase 109/147) | +| `graph_similar` | Structural (Jaccard shape) + semantic similarity to a node (Phase 109/147) | +| `graph_unused` | Symbols/files with no inbound calls/imports edges — structural complement to `dead_concepts` (Phase 109/147) | +| `cycles` | Cycle detection over typed edges, default `imports` (Phase 107/147) | +| `deps` | Dependency/dependent closure over imports/calls/extends/implements edges (Phase 107/147) | +| `co_change` | Files that historically change together with a path (Phase 107/147) | +| `blast_radius` | Structural dependents + semantic neighbors — "what breaks if I touch this" (Phase 109/147) | +| `narrate_repo` | Generate evidence (default) or an LLM narrative of repository development history | +| `explain_issue_or_error` | Generate evidence (default) or an LLM explanation/timeline for a bug, error, or topic | +| `get_skill` | Return the gitsema agent skill (usage + result-interpretation guidance for every tool) — MCP-only | +| `semantic_bisect` | Binary search over commit history to find where a concept diverged from a "good" baseline (Phase 148) | +| `refactor_candidates` | Find pairs of symbols/chunks/files similar enough to be refactoring candidates (Phase 148) | +| `concept_lifecycle` | Lifecycle stages (born → growing → mature → declining → dead) of a concept over history (Phase 148) | +| `cherry_pick_suggest` | Suggest commits to cherry-pick by semantic similarity of commit messages (Phase 148) | +| `file_diff` | Semantic diff (cosine distance) between two versions of a single file (Phase 148) | +| `pr_report` | Composite PR report: semantic diff, impacted modules, change-points, reviewer suggestions (Phase 148) | +| `regression_gate` | CI gate: per-query base/head drift check against a threshold (Phase 148) | +| `code_review` | Historical analogues + regression-risk heuristic for a unified diff (Phase 148) | +| `activity_heatmap` | Count of distinct blob changes per time period, week or month (Phase 148) | +| `semantic_map` | Most recent k-means cluster snapshot + per-cluster blob-assignment counts (Phase 148) | --- diff --git a/docs/parity.md b/docs/parity.md index 0fac6d8..6bb3086 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -2,7 +2,7 @@ This document tracks the availability of gitsema tools and commands across all interfaces, and the implementation of common flags across the CLI. It serves as the single source of truth for interface parity and helps identify gaps, inconsistencies, and opportunities for unification. -**Last updated:** 2026-07-01 (the only date in this document — see §4 for why) +**Last updated:** 2026-07-02 (the only date in this document — see §4 for why) **Maintainer note:** Any tool change, interface change, or flag addition must be reflected in the tables below and in the canonical sections of `CLAUDE.md` / `docs/features.md` / `README.md`. --- @@ -49,7 +49,7 @@ This table shows which tools/commands are available in which interface. A checkm - **REPL**: Lightweight interactive search REPL (search only) - **LSP**: Language Server Protocol for IDE integration (9 protocol methods: hover, definition, references, document/workspace symbol, call hierarchy, code lens) — available over stdio, `--tcp` (deprecated, Phase 120), or `--websocket` (see §0); tool availability is identical across all three, since they're just transports onto the same dispatcher - **Guide**: Agentic tool-calling loop in `gitsema guide` (49 tools, max 5 roundtrips) -- **MCP**: Model Context Protocol tools (38 tools for AI clients) — available over stdio, `--websocket`, or `--http` (see §0); tool availability is identical across all three, since they're just transports onto the same `McpServer` +- **MCP**: Model Context Protocol tools (56 tools for AI clients) — available over stdio, `--websocket`, or `--http` (see §0); tool availability is identical across all three, since they're just transports onto the same `McpServer` - **HTTP**: REST API server via `gitsema tools serve` (~30 endpoints) - **CLI Interactive** (planned): Full CLI in interactive mode - **Web UI** (planned): Browser-based interface @@ -65,11 +65,14 @@ This table shows which tools/commands are available in which interface. A checkm | `dead-concepts` | ✓ | — | — | ✓ | ✓ | ✓ | ✓ | ✓ | | **Analysis & Trends** | | `evolution` / `concept-evolution` | ✓ | — | — | ✓ | ✓ | ✓ | ✓ | ✓ | -| `file-evolution` / `file-diff` | ✓ | — | — | ✓ | ✓ | ✓ | ✓ | ✓ | -| `diff` / `semantic-diff` | ✓ | — | — | ✓ | ✓ | ✓ | ✓ | ✓ | +| `file-evolution` | ✓ | — | — | ✓ | ✓ | ✓ | ✓ | ✓ | +| `file-diff` [^148-file-diff] | ✓ | — | — | ✓ | ✓ | ✓ | ✓ | ✓ | +| `diff` [^148-diff] | ✓ | — | — | ✓ | ✓ | ✓ | ✓ | ✓ | | `change-points` | ✓ | — | — | ✓ | ✓ | ✓ | ✓ | ✓ | | `file-change-points` | ✓ | — | — | ✓ | — | ✓ | ✓ | ✓ | | `cluster-change-points` | ✓ | — | — | — | — | ✓ | ✓ | — | +| `bisect` [^148-new] | ✓ | — | — | ✓ | ✓ | ✓ | ✓ | ✓ | +| `lifecycle` [^148-new] | ✓ | — | — | ✓ | ✓ | ✓ | ✓ | ✓ | | **Blame & Attribution** | | `semantic-blame` | ✓ | — | — | ✓ | ✓ | ✓ | ✓ | ✓ | | `author` | ✓ | — | — | ✓ | ✓ | ✓ | ✓ | ✓ | @@ -89,30 +92,33 @@ This table shows which tools/commands are available in which interface. A checkm | `security-scan` | ✓ | — | — | ✓ | ✓ | ✓ | ✓ | ✓ | | `doc-gap` | ✓ | — | — | ✓ | ✓ | ✓ | ✓ | ✓ | | `eval` | ✓ | — | — | ✓ | — | ✓ | ✓ | ✓ | +| `refactor-candidates` [^148-new] | ✓ | — | — | ✓ | ✓ | ✓ | ✓ | ✓ | | **Impact & Dependencies** | | `impact` | ✓ | — | — | ✓ | ✓ | ✓ | ✓ | ✓ | -| `co-change` | ✓ | — | — | — | — | ✓ | ✓ | — | -| `deps` | ✓ | — | — | — | — | ✓ | ✓ | — | -| `cycles` | ✓ | — | — | — | — | ✓ | ✓ | — | +| `co-change` | ✓ | — | — | — | ✓ | ✓ | ✓ | — | +| `deps` | ✓ | — | — | — | ✓ | ✓ | ✓ | — | +| `cycles` | ✓ | — | — | — | ✓ | ✓ | ✓ | — | | **Graph & Structure** | -| `graph build` | ✓ | — | — | — | — | ✓ | — | — | -| `graph callers` | ✓ | — | — | ✓ | — | ✓ | ✓ | ✓ | -| `graph callees` | ✓ | — | — | ✓ | — | ✓ | ✓ | ✓ | -| `graph neighbors` | ✓ | — | — | ✓ | — | ✓ | ✓ | ✓ | -| `graph path` | ✓ | — | — | — | — | ✓ | ✓ | — | -| `graph relate` | ✓ | — | — | — | — | ✓ | ✓ | — | -| `graph similar` | ✓ | — | — | — | — | ✓ | ✓ | — | -| `graph unused` | ✓ | — | — | — | — | ✓ | ✓ | — | -| `blast-radius` | ✓ | — | — | — | — | ✓ | ✓ | — | +| `graph build` | ✓ | — | — | — | — | — | — | — | +| `graph callers` | ✓ | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| `graph callees` | ✓ | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| `graph neighbors` | ✓ | — | — | ✓ | ✓ | ✓ | ✓ | ✓ | +| `graph path` | ✓ | — | — | — | ✓ | ✓ | ✓ | — | +| `graph relate` | ✓ | — | — | — | ✓ | ✓ | ✓ | — | +| `graph similar` | ✓ | — | — | — | ✓ | ✓ | ✓ | — | +| `graph unused` | ✓ | — | — | — | ✓ | ✓ | ✓ | — | +| `blast-radius` | ✓ | — | — | — | ✓ | ✓ | ✓ | — | | `hotspots` | ✓ | — | — | ✓ | ✓ | ✓ | ✓ | ✓ | | **Workflow & CI** | | `triage` | ✓ | — | — | ✓ | ✓ | ✓ | ✓ | ✓ | | `policy-check` | ✓ | — | — | ✓ | ✓ | ✓ | ✓ | ✓ | -| `regression-gate` | ✓ | — | — | — | — | ✓ | ✓ | — | -| `code-review` | ✓ | — | — | — | — | ✓ | ✓ | — | -| `pr-report` | ✓ | — | — | — | — | ✓ | ✓ | — | -| `cherry-pick-suggest` | ✓ | — | — | — | — | ✓ | ✓ | — | +| `regression-gate` | ✓ | — | — | — | ✓ | ✓ | ✓ | — | +| `code-review` | ✓ | — | — | — | ✓ | ✓ | ✓ | — | +| `pr-report` | ✓ | — | — | ✓ | ✓ | ✓ | ✓ | ✓ | +| `cherry-pick-suggest` | ✓ | — | — | ✓ | ✓ | ✓ | ✓ | ✓ | +| `ci-diff` [^148-cidiff] | ✓ | — | — | — | — | — | ✓ | — | | `workflow` | ✓ | — | — | ✓ | — | ✓ | ✓ | ✓ | +| `watch` (add/list/remove/run) | ✓ | — | — | — | — | ✓ | ✓ | — | | **Narrative & Analysis** | | `narrate` | ✓ | — | — | ✓ | — | ✓ | ✓ | ✓ | | `explain` | ✓ | — | — | ✓ | — | ✓ | ✓ | ✓ | @@ -142,16 +148,92 @@ This table shows which tools/commands are available in which interface. A checkm | `admin models` (list/allow/deny/reset) | ✓ | — | — | — | — | — | — | — | | `quickstart` / `setup` | ✓ | — | — | — | — | ✓ | ✓ | — | | **Visualization** | -| `map` | ✓ | — | — | — | — | ✓ | ✓ | — | -| `heatmap` | ✓ | — | — | — | — | ✓ | ✓ | — | -| `project` | ✓ | — | — | — | — | ✓ | ✓ | — | +| `map` | ✓ | — | — | ✓ | ✓ | ✓ | ✓ | — | +| `heatmap` | ✓ | — | — | ✓ | ✓ | ✓ | ✓ | — | +| `project` [^148-project] | ✓ | — | — | — | — | — | ✓ | — | | **Protocols & Servers** | | `tools serve` | ✓ | — | — | — | — | ✓ | — | — | | `tools mcp` | ✓ | — | — | — | — | ✓ | — | — | | `tools lsp` | ✓ | — | — | — | — | ✓ | — | — | | **Multi-Repo** | | `multi-repo-search` | — | — | ✓ | ✓ | ✓ | ✓ (deprecated route, see below) | — | ✓ | -| `cross-repo-similarity` | ✓ | — | — | — | — | ✓ | ✓ | — | +| `cross-repo-similarity` [^148-crossrepo] | ✓ | — | — | ✓ | — | ✓ | ✓ | — | + +**Phase 148 footnotes** (triage of the last remaining zero-HTTP/MCP-exposure +CLI commands — see `docs/PLAN.md` Phase 148 for the full bucket (a)/(b)/(c) +methodology): + +[^148-new]: `bisect`, `lifecycle`, and `refactor-candidates` weren't tracked + in this matrix at all before Phase 148 (not a stale "—", just missing + rows) — bucket (a): genuinely missing MCP/HTTP exposure for commands that + already return clean structured data (`BisectResult`, + `ConceptLifecycleResult`, `RefactorReport`) and already had a `gitsema + guide` tool (`semantic_bisect`, `concept_lifecycle`, `refactor_candidates` + in `src/core/narrator/guideTools.ts`). Phase 148 added matching + `semantic_bisect`/`concept_lifecycle`/`refactor_candidates` MCP tools + (`src/mcp/tools/insights.ts`) and `POST /insights/bisect` / + `/insights/lifecycle` / `/insights/refactor-candidates` HTTP routes. + Fixing `refactor-candidates`' default `level: 'symbol'` query (it + referenced a nonexistent `symbol_embeddings.blob_hash` column) was a + bugfix side effect of adding real test coverage, not new Phase 148 scope. + `cherry-pick-suggest`/`pr-report` land in the same bucket (a) — they + already had CLI + Guide coverage (`cherry_pick_suggest`, `pr_report`) but + no MCP tool or HTTP route; both gained matching MCP tools + `POST + /insights/cherry-pick-suggest` / `/insights/pr-report` routes. + +[^148-file-diff]: `file-diff` (single-file semantic diff between two refs, + `computeDiff()`) was previously conflated with `file-evolution` in one + table row that overstated `file-diff`'s own MCP coverage (the ✓ actually + reflected `file-evolution`'s `evolution` MCP tool, not a `file_diff` tool, + which didn't exist). Bucket (a): split into its own row and given a real + `file_diff` MCP tool + `POST /insights/file-diff` HTTP route, matching the + `file_diff` Guide tool that already existed. + +[^148-diff]: `diff ` (conceptual/topic diff — gained/ + lost/stable concepts) is **bucket (c): redundant, not merely + similar** — its CLI registration (`src/cli/register/all.ts`) wires the + `diff` command directly to `semanticDiffCommand`, the exact same function + that backs the already-exposed `semantic_diff` MCP tool and `POST + /analysis/semantic-diff` HTTP route. There is no separate "semantic-diff" + CLI command; `diff` is that feature's only CLI name. No new work was + needed or done — this row's parity was already accurate before Phase 148. + +[^148-cidiff]: `ci-diff` is **bucket (b)+(c) hybrid** — its search/scoring + core is the same `computeSemanticDiff()` call already covered by + `semantic_diff` (MCP/HTTP), so a second MCP tool/route would just be a + renamed duplicate. Its differentiating behavior — posting a GitHub PR + review comment via `GITHUB_TOKEN` and setting a non-zero `process.exitCode` + for CI gate use — is inherently CI-runner/process-shaped (reads + `GITHUB_REPOSITORY`/`GITHUB_REF` env vars, writes to the process exit + code), not a stateless remote API call. Left CLI-only; not exposed. + +[^148-crossrepo]: `cross-repo-similarity` takes raw local filesystem paths + (`--repo-a`/`--repo-b`, arbitrary `.gitsema/index.db` files) as arguments — + a fundamentally different trust model from a remote HTTP/MCP call, where + accepting caller-supplied filesystem paths would be an arbitrary-file-read + risk. **Bucket (b): CLI-shaped by nature**, not exposed over HTTP/MCP. The + existing `multi_repo_search` MCP/HTTP tool already covers the "search + across repos" use case safely, resolving repos through the registered + `repos` table (`db_path` set via `gitsema repos add`) instead of raw + paths — that's the intended replacement for remote/AI-client callers. (The + Guide `cross_repo_similarity` tool predates this phase and was already + registered in `guideTools.ts`; the Guide column above was simply wrong — + corrected here, not new work.) + +[^148-project]: `project` (batch-computes 2D random-projection coordinates + from stored embeddings and writes them to the `projections` table) is + **bucket (b): CLI-shaped precompute/admin step**, same category as `index + --graph` needing a separate `graph build` trigger — it mutates local + index state and is meant to be run once before visualizing, not called + per-request. Its *read* side already has a dedicated HTTP route, + `GET /projections` (Phase 55), which is what `gitsema tools serve --ui`'s + embedding-space-explorer actually calls — the previous "✓" in this row's + HTTP column conflated that adjacent read route with `project` itself, + which has no route of its own. Corrected here; no new route added. `map` + and `heatmap`, by contrast, land in **bucket (a)**: both already return + plain JSON (not HTML) from the CLI, so they gained real `semantic_map` / + `activity_heatmap` MCP tools and `POST /insights/map` / `/insights/heatmap` + HTTP routes in this phase. ### LSP Interface Details @@ -191,13 +273,18 @@ This table shows which tools/commands are available in which interface. A checkm - `search`, `code-search`, `index`, `first-seen`, `evolution`, `clusters`, `merge-audit`, `merge-preview` **CLI-only gaps (not in Guide/MCP):** -- `index doctor`, `graph path`, `graph relate`, `graph similar`, `graph unused`, `blast-radius`, `regression-gate`, `code-review`, `pr-report`, `cherry-pick-suggest`, `co-change`, `deps`, `cycles`, and all maintenance subcommands +- `index doctor`, and all maintenance subcommands (including `graph build` — see Phase 147's rationale for keeping it CLI-only) +- ~~`graph path`, `graph relate`, `graph similar`, `graph unused`, `blast-radius`, `co-change`, `deps`, `cycles` (not in Guide/MCP)~~ — **closed for MCP by Phase 147** (`graph_path`, `graph_relate`, `graph_similar`, `graph_unused`, `cycles`, `deps`, `co_change`, `blast_radius` MCP tools added). Still absent from the `guide` agentic tool loop (`GUIDE_TOOLS` registry) — that gap is untouched by Phase 147, scoped to HTTP/MCP only. +- ~~`regression-gate`, `code-review`~~ — **closed by Phase 148.** Both gained MCP tools (`regression_gate`, `code_review` in `src/mcp/tools/insights.ts`); still no Guide tool (out of this phase's scope — CLI/HTTP/MCP triage only), so Guide remains "—" for these two specifically. +- ~~`pr-report`, `cherry-pick-suggest`~~ — **closed by Phase 148** (MCP tools added; both already had Guide tools). - ~~`search --merge-levels` / distinct per-level result lists (Phase 136)~~ — **closed by Phase 138.** MCP `semantic_search` now accepts `level: 'module'` (previously `file|chunk|symbol` only) and, when 2+ of {chunk, symbol, module} are active at once, returns a rendered text blob with one labeled `== ==` section per level (mirroring CLI's per-level text output) instead of one merged list; a `merge_levels` param opts back into the pre-Phase-136-equivalent single list. The HTTP `POST /search` route gained the identical `level: 'module'` option, a `mergeLevels` body param, and — when 2+ levels are active and `mergeLevels` is not set — returns `{ resultsByLevel: { file: [...], chunk: [...], ... } }` instead of a flat array (breaking response-shape change, accepted per §4's parity-over-stability principle). See `docs/PLAN.md` Phase 138. - ~~`code-search` never received Phase 136's per-level-list treatment~~ — **resolved in Phase 137.** CLI `code-search`, MCP `code_search`, and Guide's `code_search` tool all now isolate the chunk/symbol candidate pools by default (the default `--level`/`level` value `'symbol'` sets both flags, so this was the *every-call* case, not an opt-in combination) and return separate per-level lists — CLI via `renderResultsByLevel()`, MCP/Guide via a `results_by_level: { file, chunk, symbol }` object (breaking response-shape change, accepted per §4). All three gained a `--merge-levels`/`merge_levels` opt-out back to the pre-Phase-137 single merged list/array. See `docs/PLAN.md` Phase 137. **HTTP gaps:** -- Most graph commands (`callers`, `callees`, `neighbors`, `path`, `relate`, `similar`, `unused`) -- `code-search`, `file-change-points`, `cluster-diff`, `cluster-timeline`, `branch-summary`, `contributor-profile`, `eval`, `regression-gate`, `code-review`, `pr-report`, `cherry-pick-suggest` +- `code-search`, `file-change-points`, `cluster-diff`, `cluster-timeline`, `branch-summary`, `contributor-profile`, `eval` +- ~~Most graph commands (`callers`, `callees`, `neighbors`, `path`, `relate`, `similar`, `unused`)~~ — **closed by Phase 147.** All now have `POST /api/v1/graph/*` routes (`callers`, `callees`, `neighbors`, `path`, `relate`, `similar`, `unused`, plus `cycles`, `deps`, `co-change`, `blast-radius`), following the pre-existing `/hotspots` route's pattern. `graph build` remains CLI-only by design (mutating index-maintenance operation — see Phase 147's rationale). +- ~~`regression-gate`, `code-review`, `pr-report`, `cherry-pick-suggest`~~ — **closed by Phase 148.** All four gained `POST /insights/*` HTTP routes (see the [^148-new] footnote above). `bisect`, `lifecycle`, `refactor-candidates`, `file-diff`, `map`, `heatmap` also gained HTTP routes in the same phase, having never been tracked as gaps at all (missing rows, not stale dashes) — see the Phase 148 footnotes above. +- `cross-repo-similarity`, `project`, `ci-diff` remain HTTP-less by design (bucket (b)/(c) — see Phase 148 footnotes), not tracked as open gaps. **`multi-repo-search` HTTP route consolidated (Phase 138):** `POST /search` now accepts a `repos: string[]` body param that merges multi-repo results @@ -256,9 +343,9 @@ This section documents all flags used across CLI commands, their consistency, an | `--vss` | — | bool | false | `search`, `first-seen` | Use vector search index for approximate search | | `--level` | — | enum | file | `search`, `code-search`, `repl`, `file-evolution` | Search/index granularity: `file`, `chunk`, `symbol`, or `module` (`file-evolution` only supports `file`\|`symbol` — per-symbol centroid drift; also on `POST /evolution/file` since Phase 139); MCP `semantic_search`'s `level` param gained `module` (previously `file\|chunk\|symbol`) in Phase 138, matching HTTP `POST /search`'s `level` | | `--chunker` | — | enum | file | `index start` | Chunking strategy: `file`, `function`, or `fixed` | -| `--lens` | — | enum | hybrid | `blast-radius`, `relate`, `similar`, `hotspots` | Structural/semantic lens toggle: `structural`, `semantic`, `hybrid` | -| `--weight-structural` | — | float | lens-dependent | `blast-radius`, `relate`, `similar`, `hotspots` | Structural-signal weight override for the active `--lens` (`addLensOption`); **no-op on `hotspots`** specifically — its risk score is an unweighted geometric mean with no weighting hook, on both CLI and `POST /graph/hotspots` (accepted there for flag-surface parity only, Phase 139) | -| `--depth` | — | int | varies | `deps`, `graph callers`, `graph callees`, `graph neighbors`, `graph path`, `blast-radius` | Traversal depth for graph commands | +| `--lens` | — | enum | hybrid | `blast-radius`, `relate`, `similar`, `hotspots` | Structural/semantic lens toggle: `structural`, `semantic`, `hybrid`; MCP `graph_relate`/`graph_similar`/`blast_radius` (`lens`) and HTTP `POST /graph/{relate,similar,blast-radius}` (`lens`) mirror this since Phase 147 | +| `--weight-structural` | — | float | lens-dependent | `blast-radius`, `relate`, `similar`, `hotspots` | Structural-signal weight override for the active `--lens` (`addLensOption`); **no-op on `hotspots`** specifically — its risk score is an unweighted geometric mean with no weighting hook, on both CLI and `POST /graph/hotspots` (accepted there for flag-surface parity only, Phase 139); not accepted on `POST /graph/{relate,similar,blast-radius}` (Phase 147) — those routes mirror only `lens`, matching the CLI's own no-op-if-set-elsewhere behavior for this flag on non-`hotspots` commands | +| `--depth` | — | int | varies | `deps`, `graph callers`, `graph callees`, `graph neighbors`, `graph path`, `blast-radius` | Traversal depth for graph commands; MCP `call_graph`/`graph_neighbors`/`deps`/`blast_radius` (`depth`) and HTTP `POST /graph/{callers,callees,neighbors,deps,blast-radius}` (`depth`) mirror this since Phase 108/147 | | `--repos` | — | string | — | `search`, `first-seen` | Comma-separated repo IDs for multi-repo mode; MCP `semantic_search`/`first_seen` (`repos: string[]`) and HTTP `POST /search`/`POST /search/first-seen` (`repos: string[]`) mirror this since Phase 138 — results are merged into the primary-DB result list rather than returned separately, matching CLI's `--repos` behavior. `POST /analysis/multi-repo-search` remains as a thin deprecated alias (see `docs/deprecations.md`) | | `--threshold` | — | float | varies | `code-review`, `cross-repo-similarity`, `policy-check` | Similarity/distance threshold for matching | | `--base` | — | ref | varies | `regression-gate`, `code-review`, `ci-diff` | Base git ref to compare from | @@ -318,9 +405,9 @@ This table shows less common flags used by specific commands or command groups. | `--fix` | `index doctor` | bool | false | Auto-repair fixable issues (missing FTS content, orphan embeddings) and re-report | | `--no-cache` | `search` | bool | false | Skip query embedding cache; MCP `semantic_search` (`no_cache`) / HTTP `POST /search` (`noCache`) since Phase 138 | | `--cache` | `search` | bool | true | Use query embedding cache | -| `--edge-types` | `deps`, `graph cycles`, `graph neighbors`, `unused` | string | varies | Comma-separated edge types to traverse | -| `--reverse` | `deps` | bool | false | Show dependents instead of dependencies | -| `--direction` | `graph neighbors` | enum | both | Edge direction: `out`, `in`, or `both` | +| `--edge-types` | `deps`, `graph cycles`, `graph neighbors`, `unused` | string | varies | Comma-separated edge types to traverse; MCP `deps`/`cycles`/`graph_neighbors`/`graph_unused` (`edge_types: string[]`) and HTTP `POST /graph/{deps,cycles,neighbors,unused}` (`edgeTypes: string[]`) mirror this since Phase 108/147 | +| `--reverse` | `deps` | bool | false | Show dependents instead of dependencies; MCP `deps` (`reverse`) and HTTP `POST /graph/deps` (`reverse`) mirror this since Phase 147 | +| `--direction` | `graph neighbors` | enum | both | Edge direction: `out`, `in`, or `both`; MCP `graph_neighbors` (`direction`) and HTTP `POST /graph/neighbors` (`direction`) mirror this since Phase 108/147 | | `--to` | `storage migrate` | enum | — | Destination backend: `sqlite`, `postgres`, or `qdrant` | | `--to-path` | `storage migrate` | path | — | Destination SQLite database file | | `--to-metadata-url` | `storage migrate` | url | — | Destination postgres connection string | @@ -341,6 +428,20 @@ This table shows less common flags used by specific commands or command groups. | `--hybrid` | `author` | bool | false | Use hybrid (vector + BM25) candidate selection (HTTP: also accepted by `POST /analysis/author`, Phase 141) | | `--bm25-weight` | `author` | float | 0.3 | BM25 weight when `--hybrid` is set (HTTP: also accepted by `POST /analysis/author`, Phase 141) | | `--chunks` / `--level` / `--vss` | `author` | bool/enum/bool | false/—/false | Declared on the CLI but not wired to anything in `computeAuthorContributions` (blob-level only); `--vss` prints a warning and is ignored. HTTP's `POST /analysis/author` accepts all three for flag-surface parity with the same no-op behavior (Phase 141) — not a gap, a documented CLI limitation mirrored intentionally. | +| `--base` | `merge-audit` | commit/ref | — | Override merge-base detection (HTTP: also accepted by `POST /analysis/merge-audit`, Phase 143) | +| `--iterations` | `clusters`, `cluster-diff`, `cluster-timeline`, `merge-preview` | int | 20 | Max k-means iterations (HTTP: also accepted by `POST /analysis/clusters` and `POST /analysis/merge-preview`, Phase 143) | +| `--edge-threshold` | `clusters`, `cluster-diff`, `cluster-timeline`, `merge-preview` | float | 0.3 | Cosine similarity threshold for concept-graph edges (HTTP: also accepted by `POST /analysis/clusters` and `POST /analysis/merge-preview`, Phase 143) | +| `--enhanced-keywords-n` | `clusters`, `cluster-diff`, `cluster-timeline`, `merge-preview`, `branch-summary` | int | 5 (8 for `branch-summary`) | Number of TF-IDF-enhanced keywords to compute (`clusters`/`merge-preview`) or display (`branch-summary`) when enhanced labels are enabled (HTTP: also accepted by `POST /analysis/clusters`, `POST /analysis/merge-preview`, `POST /analysis/branch-summary`, Phase 143 — `merge-preview` and `branch-summary` HTTP routes gained `useEnhancedLabels`/`enhancedLabels` too, since this flag is a no-op without it; see Phase 143 note in §6) | +| `--top` | `merge-preview` | int | 5 | Top representative paths per cluster (HTTP: also accepted by `POST /analysis/merge-preview`, Phase 143) | +| `--high-confidence-only` | `security-scan` | bool | false | Filter findings to `confidence === 'high'` only (HTTP: also accepted by `POST /analysis/security-scan`, Phase 143) | +| `--chunks` / `--level` | `impact` | bool/enum | false/— | Include chunk-level embeddings / search level (`file`\|`chunk`\|`symbol`) for coupling (HTTP: also accepted by `POST /analysis/impact`, Phase 143) | +| `--lens` | `impact`, `blast-radius`, `relate`, `similar`, `hotspots` | enum | semantic (`impact`); hybrid (others) | `semantic`\|`structural`\|`hybrid` — for `impact`, `structural`/`hybrid` makes it a thin `blast-radius` alias instead of pure semantic similarity (HTTP: also accepted by `POST /analysis/impact`, Phase 143 — previously HTTP only ever did semantic-lens impact, silently diverging from the CLI default) | +| `--hybrid` / `--bm25-weight` | `diff` (semantic-diff) | bool/float | false/0.3 | Blend vector similarity with BM25 keyword matching to select candidate blobs. Previously declared on the CLI but never wired to anything (dead flags); Phase 143 fixed this in both the CLI and `POST /analysis/semantic-diff`, which now blend via `hybridSearch()` + `computeSemanticDiff()`'s new `candidateBlobs` parameter. | +| `--level` | `blame` / `semantic-blame` | enum | file | `file`\|`symbol` — symbol uses function-level embeddings (HTTP: also accepted by `POST /analysis/semantic-blame`, Phase 143, as an alternate spelling of the pre-existing `searchSymbols` boolean) | +| `--evidence-only` / `--narrate` | `narrate`, `explain` | bool | evidence-only (`--narrate` flips to LLM prose) | Safe-by-default toggle — CLI's `--narrate` is shorthand for `--no-evidence-only`. HTTP had no schema field for this at all until Phase 144: `POST /narrate`/`POST /explain` now accept `evidenceOnly: boolean` (omitted = evidence-only, matching `runNarrate`/`runExplain`'s own default), and both responses now also carry a structured `evidence` array (the same evidence previously only reachable by re-parsing the `prose` JSON string) | +| `--log` | `explain` | path | — | Path to an error log/stack-trace file included as LLM context; HTTP `POST /explain` gained a matching `log` body field in Phase 144 | +| `--files` | `explain` | glob | — | Intended to restrict search scope, but neither the CLI command nor `runExplain()` actually filters commits by it today — it's only consumed by the Phase 111 lens-driven structural-context append (see `--lens` below), a pre-existing CLI-side gap mirrored as-is (not fixed) by the matching HTTP `files` field added in Phase 144 | +| `--lens` | `explain` | enum | semantic | Distinct from the graph-family `--lens` row above (different default: `semantic`, not `hybrid`) — when `structural`/`hybrid` and a concrete `--files` path is given, appends grounded call-graph/co-change context after the main result (CLI: printed to stdout; HTTP: a `structuralContext` response field, both via `structuralContextForPath()`). Default `semantic` lens (or no `--files`) leaves output unchanged. HTTP `POST /explain` and `POST /narrate` both gained a `lens` body field in Phase 144; for `narrate` it's accepted for flag-surface parity only — there's no single-file enrichment target on `narrate`, so it's currently a no-op there | ### 2.3 Flag Coherence Issues @@ -413,22 +514,22 @@ This table shows less common flags used by specific commands or command groups. ### Guide (Agentic Tool-Calling) - **Status:** 49 tools registered; up to 5 roundtrips - **Strengths:** Sophisticated multi-step workflows, LLM-driven -- **Gaps:** No maintenance commands (doctor, vacuum, gc), some graph commands, no visualization +- **Gaps:** No maintenance commands (doctor, vacuum, gc), some graph commands. `map`/`heatmap` (`semantic_map`/`activity_heatmap`) *are* covered — "no visualization" was stale as of Phase 148. - **Constraints:** Max ~4000 chars per result for token budget - **Tools:** Defined in `guideTools.ts` with schema definitions and executors ### MCP (Model Context Protocol) -- **Status:** 38 tools exposed for external AI clients +- **Status:** 56 tools exposed for external AI clients (Phase 147 added 8 graph tools: `graph_path`, `graph_relate`, `graph_similar`, `graph_unused`, `cycles`, `deps`, `co_change`, `blast_radius`; Phase 148 added 10 insights tools: `semantic_bisect`, `refactor_candidates`, `concept_lifecycle`, `cherry_pick_suggest`, `file_diff`, `pr_report`, `regression_gate`, `code_review`, `activity_heatmap`, `semantic_map`, in `src/mcp/tools/insights.ts`) - **Strengths:** Standardized protocol, works with Claude, other AI systems -- **Gaps:** No maintenance commands, some graph commands, no visualization -- **Transports:** stdio (default), `--websocket`, `--http` (Streamable HTTP, Phase 117) — see §0; identical 38-tool surface on all three +- **Gaps:** No maintenance commands (`graph build` included, by design — Phase 147). Visualization gap closed — `map`/`heatmap` now covered via `semantic_map`/`activity_heatmap` (Phase 148). +- **Transports:** stdio (default), `--websocket`, `--http` (Streamable HTTP, Phase 117) — see §0; identical 56-tool surface on all three - **Remote delegation:** `gitsema tools mcp --remote ` proxies every tool call to a `gitsema tools serve`'s `POST /api/v1/protocol/mcp.` route (Phase 113) — a separate, outbound-delegation axis from the inbound transport above (see §0) ### HTTP API (`gitsema tools serve`) -- **Status:** ~30 REST endpoints across multiple routes +- **Status:** ~50 REST endpoints across multiple routes (Phase 147 added 11 graph routes; Phase 148 added 10 under `insights/`) - **Strengths:** Language-agnostic, browser-accessible, remote delegation -- **Gaps:** Missing graph commands (callers/callees/neighbors), some analysis endpoints -- **Routes:** `search/`, `analysis/`, `evolution/`, `guide/`, `status/`, `graph/`, `commits/`, `blobs/`, `watch/`, `remote/`, `protocol/` (Phase 113 — generic LSP/MCP remote-delegation dispatch) +- **Gaps:** Some analysis endpoints (see §6); `graph build` intentionally excluded (mutating index-maintenance op — Phase 147) +- **Routes:** `search/`, `analysis/`, `insights/` (Phase 148), `evolution/`, `guide/`, `status/`, `graph/` (Phase 147), `commits/`, `blobs/`, `watch/`, `remote/`, `protocol/` (Phase 113 — generic LSP/MCP remote-delegation dispatch) - **Authentication:** Optional bearer token via `--key`/`GITSEMA_SERVE_KEY` ### CLI Interactive (Planned) @@ -539,6 +640,7 @@ If you find a discrepancy, **update this file first**, then propagate the change | Phase 129 | Admin-gated enabled sets | New `gitsema admin models list\|allow\|deny\|reset` CLI command (operator-only, no other interface — see Tool Matrix); no flag changes to existing tools | | Phase 130 | BYOK for narrator/guide | `--byok-http-url`/`--byok-api-key`/`--byok-model`/`--byok-max-tokens`/`--byok-temperature` flags added to `narrate`/`explain`/`guide` (CLI), nested `byok` body field on the matching HTTP routes, flattened `byok_*` fields on the matching MCP tools; no Tool Matrix changes (additive flags on already-listed tools) | | Phase 139 | `evolution`/`hotspots` HTTP route parity | `POST /evolution/file` gains `level`, `branch`, `model`/`textModel`/`codeModel`, `alerts`; `POST /evolution/concept` gains `branch`, `model`/`textModel`/`codeModel`; `computeEvolution()` now accepts a `branch` filter directly (previously CLI-only post-filtering) — `computeConceptEvolution()` already did. `POST /graph/hotspots` gains `weightStructural` for flag-surface parity only (no-op, same as CLI — see §2.1's `--weight-structural` row). No Tool Matrix changes (both routes were already listed ✓ HTTP; this closes a flag-level, not tool-level, gap) | +| Phase 147 | Graph command family HTTP/MCP exposure | `POST /api/v1/graph/{callers,callees,neighbors,path,relate,similar,unused,cycles,deps,co-change,blast-radius}` HTTP routes added; MCP tools `graph_path`, `graph_relate`, `graph_similar`, `graph_unused`, `cycles`, `deps`, `co_change`, `blast_radius` added (46 MCP tools total). `graph build` kept CLI-only — mutating truncate-and-rebuild index-maintenance op, same precedent as `index vacuum`/`gc`/`rebuild-fts`/etc, none of which have an HTTP route | | Future | CLI Interactive | Full CLI with autocomplete, history, interactive UI | | Future | Web UI | Browser-based dashboard with visualization | @@ -583,25 +685,121 @@ this section's prior open-ended bullets into concrete, numbered commits? }` (breaking change, per §4's parity-over-stability rule) to carry `includeCommits` results. Model-override triplet intentionally excluded — tracked separately in Phase 140. -- **Phase 142:** `workflow` HTTP route parity — 3/8 templates exposed - today, all 8 on CLI. -- **Phase 143:** analysis-route small-fixes bundle (merge-audit, - merge-preview, branch-summary, clusters, security-scan, impact, - semantic-diff, semantic-blame). +- **Phase 142:** `workflow` HTTP route parity. ✅ done — + `POST /analysis/workflow`'s `WorkflowBodySchema.template` enum now accepts + all 8 CLI templates (`pr-review`, `incident`, `release-audit`, + `onboarding`, `ownership-intel`, `arch-drift`, `knowledge-portal`, + `regression-forecast`), each wired to the same underlying functions the + CLI's `workflowCommand()` calls (`computeAuthorContributions`, + `getActiveSession`/`computeHealthTimeline`/`scoreDebt`, plus the + already-used `embedQuery`/`vectorSearch`/`computeConceptChangePoints`/ + `computeExperts`) — all 5 newly-exposed templates' core functions were + already imported and HTTP-callable elsewhere in `analysis.ts` (via + `/author`, `/health`, `/debt`), so no follow-up gaps were found. `role` + and `ref` are new body fields (mirroring CLI `--role`/`--ref`); `base` was + already present and un-gated, but investigation found it's a **no-op in + the CLI itself** (`workflowCommand()` declares `options.base` but never + reads it, for any template) — the HTTP route intentionally mirrors this + rather than inventing HTTP-only behavior the CLI doesn't have. Flagging + this as a known CLI-level gap (not fixed here — out of scope for an + HTTP-parity phase). Model-override triplet untouched — already closed in + Phase 140. +- **Phase 143:** analysis-route small-fixes bundle. ✅ done — + `POST /analysis/merge-audit` gained `base` (merge-base override, mirrors + CLI `--base`); `POST /analysis/merge-preview` gained `top`, `iterations`, + `edgeThreshold`, `enhancedKeywordsN` (plus `useEnhancedLabels`, added as a + deviation since `enhancedKeywordsN` is a no-op without it — + `computeClusters`/`computeMergeImpact` only compute TF-IDF-enhanced + keywords when that flag is set); `POST /analysis/branch-summary` gained + `enhancedLabels`/`enhancedKeywordsN` — since `computeBranchSummary()` has + no enhanced-labels concept of its own (the CLI only uses these flags to + decide how many already-computed keywords to *print* in text mode), the + route now slices `nearestConcepts[].topKeywords` to `enhancedKeywordsN` + (or 5, unenhanced) so the flags have an observable effect on this + JSON-only interface; `POST /analysis/clusters` gained `iterations`, + `edgeThreshold`, `enhancedKeywordsN` (now threaded into `computeClusters` + alongside the existing `useEnhancedLabels`); `POST /analysis/security-scan` + gained `highConfidenceOnly` (filters to `confidence === 'high'`, mirrors + CLI `--high-confidence-only`); `POST /analysis/impact` gained `chunks`, + `level`, and `lens` — the most notable gap: HTTP previously only ever did + semantic-lens impact analysis, silently diverging from the CLI's + `--lens structural|hybrid` (thin `blast-radius` alias); the route now + mirrors that behavior exactly. `POST /analysis/semantic-diff` gained + `hybrid`/`bm25Weight` — this actually required fixing a **pre-existing + CLI bug**: the `diff ` command already declared + `--hybrid`/`--bm25-weight` in its Commander options but never wired them + to anything (dead flags). `computeSemanticDiff()` gained an optional + `candidateBlobs` parameter (mirrors `computeAuthorContributions`'s + Phase-141 `candidateBlobs` pattern) so both the CLI and this route now + genuinely blend BM25 via `hybridSearch()` when `hybrid` is set. + `POST /analysis/semantic-blame` gained `level` (file/symbol, mirrors CLI + `--level`) as an alternate spelling of the pre-existing `searchSymbols` + boolean (kept for backward compatibility). - **Phase 144:** `narrate`/`explain` HTTP routes — evidence-only toggle, - `--log`/`--files`, `--lens`. -- **Phase 145:** `guide` HTTP route — `--lens`, remote multi-turn/session - support (open design question). + `--log`/`--files`, `--lens`. ✅ done — `POST /narrate`/`POST /explain` both + gained `evidenceOnly` (default matches `runNarrate`/`runExplain`'s own + evidence-only default) and `lens`; `POST /explain` additionally gained + `log`/`files` and, for `structural`/`hybrid` lens + a concrete `files` + path, a `structuralContext` response field (mirroring the CLI's post-run + append). Both responses also gained a structured `evidence` array (see + §2.2's `--evidence-only` row). `lens` on `POST /narrate` is accepted but a + no-op (no per-file enrichment target on `narrate`); `files` was already a + pre-existing CLI-side no-op for actual commit filtering (only consumed by + lens enrichment) and is mirrored as-is, not fixed, per §2.2. +- **Phase 145:** `guide` HTTP route — `--lens`. ✅ done — `POST + /api/v1/guide/chat` now accepts `lens: 'semantic'|'structural'|'hybrid'`, + appending the identical "(Lens preference: ...)" hint suffix to the + question that CLI `guide --lens` appends (`withLens()` in + `src/cli/commands/guide.ts`), so structural/hybrid callers get the same + tool-choice bias over HTTP as on the CLI. The remote multi-turn/session + half of the original scope (HTTP equivalent of CLI `--interactive`/`-i`, + which reuses one agent session across turns) is a genuine open design + question — not "not started," a deliberate deferral — and was **not** + implemented in this phase; see `docs/PLAN.md`'s Phase 145 Status note and + `docs/feature-ideas.md` for the `sessionId`/TTL/storage tradeoffs to + resolve in a follow-up phase. - **Phase 146:** `watch list`/`watch remove` HTTP routes (currently missing - entirely, not just flags). -- **Phase 147:** graph command family HTTP/MCP exposure — closes the - "Expose graph commands to HTTP API" item below. -- **Phase 148:** triage pass on the remaining zero-HTTP/MCP-exposure CLI - commands (`bisect`, `refactor-candidates`, `ci-diff`, `lifecycle`, - `cherry-pick-suggest`, `regression-gate`, `code-review`, - `cross-repo-similarity`, `pr-report`, `file-diff`, `diff`, - `map`/`heatmap`/`project`) — decides which genuinely warrant exposure vs. - are CLI/visualization-shaped by nature, rather than blanket-implementing. + entirely, not just flags). ✅ done — `GET /watch` lists all saved queries + (`{ watches: [{ id, name, query, lastRunAt, webhookUrl }] }`) and + `DELETE /watch/:name` removes one by name (404 if not found), mirroring + the CLI's `watch list`/`watch remove` exactly (`name` is the saved + query's unique identifier — the CLI's `remove` also deletes by name, not + numeric id). Added a `watch` row to the Tool Matrix above (it had no row + at all before this phase, despite `add`/`run` already having HTTP + routes). +- **Phase 147:** graph command family HTTP/MCP exposure. ✅ done — HTTP + routes and MCP tools added for `callers`/`callees`/`neighbors`/`path`/ + `relate`/`similar`/`unused`/`cycles`/`deps`/`co-change`/`blast-radius`; + `graph build` kept CLI-only (mutating index-maintenance op, no HTTP + route — same as `index vacuum`/`gc`/etc). Closes the "Expose graph + commands to HTTP API" Medium-Term item (see the HTTP-gaps and + CLI-only-gaps notes above, now struck through). +- **Phase 148:** ✅ **shipped** — triage pass on the remaining + zero-HTTP/MCP-exposure CLI commands. Bucket (a) (genuinely missing, + implemented): `bisect`, `refactor-candidates`, `lifecycle`, + `cherry-pick-suggest`, `file-diff`, `pr-report`, `regression-gate`, + `code-review`, `map`, `heatmap` — all ten gained a matching MCP tool + (`src/mcp/tools/insights.ts`, registered in `src/mcp/server.ts` + + `src/server/routes/protocol.ts`'s remote-delegation dispatch) and a + `POST /insights/*` HTTP route (`src/server/routes/insights.ts`, mounted + in `src/server/app.ts`). `regression-gate`'s and `code-review`'s core + loops were extracted out of their CLI command files into + `src/core/search/regressionGate.ts` / `src/core/search/codeReview.ts` so + CLI/MCP/HTTP share one implementation (design constraint #5); a + pre-existing bug in `computeRefactorCandidates`'s default `level: + 'symbol'` query (referenced a nonexistent `symbol_embeddings.blob_hash` + column) was fixed as a side effect of adding real test coverage. Bucket + (b) (CLI-shaped by nature, not exposed): `cross-repo-similarity` (raw + local filesystem paths — a different trust model than a remote call; + `multi_repo_search` is the safe equivalent), `project` (local + precompute/write step; its read side already has `GET /projections`), + and `ci-diff`'s differentiating behavior (GitHub PR commenting, CI exit + code — inherently process-shaped). Bucket (c) (redundant, not + implemented): `diff` — its CLI action is literally `semanticDiffCommand`, + the same function backing the already-exposed `semantic_diff` tool/route; + `ci-diff`'s search core is also `computeSemanticDiff`, redundant with + `semantic_diff` (hence bucket (b)+(c) for `ci-diff`). See the Phase 148 + footnotes in §1 for the full per-command rationale. **Two corrections the audit surfaced in this section's prior bullets** (kept here as a record, not re-listed as open items below): diff --git a/src/cli/commands/codeReview.ts b/src/cli/commands/codeReview.ts index 97c765e..9f668b8 100644 --- a/src/cli/commands/codeReview.ts +++ b/src/cli/commands/codeReview.ts @@ -17,15 +17,14 @@ import * as fs from 'node:fs' import { execFileSync } from 'node:child_process' -import { embedQuery } from '../../core/embedding/embedQuery.js' -import { vectorSearch } from '../../core/search/analysis/vectorSearch.js' import { buildProviderOrExit, resolveModels } from '../lib/provider.js' import { EXIT_USAGE, EXIT_RUNTIME } from '../lib/errors.js' import { resolveOutputs, getSink } from '../../utils/outputSink.js' import { getCachedStorageProfile } from '../../core/storage/resolveProfile.js' -import { structuralContextForPath, formatStructuralContext, type StructuralContext } from '../../core/graph/structuralContext.js' +import { formatStructuralContext } from '../../core/graph/structuralContext.js' import { parseLens } from '../lib/lens.js' import { isSafeGitRange } from '../../core/narrator/narrator.js' +import { parseDiff, computeCodeReview, type CodeReviewEntry } from '../../core/search/codeReview.js' export interface CodeReviewOptions { base?: string @@ -40,37 +39,6 @@ export interface CodeReviewOptions { lens?: string } -interface HunkSummary { - file: string - addedLines: string[] - removedLines: string[] -} - -function parseDiff(diffText: string): HunkSummary[] { - const hunks: HunkSummary[] = [] - let current: HunkSummary | null = null - - for (const line of diffText.split('\n')) { - if (line.startsWith('--- ') || line.startsWith('+++ ')) continue - if (line.startsWith('diff --git ')) { - // Extract file path - const match = line.match(/b\/(.+)$/) - if (match) { - if (current) hunks.push(current) - current = { file: match[1], addedLines: [], removedLines: [] } - } - } else if (current) { - if (line.startsWith('+') && !line.startsWith('+++')) { - current.addedLines.push(line.slice(1)) - } else if (line.startsWith('-') && !line.startsWith('---')) { - current.removedLines.push(line.slice(1)) - } - } - } - if (current) hunks.push(current) - return hunks -} - export async function codeReviewCommand(opts: CodeReviewOptions): Promise { const topK = parseInt(opts.top ?? '5', 10) const threshold = parseFloat(opts.threshold ?? '0.75') @@ -135,54 +103,7 @@ export async function codeReviewCommand(opts: CodeReviewOptions): Promise const lens = parseLens(opts.lens, 'semantic') const graph = lens !== 'semantic' ? getCachedStorageProfile(process.cwd()).graph : undefined - const reviews: Array<{ - file: string - analogues: Array<{ path: string; score: number }> - regressionRisk: 'low' | 'medium' | 'high' - structural?: StructuralContext - }> = [] - - for (const hunk of hunks) { - if (hunk.addedLines.length === 0 && hunk.removedLines.length === 0) continue - - // Embed the added code as the "new concept" - const addedText = hunk.addedLines.join('\n').slice(0, 2000) - if (!addedText.trim()) continue - - let embedding: number[] - try { - embedding = await embedQuery(provider, addedText) as number[] - } catch { - continue - } - - const results = await vectorSearch(embedding, { topK, searchChunks: true }) - const analogues = results - .filter((r) => r.score >= threshold) - .map((r) => ({ path: r.paths?.[0] ?? r.blobHash.slice(0, 12), score: r.score })) - - // Simple regression risk heuristic: are removed lines similar to historical top results? - let regressionRisk: 'low' | 'medium' | 'high' = 'low' - if (hunk.removedLines.length > 0 && analogues.length > 0) { - const removedText = hunk.removedLines.join('\n').slice(0, 2000) - try { - const removedEmbedding = await embedQuery(provider, removedText) as number[] - const removedResults = await vectorSearch(removedEmbedding, { topK: 3 }) - const maxRemovedScore = removedResults[0]?.score ?? 0 - if (maxRemovedScore >= 0.9) regressionRisk = 'high' - else if (maxRemovedScore >= 0.75) regressionRisk = 'medium' - } catch { /* ignore */ } - } - - // Structural enrichment (Phase 110): surface call-graph/co-change context - // for the changed file under a structural/hybrid lens. - let structural: StructuralContext | undefined - if (graph) { - structural = await structuralContextForPath(graph, hunk.file) - } - - reviews.push({ file: hunk.file, analogues, regressionRisk, structural }) - } + const reviews: CodeReviewEntry[] = await computeCodeReview(hunks, provider, { topK, threshold, graph }) if (format === 'json') { const json = JSON.stringify({ reviews }, null, 2) diff --git a/src/cli/commands/regressionGate.ts b/src/cli/commands/regressionGate.ts index 5764b26..733b508 100644 --- a/src/cli/commands/regressionGate.ts +++ b/src/cli/commands/regressionGate.ts @@ -18,11 +18,11 @@ import { execFileSync } from 'node:child_process' import { readFileSync, writeFileSync } from 'node:fs' import { embedQuery } from '../../core/embedding/embedQuery.js' -import { vectorSearch, cosineSimilarity } from '../../core/search/analysis/vectorSearch.js' import { buildProviderOrExit, resolveModels } from '../lib/provider.js' import { EXIT_USAGE, EXIT_RUNTIME, EXIT_GATE_FAILED } from '../lib/errors.js' import { resolveOutputs, getSink } from '../../utils/outputSink.js' import { isSafeGitRange } from '../../core/narrator/narrator.js' +import { computeRegressionGate, type RegressionGateResult, type RegressionGateQuery } from '../../core/search/regressionGate.js' export interface RegressionGateOptions { base?: string @@ -36,14 +36,8 @@ export interface RegressionGateOptions { out?: string[] } -export interface RegressionResult { - query: string - threshold: number - baseScore: number - headScore: number - drift: number - passed: boolean -} +/** @deprecated use `RegressionGateResult` from `../../core/search/regressionGate.js` */ +export type RegressionResult = RegressionGateResult export async function regressionGateCommand(opts: RegressionGateOptions): Promise { const baseRef = opts.base ?? 'main' @@ -107,8 +101,9 @@ export async function regressionGateCommand(opts: RegressionGateOptions): Promis console.log() } - const results: RegressionResult[] = [] - + // Embed all queries up front (core function takes pre-embedded vectors, same + // convention as computeSemanticDiff/computeConceptLifecycle/etc). + const embeddedQueries: RegressionGateQuery[] = [] for (const { query, threshold } of queries) { let embedding: number[] try { @@ -117,32 +112,28 @@ export async function regressionGateCommand(opts: RegressionGateOptions): Promis console.error(`Could not embed query "${query}": ${err instanceof Error ? err.message : String(err)}`) process.exit(EXIT_RUNTIME) } + embeddedQueries.push({ query, embedding, threshold }) + } - // Search at base/head ref. When refs are branch names the `branch` filter in - // vectorSearch restricts to blobs indexed on that branch (via blob_branches table). - // For commit hashes or tags, blob_branches may have no match and the filter is - // effectively a no-op (returns all blobs), which is safe but less precise. - const baseResults = await vectorSearch(embedding, { topK, branch: baseRef }) - const headResults = await vectorSearch(embedding, { topK, branch: headRef }) - - const baseScore = baseResults.length > 0 ? baseResults[0].score : 0 - const headScore = headResults.length > 0 ? headResults[0].score : 0 - const drift = Math.abs(baseScore - headScore) - const passed = drift <= threshold - - results.push({ query, threshold, baseScore, headScore, drift, passed }) - - if (format === 'text') { - const icon = passed ? '✓' : '✗' - console.log(`${icon} "${query}"`) - console.log(` base: ${baseScore.toFixed(4)} head: ${headScore.toFixed(4)} drift: ${drift.toFixed(4)} (threshold: ${threshold})`) - if (!passed) { + // Search at base/head ref. When refs are branch names the `branch` filter in + // vectorSearch restricts to blobs indexed on that branch (via blob_branches table). + // For commit hashes or tags, blob_branches may have no match and the filter is + // effectively a no-op (returns all blobs), which is safe but less precise. + const report = await computeRegressionGate(embeddedQueries, { baseRef, headRef, topK }) + const results = report.results + + if (format === 'text') { + for (const r of results) { + const icon = r.passed ? '✓' : '✗' + console.log(`${icon} "${r.query}"`) + console.log(` base: ${r.baseScore.toFixed(4)} head: ${r.headScore.toFixed(4)} drift: ${r.drift.toFixed(4)} (threshold: ${r.threshold})`) + if (!r.passed) { console.log(` ↑ REGRESSION DETECTED`) } } } - const allPassed = results.every((r) => r.passed) + const allPassed = report.allPassed if (format === 'json') { const json = JSON.stringify({ base: baseHash, head: headHash, results, allPassed }, null, 2) diff --git a/src/cli/commands/semanticDiff.ts b/src/cli/commands/semanticDiff.ts index a4ae425..31833fd 100644 --- a/src/cli/commands/semanticDiff.ts +++ b/src/cli/commands/semanticDiff.ts @@ -2,6 +2,7 @@ import { writeFileSync } from 'node:fs' import { embedQuery } from '../../core/embedding/embedQuery.js' import type { Embedding } from '../../core/models/types.js' import { computeSemanticDiff } from '../../core/search/semanticDiff.js' +import { hybridSearch } from '../../core/search/analysis/hybridSearch.js' import { formatDate, shortHash } from '../../core/search/ranking.js' import { renderSemanticDiffHtml } from '../../core/viz/htmlRenderer.js' import { resolveOutputs, hasSinkFormat, getSink } from '../../utils/outputSink.js' @@ -112,9 +113,29 @@ export async function semanticDiffCommand( throw err } + // --hybrid: blend vector similarity with BM25 keyword matching to select the + // candidate blob set, mirroring the `author` command's `--hybrid` handling + // (src/cli/commands/author.ts). Previously declared here but never wired up. + let candidateBlobs: Array<{ blobHash: string; score: number }> | undefined + if (options.hybrid) { + const bm25Weight = options.bm25Weight !== undefined ? parseFloat(options.bm25Weight) : 0.3 + try { + const hybridResults = await hybridSearch(topic, queryEmbedding, { + topK: Math.max(topK * 5, 100), + bm25Weight, + branch: options.branch, + }) + candidateBlobs = hybridResults.map((r) => ({ blobHash: r.blobHash, score: r.score })) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + console.error(`Error: hybrid search failed — ${msg}`) + process.exit(1) + } + } + let result: SemanticDiffResult try { - result = computeSemanticDiff(queryEmbedding, topic, ref1, ref2, topK, options.branch) + result = computeSemanticDiff(queryEmbedding, topic, ref1, ref2, topK, options.branch, candidateBlobs) } catch (err) { const msg = err instanceof Error ? err.message : String(err) console.error(`Error: ${msg}`) diff --git a/src/core/search/codeReview.ts b/src/core/search/codeReview.ts new file mode 100644 index 0000000..93e7633 --- /dev/null +++ b/src/core/search/codeReview.ts @@ -0,0 +1,114 @@ +/** + * Core computation for the semantic code-review assistant (Phase 81, + * extracted to `src/core/search` in Phase 148 so the CLI `code-review` + * command, the `code_review` MCP tool, and the `POST /insights/code-review` + * HTTP route all share one implementation instead of duplicating it). + * + * Given a unified diff, finds historical analogues for each changed file's + * added lines and flags a regression-risk heuristic based on how similar + * the removed lines were to previously-seen code. + */ + +import type { EmbeddingProvider } from '../embedding/provider.js' +import { embedQuery } from '../embedding/embedQuery.js' +import { vectorSearch } from './analysis/vectorSearch.js' +import { structuralContextForPath, type StructuralContext } from '../graph/structuralContext.js' +import type { GraphStore } from '../storage/types.js' + +export interface HunkSummary { + file: string + addedLines: string[] + removedLines: string[] +} + +/** Parses a unified diff (`git diff` / `diff --git` format) into per-file hunks. */ +export function parseDiff(diffText: string): HunkSummary[] { + const hunks: HunkSummary[] = [] + let current: HunkSummary | null = null + + for (const line of diffText.split('\n')) { + if (line.startsWith('--- ') || line.startsWith('+++ ')) continue + if (line.startsWith('diff --git ')) { + const match = line.match(/b\/(.+)$/) + if (match) { + if (current) hunks.push(current) + current = { file: match[1], addedLines: [], removedLines: [] } + } + } else if (current) { + if (line.startsWith('+') && !line.startsWith('+++')) { + current.addedLines.push(line.slice(1)) + } else if (line.startsWith('-') && !line.startsWith('---')) { + current.removedLines.push(line.slice(1)) + } + } + } + if (current) hunks.push(current) + return hunks +} + +export interface CodeReviewEntry { + file: string + analogues: Array<{ path: string; score: number }> + regressionRisk: 'low' | 'medium' | 'high' + structural?: StructuralContext +} + +export interface CodeReviewComputeOptions { + topK?: number + threshold?: number + /** When provided (Phase 110 hybrid/structural lens), enriches each entry with call-graph/co-change context. */ + graph?: GraphStore +} + +/** + * Runs the code-review analysis over already-parsed diff hunks. + */ +export async function computeCodeReview( + hunks: HunkSummary[], + provider: EmbeddingProvider, + options: CodeReviewComputeOptions = {}, +): Promise { + const topK = options.topK ?? 5 + const threshold = options.threshold ?? 0.75 + const reviews: CodeReviewEntry[] = [] + + for (const hunk of hunks) { + if (hunk.addedLines.length === 0 && hunk.removedLines.length === 0) continue + + const addedText = hunk.addedLines.join('\n').slice(0, 2000) + if (!addedText.trim()) continue + + let embedding: number[] + try { + embedding = await embedQuery(provider, addedText) as number[] + } catch { + continue + } + + const results = await vectorSearch(embedding, { topK, searchChunks: true }) + const analogues = results + .filter((r) => r.score >= threshold) + .map((r) => ({ path: r.paths?.[0] ?? r.blobHash.slice(0, 12), score: r.score })) + + let regressionRisk: 'low' | 'medium' | 'high' = 'low' + if (hunk.removedLines.length > 0 && analogues.length > 0) { + const removedText = hunk.removedLines.join('\n').slice(0, 2000) + try { + const removedEmbedding = await embedQuery(provider, removedText) as number[] + const removedResults = await vectorSearch(removedEmbedding, { topK: 3 }) + const maxRemovedScore = removedResults[0]?.score ?? 0 + if (maxRemovedScore >= 0.9) regressionRisk = 'high' + else if (maxRemovedScore >= 0.75) regressionRisk = 'medium' + } catch { /* ignore */ } + } + + let structural: StructuralContext | undefined + if (options.graph) { + structural = await structuralContextForPath(options.graph, hunk.file) + } + + reviews.push({ file: hunk.file, analogues, regressionRisk, structural }) + } + + return reviews +} diff --git a/src/core/search/refactorCandidates.ts b/src/core/search/refactorCandidates.ts index 50d5d17..a9d691e 100644 --- a/src/core/search/refactorCandidates.ts +++ b/src/core/search/refactorCandidates.ts @@ -41,11 +41,18 @@ export function computeRefactorCandidates(options: RefactorCandidatesOptions = { let rows: Row[] if (level === 'symbol') { + // `symbol_embeddings` has no `blob_hash` column of its own (only + // `symbol_id`, FK'd to `symbols.id`) — the blob hash and symbol + // name/kind live on the joined `symbols` row (`symbols.symbol_name` / + // `symbols.symbol_kind`, not `s.name`/`s.kind`). This query previously + // referenced nonexistent columns and threw on any DB with symbol + // embeddings present (Phase 148 bugfix, found while adding MCP/HTTP + // exposure for this command). rows = rawDb.prepare(` - SELECT se.blob_hash, se.vector, p.path, s.name, s.kind + SELECT s.blob_hash AS blob_hash, se.vector, p.path, s.symbol_name AS name, s.symbol_kind AS kind FROM symbol_embeddings se JOIN symbols s ON s.id = se.symbol_id - JOIN paths p ON p.blob_hash = se.blob_hash + JOIN paths p ON p.blob_hash = s.blob_hash GROUP BY se.symbol_id LIMIT 2000 `).all() as Row[] diff --git a/src/core/search/regressionGate.ts b/src/core/search/regressionGate.ts new file mode 100644 index 0000000..087ca87 --- /dev/null +++ b/src/core/search/regressionGate.ts @@ -0,0 +1,74 @@ +/** + * Core computation for the semantic regression CI gate (Phase 79, extracted + * to `src/core/search` in Phase 148 so the CLI `regression-gate` command, + * the `regression_gate` MCP tool, and the `POST /insights/regression-gate` + * HTTP route all share one implementation instead of duplicating it). + * + * Compares the top cosine-similarity score for a set of concept queries + * between two Git refs (typically a PR branch vs. a base branch) and flags + * any query whose drift exceeds its threshold. + */ + +import type { Embedding } from '../models/types.js' +import { vectorSearch } from './analysis/vectorSearch.js' + +export interface RegressionGateQuery { + query: string + embedding: Embedding + threshold: number +} + +export interface RegressionGateResult { + query: string + threshold: number + baseScore: number + headScore: number + drift: number + passed: boolean +} + +export interface RegressionGateReport { + baseRef: string + headRef: string + results: RegressionGateResult[] + allPassed: boolean +} + +export interface RegressionGateComputeOptions { + baseRef: string + headRef: string + topK?: number +} + +/** + * Compares base/head top-match scores for each pre-embedded query and + * reports per-query drift plus an overall pass/fail. + * + * Search is restricted to blobs seen on `baseRef`/`headRef` via the + * `branch` filter in `vectorSearch` (backed by `blob_branches`). When a ref + * is a commit hash/tag rather than a branch name, the filter is typically a + * no-op (no matching rows), which still returns a safe, if less precise, + * result — same caveat as the pre-extraction CLI implementation. + */ +export async function computeRegressionGate( + queries: RegressionGateQuery[], + options: RegressionGateComputeOptions, +): Promise { + const topK = options.topK ?? 10 + const results: RegressionGateResult[] = [] + + for (const { query, embedding, threshold } of queries) { + const baseResults = await vectorSearch(embedding, { topK, branch: options.baseRef }) + const headResults = await vectorSearch(embedding, { topK, branch: options.headRef }) + + const baseScore = baseResults.length > 0 ? baseResults[0].score : 0 + const headScore = headResults.length > 0 ? headResults[0].score : 0 + const drift = Math.abs(baseScore - headScore) + const passed = drift <= threshold + + results.push({ query, threshold, baseScore, headScore, drift, passed }) + } + + const allPassed = results.every((r) => r.passed) + return { baseRef: options.baseRef, headRef: options.headRef, results, allPassed } +} diff --git a/src/core/search/semanticDiff.ts b/src/core/search/semanticDiff.ts index b625eb7..1719989 100644 --- a/src/core/search/semanticDiff.ts +++ b/src/core/search/semanticDiff.ts @@ -118,6 +118,37 @@ function scoreAndSort( return scored.slice(0, topK) } +/** + * Score and sort a set of blobs using pre-scored hybrid (vector + BM25) + * candidates instead of pure cosine similarity — mirrors the + * `candidateBlobs` pattern used by `computeAuthorContributions` + * (`src/core/search/authorSearch.ts`, Phase 141). Only blobs present in both + * `candidateBlobs` and `blobHashes` (the gained/lost/stable membership set) + * are considered — this restricts the scoring universe to the hybrid-search + * results rather than falling back to cosine per-blob. + */ +function scoreAndSortHybrid( + blobHashes: string[], + embeddingMap: Map, + candidateBlobs: Array<{ blobHash: string; score: number }>, + topK: number, +): SemanticDiffEntry[] { + const hashSet = new Set(blobHashes) + const scored: SemanticDiffEntry[] = [] + for (const c of candidateBlobs) { + if (!hashSet.has(c.blobHash)) continue + const data = embeddingMap.get(c.blobHash) + scored.push({ + blobHash: c.blobHash, + paths: data?.paths ?? [], + score: c.score, + firstSeen: data?.firstSeen ?? 0, + }) + } + scored.sort((a, b) => b.score - a.score) + return scored.slice(0, topK) +} + // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- @@ -139,6 +170,12 @@ function scoreAndSort( * @param ref1 Earlier git ref (branch, tag, commit, or date). * @param ref2 Later git ref. * @param topK Maximum entries to return per group (default 10). + * @param branch When provided, restrict blobs to those seen on this branch. + * @param candidateBlobs Pre-scored hybrid (vector + BM25) candidates (e.g. from + * `hybridSearch()`, Phase 143). When supplied, each group is + * scored/ranked from this candidate set (intersected with + * group membership) instead of recomputing cosine similarity — + * mirrors the `author` command's `--hybrid` handling. */ export function computeSemanticDiff( queryEmbedding: Embedding, @@ -147,6 +184,7 @@ export function computeSemanticDiff( ref2: string, topK = 10, branch?: string, + candidateBlobs?: Array<{ blobHash: string; score: number }>, ): SemanticDiffResult { const ts1 = resolveRefToTimestamp(ref1) const ts2 = resolveRefToTimestamp(ref2) @@ -169,6 +207,19 @@ export function computeSemanticDiff( const allHashes = [...new Set([...set1, ...set2])] const embeddingMap = loadBlobData(allHashes) + if (candidateBlobs !== undefined) { + return { + ref1, + ref2, + topic, + timestamp1: ts1, + timestamp2: ts2, + gained: scoreAndSortHybrid(gainedHashes, embeddingMap, candidateBlobs, topK), + lost: scoreAndSortHybrid(lostHashes, embeddingMap, candidateBlobs, topK), + stable: scoreAndSortHybrid(stableHashes, embeddingMap, candidateBlobs, topK), + } + } + return { ref1, ref2, diff --git a/src/mcp/server.ts b/src/mcp/server.ts index dac4e94..868593b 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -13,6 +13,7 @@ import { registerWorkflowTools } from './tools/workflow.js' import { registerInfrastructureTools } from './tools/infrastructure.js' import { registerNarratorTools } from './tools/narrator.js' import { registerGraphTools } from './tools/graph.js' +import { registerInsightsTools } from './tools/insights.js' import { setMcpRemoteConfig } from './registerTool.js' import { checkRemoteHealth } from '../core/remote/protocolClient.js' import { readFileSync } from 'node:fs' @@ -55,6 +56,7 @@ export function buildMcpServer(): McpServer { registerInfrastructureTools(server) registerNarratorTools(server) registerGraphTools(server) + registerInsightsTools(server) return server } diff --git a/src/mcp/tools/graph.ts b/src/mcp/tools/graph.ts index 06e5aa1..da4bb56 100644 --- a/src/mcp/tools/graph.ts +++ b/src/mcp/tools/graph.ts @@ -2,10 +2,21 @@ import { z } from 'zod' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { registerTool } from '../registerTool.js' import { getCachedStorageProfile } from '../../core/storage/resolveProfile.js' -import { callers, callees, neighbors } from '../../core/graph/traversal.js' +import { callers, callees, neighbors, path as graphPath } from '../../core/graph/traversal.js' import { computeHotspots, churnByPath } from '../../core/graph/hotspots.js' +import { relate } from '../../core/graph/relate.js' +import { similar } from '../../core/graph/similar.js' +import { unused, UNUSED_EDGE_TYPES } from '../../core/graph/unused.js' +import { findCycles } from '../../core/graph/cycles.js' +import { deps, DEPS_EDGE_TYPES } from '../../core/graph/deps.js' +import { coChange } from '../../core/graph/coChange.js' +import { blastRadius } from '../../core/graph/blastRadius.js' +import { renderBlastRadius } from '../../cli/lib/graphRender.js' import { parseLens } from '../../cli/lib/lens.js' import type { EdgeType, GraphHit } from '../../core/storage/types.js' +import type { SemanticHit } from '../../core/graph/semanticNeighbors.js' + +const EDGE_TYPE_ENUM = z.enum(['contains', 'defines', 'imports', 'calls', 'extends', 'implements', 'references', 'co_change', 'similar_to']) function renderResolutionError(label: string, resolved: { status: string; candidates?: Array<{ nodeKey: string }> }): string { if (resolved.status === 'not-found') { @@ -20,6 +31,11 @@ function renderHits(hits: GraphHit[]): string { return hits.map((h) => ` ${h.edgeType ? `[${h.edgeType}] ` : ''}${h.displayName} (depth ${h.depth})`).join('\n') } +function renderSemanticHits(hits: SemanticHit[]): string { + if (hits.length === 0) return ' (none)' + return hits.map((h) => ` ${h.score.toFixed(3)} ${h.symbolName ?? h.paths[0] ?? '(unknown)'}`).join('\n') +} + /** * Phase 108 (knowledge-graph §6/§8) MCP tools, exposing the `GraphStore` * traversal primitives: `call_graph` (callers/callees over `calls` edges) @@ -117,4 +133,254 @@ export function registerGraphTools(server: McpServer) { } }, ) + + registerTool( + server, + 'graph_path', + 'Shortest typed path between two nodes in the structural graph (Phase 108/147: `gitsema index --graph` + `gitsema graph build`) — "how does A reach B" across imports/calls/extends/implements/references/co_change edges.', + { + from: z.string().describe('A symbol qualified name, file path, or literal node key (file:..., symbol:..., external:...)'), + to: z.string().describe('A symbol qualified name, file path, or literal node key'), + }, + async ({ from, to }) => { + try { + const profile = getCachedStorageProfile(process.cwd()) + const result = await graphPath(profile.graph, from, to) + + if (result.from.status !== 'found') { + return { content: [{ type: 'text', text: renderResolutionError(from, result.from) }] } + } + if (result.to.status !== 'found') { + return { content: [{ type: 'text', text: renderResolutionError(to, result.to) }] } + } + + const fromNode = result.from.node + const toNode = result.to.node + if (!result.path) { + return { content: [{ type: 'text', text: `No path found from ${fromNode.displayName} to ${toNode.displayName} within the traversal depth limit.` }] } + } + if (result.path.hops.length === 0) { + return { content: [{ type: 'text', text: `${fromNode.displayName} is the same node as ${toNode.displayName}.` }] } + } + const segments = [fromNode.displayName] + for (const hop of result.path.hops) { + segments.push(hop.reversed ? `<-[${hop.edgeType}]-` : `-[${hop.edgeType}]->`, hop.displayName) + } + return { content: [{ type: 'text', text: segments.join(' ') }] } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return { content: [{ type: 'text', text: `Error: ${msg}` }] } + } + }, + ) + + registerTool( + server, + 'graph_relate', + 'Combined structural + semantic view of a symbol/file (Phase 109/147): direct (depth-1) callers/callees via the structural graph, plus semantically similar blobs/symbols. `--lens` selects which signal(s) drive the result.', + { + symbol: z.string().describe('A symbol qualified name, file path, or literal node key (file:..., symbol:..., external:...)'), + lens: z.enum(['semantic', 'structural', 'hybrid']).optional().default('hybrid').describe('Which lens(es) to include (default: hybrid)'), + top_k: z.number().int().positive().optional().describe('Number of semantic neighbors to return (default 10)'), + }, + async ({ symbol, lens, top_k }) => { + try { + const profile = getCachedStorageProfile(process.cwd()) + const resolvedLens = parseLens(lens, 'hybrid') + const result = await relate(profile.graph, symbol, { lens: resolvedLens, topK: top_k }) + + if (result.resolved.status !== 'found') { + return { content: [{ type: 'text', text: renderResolutionError(symbol, result.resolved) }] } + } + + const node = result.resolved.node + const lines = [`Related to ${node.displayName} (${node.nodeKey}) — lens: ${result.lens}`, ''] + if (result.lens !== 'semantic') { + lines.push('Called by [structural]:', renderHits(result.callers), '', 'Calls [structural]:', renderHits(result.callees), '') + } + if (result.lens !== 'structural') { + lines.push('Semantically similar [semantic]:', result.semanticSupported ? renderSemanticHits(result.similar) : ' (not supported on this storage backend)') + } + return { content: [{ type: 'text', text: lines.join('\n') }] } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return { content: [{ type: 'text', text: `Error: ${msg}` }] } + } + }, + ) + + registerTool( + server, + 'graph_similar', + 'Nodes similar to a symbol/file (Phase 109/147): structural similarity ranks by Jaccard overlap of outgoing edge targets ("same call/import shape"); semantic similarity ranks by embedding cosine similarity. `--lens` selects which signal(s) to include.', + { + symbol: z.string().describe('A symbol qualified name, file path, or literal node key (file:..., symbol:..., external:...)'), + lens: z.enum(['semantic', 'structural', 'hybrid']).optional().default('hybrid').describe('Which lens(es) to include (default: hybrid)'), + top_k: z.number().int().positive().optional().describe('Number of results to return per lens (default 10)'), + }, + async ({ symbol, lens, top_k }) => { + try { + const profile = getCachedStorageProfile(process.cwd()) + const resolvedLens = parseLens(lens, 'hybrid') + const result = await similar(profile.graph, symbol, { lens: resolvedLens, topK: top_k }) + + if (result.resolved.status !== 'found') { + return { content: [{ type: 'text', text: renderResolutionError(symbol, result.resolved) }] } + } + + const node = result.resolved.node + const lines = [`Similar to ${node.displayName} (${node.nodeKey}) — lens: ${result.lens}`, ''] + if (result.lens !== 'semantic') { + lines.push('Structural (same call/import shape):') + lines.push(result.structural.length === 0 ? ' (none)' : result.structural.map((h) => ` ${h.jaccard.toFixed(3)} ${h.displayName} (${h.shared} shared)`).join('\n')) + lines.push('') + } + if (result.lens !== 'structural') { + lines.push('Semantic:') + lines.push(result.semanticSupported ? renderSemanticHits(result.semantic) : ' (not supported on this storage backend)') + } + return { content: [{ type: 'text', text: lines.join('\n') }] } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return { content: [{ type: 'text', text: `Error: ${msg}` }] } + } + }, + ) + + registerTool( + server, + 'graph_unused', + 'Symbols/files with no inbound `calls`/`imports` edges in the structural graph (Phase 109/147: `gitsema index --graph` + `gitsema graph build`) — the structural complement to `dead_concepts`.', + { + edge_types: z.array(EDGE_TYPE_ENUM).optional().describe('Inbound edge types that count as "used" (default: calls, imports)'), + }, + async ({ edge_types }) => { + try { + const profile = getCachedStorageProfile(process.cwd()) + const edgeTypes = (edge_types as EdgeType[] | undefined) ?? UNUSED_EDGE_TYPES + const result = await unused(profile.graph, { edgeTypes }) + if (result.nodes.length === 0) { + return { content: [{ type: 'text', text: 'No unused symbols or files found (or `gitsema graph build` has not been run).' }] } + } + const lines = result.nodes.map((n) => ` [${n.kind}] ${n.displayName}${n.path ? ` (${n.path})` : ''}`) + return { content: [{ type: 'text', text: `${result.nodes.length} unused node${result.nodes.length === 1 ? '' : 's'} (no inbound calls/imports):\n\n${lines.join('\n')}` }] } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return { content: [{ type: 'text', text: `Error: ${msg}` }] } + } + }, + ) + + registerTool( + server, + 'cycles', + 'Detects cycles in the structural graph (Phase 107/147: `gitsema index --graph` + `gitsema graph build`), by default over `imports` edges (import cycles).', + { + edge_types: z.array(EDGE_TYPE_ENUM).optional().describe('Edge types to traverse for cycle detection (default: imports)'), + }, + async ({ edge_types }) => { + try { + const profile = getCachedStorageProfile(process.cwd()) + const edgeTypes = (edge_types as EdgeType[] | undefined) ?? (['imports'] as EdgeType[]) + const found = await findCycles(profile.graph, edgeTypes) + if (found.length === 0) { + return { content: [{ type: 'text', text: `No ${edgeTypes.join('/')} cycles found.` }] } + } + const lines = found.map((c) => ` ${c.displayNames.join(' -> ')}`) + return { content: [{ type: 'text', text: `Found ${found.length} ${edgeTypes.join('/')} cycle(s):\n\n${lines.join('\n')}` }] } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return { content: [{ type: 'text', text: `Error: ${msg}` }] } + } + }, + ) + + registerTool( + server, + 'deps', + 'Dependency (or, with reverse=true, dependent) closure of a file or symbol (Phase 107/147: `gitsema index --graph` + `gitsema graph build`) — BFS over imports/calls/extends/implements edges.', + { + identifier: z.string().describe('A symbol qualified name, file path, or literal node key (file:..., symbol:..., external:...)'), + reverse: z.boolean().optional().describe('Walk dependents (inbound edges) instead of dependencies (outbound edges)'), + depth: z.number().int().positive().optional().describe('Traversal depth (default: unlimited)'), + edge_types: z.array(EDGE_TYPE_ENUM).optional().describe('Edge types to traverse (default: imports, calls, extends, implements)'), + }, + async ({ identifier, reverse, depth, edge_types }) => { + try { + const profile = getCachedStorageProfile(process.cwd()) + const edgeTypes = (edge_types as EdgeType[] | undefined) ?? DEPS_EDGE_TYPES + const result = await deps(profile.graph, identifier, { reverse, depth, edgeTypes }) + + if (result.resolved.status !== 'found') { + return { content: [{ type: 'text', text: renderResolutionError(identifier, result.resolved) }] } + } + + const node = result.resolved.node + const label = reverse ? 'Dependents of' : 'Dependencies of' + if (result.hits.length === 0) { + return { content: [{ type: 'text', text: `${label} ${node.displayName} (${node.nodeKey}):\n\n (none)` }] } + } + const lines = result.hits.map((h) => ` [${h.edgeType}] ${h.displayName} (depth ${h.depth})`) + return { content: [{ type: 'text', text: `${label} ${node.displayName} (${node.nodeKey}):\n\n${lines.join('\n')}` }] } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return { content: [{ type: 'text', text: `Error: ${msg}` }] } + } + }, + ) + + registerTool( + server, + 'co_change', + 'Files that historically change together with a given path (Phase 107/147), materialized as `co_change` edges by `gitsema graph build` from `blob_commits` history.', + { + path: z.string().describe('File path'), + top: z.number().int().positive().optional().default(10).describe('Number of co-changing files to return (default 10)'), + }, + async ({ path, top }) => { + try { + const profile = getCachedStorageProfile(process.cwd()) + const result = await coChange(profile.graph, path, top) + if (!result.found) { + return { content: [{ type: 'text', text: `No graph node for "${path}". Run \`gitsema index --graph\` then \`gitsema graph build\` first.` }] } + } + if (result.hits.length === 0) { + return { content: [{ type: 'text', text: `No co-change history for ${path}.` }] } + } + const lines = result.hits.map((h) => ` ${h.path} (${h.count} commits)`) + return { content: [{ type: 'text', text: `Files that change together with ${path}:\n\n${lines.join('\n')}` }] } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return { content: [{ type: 'text', text: `Error: ${msg}` }] } + } + }, + ) + + registerTool( + server, + 'blast_radius', + '"What breaks if I touch this" (Phase 109/147): structural dependents (who references this node, transitively) and/or semantically similar blobs, selected by `--lens`. The graph-aware upgrade to `impact`\'s semantic-only analysis.', + { + symbol: z.string().describe('A symbol qualified name, file path, or literal node key (file:..., symbol:..., external:...)'), + lens: z.enum(['semantic', 'structural', 'hybrid']).optional().default('hybrid').describe('Which lens(es) to include (default: hybrid)'), + depth: z.number().int().positive().optional().describe('Structural traversal depth (default: unlimited within MAX_GRAPH_TRAVERSAL_DEPTH)'), + top_k: z.number().int().positive().optional().describe('Number of semantic neighbors to return (default 10)'), + }, + async ({ symbol, lens, depth, top_k }) => { + try { + const profile = getCachedStorageProfile(process.cwd()) + const resolvedLens = parseLens(lens, 'hybrid') + const result = await blastRadius(profile.graph, symbol, { lens: resolvedLens, depth, topK: top_k }) + + if (result.resolved.status !== 'found') { + return { content: [{ type: 'text', text: renderResolutionError(symbol, result.resolved) }] } + } + + return { content: [{ type: 'text', text: renderBlastRadius(result, result.resolved.node) }] } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return { content: [{ type: 'text', text: `Error: ${msg}` }] } + } + }, + ) } diff --git a/src/mcp/tools/insights.ts b/src/mcp/tools/insights.ts new file mode 100644 index 0000000..4c63529 --- /dev/null +++ b/src/mcp/tools/insights.ts @@ -0,0 +1,338 @@ +/** + * Insights tools (Phase 148 — triage of remaining zero-HTTP/MCP-exposure CLI + * commands). Wraps commands that had a CLI surface but no MCP tool at all: + * `bisect`, `refactor-candidates`, `lifecycle`, `cherry-pick-suggest`, + * `file-diff`, `pr-report`, `regression-gate`, `code-review`, `heatmap`, + * `map`. Each handler is a thin adapter over the same `src/core/search/*` + * functions the CLI commands call — no business logic is duplicated here. + * + * See docs/parity.md §1 for the bucket (a)/(b)/(c) triage of all 13 + * commands the Phase 148 audit flagged; `diff`, `ci-diff`, + * `cross-repo-similarity`, and `project` were judged redundant or + * CLI-shaped and are intentionally NOT exposed here. + */ + +import { z } from 'zod' +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { registerTool } from '../registerTool.js' +import { getTextProvider } from '../../core/embedding/providerFactory.js' +import { embedQuery } from '../../core/embedding/embedQuery.js' +import { getActiveSession } from '../../core/db/sqlite.js' +import { computeSemanticBisect } from '../../core/search/semanticBisect.js' +import { computeRefactorCandidates } from '../../core/search/refactorCandidates.js' +import { computeConceptLifecycle } from '../../core/search/conceptLifecycle.js' +import { suggestCherryPicks } from '../../core/search/cherryPick.js' +import { computeDiff } from '../../core/search/temporal/evolution.js' +import { computeSemanticDiff } from '../../core/search/semanticDiff.js' +import { computeImpact } from '../../core/search/impact.js' +import { computeConceptChangePoints } from '../../core/search/temporal/changePoints.js' +import { computeExperts } from '../../core/search/experts.js' +import { computeRegressionGate, type RegressionGateQuery } from '../../core/search/regressionGate.js' +import { parseDiff, computeCodeReview } from '../../core/search/codeReview.js' +import { getCachedStorageProfile } from '../../core/storage/resolveProfile.js' +import { parseLens } from '../../cli/lib/lens.js' + +export function registerInsightsTools(server: McpServer) { + // semantic_bisect + registerTool( + server, + 'semantic_bisect', + 'Binary search over commit history to find where a concept diverged from a "good" baseline (semantic git bisect).', + { + good_ref: z.string().describe('A git ref known to be "good" (baseline) — branch, tag, commit hash, or date.'), + bad_ref: z.string().describe('A git ref known to be "bad" (where the concept has drifted).'), + query: z.string().describe('Natural-language concept to track.'), + top_k: z.number().int().min(1).max(50).optional().default(20).describe('Top-K blobs used to compute the concept centroid at each step.'), + max_steps: z.number().int().min(1).max(25).optional().default(10).describe('Maximum bisect steps.'), + }, + async ({ good_ref, bad_ref, query, top_k, max_steps }, { embed }) => { + const provider = getTextProvider() + const eRes = await embed(provider, query, 'Error embedding query') + if (!eRes.ok) return eRes.resp + const result = computeSemanticBisect(eRes.embedding!, query, good_ref, bad_ref, { topK: top_k, maxSteps: max_steps }) + const lines = [ + `Semantic bisect: "${result.query}"`, + ` good: ${result.goodRef} → bad: ${result.badRef}`, + ` Culprit: ${result.culpritRef} (max shift ${result.maxShift.toFixed(3)})`, + '', + ...result.steps.map((s) => ` ${new Date(s.timestamp * 1000).toISOString().slice(0, 10)} blobs=${s.blobCount} dist=${s.distanceFromGood.toFixed(3)}`), + ] + return { content: [{ type: 'text', text: lines.join('\n') }] } + }, + ) + + // refactor_candidates + registerTool( + server, + 'refactor_candidates', + 'Find pairs of symbols/chunks/files that are semantically similar enough to be refactoring candidates.', + { + threshold: z.number().min(0).max(1).optional().default(0.88).describe('Cosine similarity threshold for a candidate pair.'), + top_k: z.number().int().min(1).max(50).optional().default(50).describe('Maximum pairs to return.'), + level: z.enum(['symbol', 'chunk', 'file']).optional().default('symbol').describe('Search granularity.'), + }, + async ({ threshold, top_k, level }) => { + const report = computeRefactorCandidates({ threshold, topK: top_k, level }) + const lines = [ + `Refactoring candidates (level=${report.level}, threshold=${report.threshold}, scanned=${report.totalScanned})`, + '', + ...report.pairs.map((p) => { + const a = p.nameA ? `${p.pathA}::${p.nameA}` : p.pathA + const b = p.nameB ? `${p.pathB}::${p.nameB}` : p.pathB + return ` ${p.similarity.toFixed(3)} ${a} ↔ ${b}` + }), + ] + if (report.pairs.length === 0) lines.push(' No candidates found above threshold.') + return { content: [{ type: 'text', text: lines.join('\n') }] } + }, + ) + + // concept_lifecycle + registerTool( + server, + 'concept_lifecycle', + 'Analyze the lifecycle stages (born → growing → mature → declining → dead) of a semantic concept across Git history.', + { + query: z.string().describe('Natural-language concept to trace.'), + steps: z.number().int().min(2).max(50).optional().default(10).describe('Number of time windows to sample.'), + threshold: z.number().min(0).max(1).optional().default(0.7).describe('Cosine similarity threshold for a "match".'), + }, + async ({ query, steps, threshold }, { embed }) => { + const provider = getTextProvider() + const eRes = await embed(provider, query, 'Error embedding query') + if (!eRes.ok) return eRes.resp + const result = computeConceptLifecycle(eRes.embedding!, query, { steps, threshold }) + const lines = [ + `Concept lifecycle: "${result.query}"`, + ` Current stage: ${result.currentStage}`, + ` Peak: ${result.peakCount} matches on ${new Date(result.peakTimestamp * 1000).toISOString().slice(0, 10)}`, + result.isDead ? ' Concept appears to be dead (no recent matches)' : '', + '', + ...result.points.map((p) => ` ${p.date} count=${p.matchCount} stage=${p.stage}`), + ].filter(Boolean) + return { content: [{ type: 'text', text: lines.join('\n') }] } + }, + ) + + // cherry_pick_suggest + registerTool( + server, + 'cherry_pick_suggest', + 'Suggest commits to cherry-pick based on semantic similarity of their commit messages to a query.', + { + query: z.string().describe('Natural-language description of the change to find.'), + top_k: z.number().int().min(1).max(25).optional().default(10).describe('Number of results to return.'), + }, + async ({ query, top_k }, { embed }) => { + const provider = getTextProvider() + const eRes = await embed(provider, query, 'Error embedding query') + if (!eRes.ok) return eRes.resp + const results = await suggestCherryPicks(eRes.embedding!, { topK: top_k, model: provider.model }) + if (results.length === 0) return { content: [{ type: 'text', text: 'No cherry-pick suggestions found.' }] } + const lines = [`Cherry-pick suggestions for: "${query}"`, ''] + results.forEach((r, idx) => lines.push(`${idx + 1}. ${r.score.toFixed(3)} ${r.commitHash.slice(0, 7)} ${r.message}`)) + return { content: [{ type: 'text', text: lines.join('\n') }] } + }, + ) + + // file_diff + registerTool( + server, + 'file_diff', + 'Compute the semantic diff (cosine distance) between two versions of a single file at two git refs.', + { + ref1: z.string().describe('Earlier git ref (branch, tag, commit hash, or date).'), + ref2: z.string().describe('Later git ref.'), + path: z.string().describe('File path relative to the repo root.'), + neighbors: z.number().int().min(0).max(10).optional().default(0).describe('Number of nearest-neighbour blobs to show for each version.'), + }, + async ({ ref1, ref2, path, neighbors }) => { + const result = await computeDiff(ref1, ref2, path, { neighbors }) + const lines = [ + `Semantic diff: ${path}`, + ` ref1: ${result.ref1} blob: ${result.blobHash1 ?? '(not found)'}`, + ` ref2: ${result.ref2} blob: ${result.blobHash2 ?? '(not found)'}`, + ] + if (result.cosineDistance === null) { + lines.push(' One or both versions are not present in the index, or embeddings are missing.') + } else { + lines.push(` Cosine distance: ${result.cosineDistance.toFixed(4)}`) + } + return { content: [{ type: 'text', text: lines.join('\n') }] } + }, + ) + + // pr_report + registerTool( + server, + 'pr_report', + 'Compose a semantic PR report: diff summary and impacted modules for a file, change-point highlights for a concept query, and suggested reviewers.', + { + ref1: z.string().optional().default('HEAD~1').describe('Base ref.'), + ref2: z.string().optional().default('HEAD').describe('Head ref.'), + file: z.string().optional().describe('File path to analyze for semantic diff and impact.'), + query: z.string().optional().describe('Concept query for change-point highlights.'), + top: z.number().int().min(1).max(25).optional().default(10).describe('Result limit per section.'), + }, + async ({ ref1, ref2, file, query, top }, { embed }) => { + const report: Record = { ref1, ref2 } + const provider = (file || query) ? getTextProvider() : undefined + + if (file && provider) { + try { + const eRes = await embed(provider, file, 'Error embedding file content') + if (eRes.ok) { + const diff = computeSemanticDiff(eRes.embedding!, file, ref1, ref2, top) + report.semanticDiff = { gained: diff.gained.length, lost: diff.lost.length, stable: diff.stable.length } + } + } catch (err) { + report.semanticDiff = { error: err instanceof Error ? err.message : String(err) } + } + try { + const impactReport = await computeImpact(file, provider, { topK: top }) + report.impactedModules = impactReport.results.map((r) => ({ path: r.paths[0] ?? null, score: r.score })) + } catch (err) { + report.impactedModules = { error: err instanceof Error ? err.message : String(err) } + } + } + + if (query && provider) { + try { + const eRes = await embed(provider, query, 'Error embedding query') + if (eRes.ok) { + const cpReport = computeConceptChangePoints(query, eRes.embedding!, { topK: top, topPoints: 5 }) + report.changePoints = cpReport.points.map((p) => ({ + distance: p.distance, + before: { commit: p.before.commit, date: p.before.date }, + after: { commit: p.after.commit, date: p.after.date }, + })) + } + } catch (err) { + report.changePoints = { error: err instanceof Error ? err.message : String(err) } + } + } + + try { + report.reviewerSuggestions = computeExperts({ topN: 5, minBlobs: 1, topClusters: 3 }) + .map((e) => ({ authorName: e.authorName, authorEmail: e.authorEmail, blobCount: e.blobCount })) + } catch (err) { + report.reviewerSuggestions = { error: err instanceof Error ? err.message : String(err) } + } + + return { content: [{ type: 'text', text: JSON.stringify(report, null, 2) }] } + }, + ) + + // regression_gate + registerTool( + server, + 'regression_gate', + 'CI policy gate: compares each concept query\'s top-match score between two git refs and fails if the drift exceeds its threshold. Companion to `policy_check` for pre-merge concept-drift checks.', + { + base: z.string().optional().default('main').describe('Base ref to compare from.'), + head: z.string().optional().default('HEAD').describe('Head ref to compare to.'), + queries: z.array(z.object({ + query: z.string(), + threshold: z.number().min(0).max(2).optional(), + })).min(1).describe('Concept queries to check, each with an optional per-query drift threshold.'), + threshold: z.number().min(0).max(2).optional().default(0.15).describe('Default max allowed cosine drift (used when a query has no per-query threshold).'), + top_k: z.number().int().min(1).max(50).optional().default(10).describe('Top-k results to compare per query.'), + }, + async ({ base, head, queries, threshold, top_k }, { embed }) => { + const provider = getTextProvider() + const embeddedQueries: RegressionGateQuery[] = [] + for (const q of queries) { + const eRes = await embed(provider, q.query, `Error embedding query "${q.query}"`) + if (!eRes.ok) return eRes.resp + embeddedQueries.push({ query: q.query, embedding: eRes.embedding!, threshold: q.threshold ?? threshold }) + } + const report = await computeRegressionGate(embeddedQueries, { baseRef: base, headRef: head, topK: top_k }) + const lines = [ + `Regression gate: ${base}..${head}`, + report.allPassed ? '✅ All concepts within drift threshold — gate PASSED' : '❌ One or more concepts drifted beyond threshold — gate FAILED', + '', + ...report.results.map((r) => ` ${r.passed ? '✓' : '✗'} "${r.query}" base=${r.baseScore.toFixed(4)} head=${r.headScore.toFixed(4)} drift=${r.drift.toFixed(4)} (threshold ${r.threshold})`), + ] + return { content: [{ type: 'text', text: lines.join('\n') }] } + }, + ) + + // code_review + registerTool( + server, + 'code_review', + 'Semantic code review: given a unified diff, find historical analogues for changed code and flag a regression-risk heuristic per file.', + { + diff_text: z.string().describe('Unified diff text (e.g. output of `git diff`).'), + top_k: z.number().int().min(1).max(25).optional().default(5).describe('Top analogues per file.'), + threshold: z.number().min(0).max(1).optional().default(0.75).describe('Minimum similarity score for an analogue to be reported.'), + lens: z.enum(['semantic', 'structural', 'hybrid']).optional().default('semantic').describe('Whether to enrich results with structural (call-graph/co-change) context.'), + }, + async ({ diff_text, top_k, threshold, lens }) => { + const hunks = parseDiff(diff_text) + if (hunks.length === 0) return { content: [{ type: 'text', text: 'No changed files detected in diff.' }] } + + const provider = getTextProvider() + const parsedLens = parseLens(lens, 'semantic') + const graph = parsedLens !== 'semantic' ? getCachedStorageProfile(process.cwd()).graph : undefined + + const reviews = await computeCodeReview(hunks, provider, { topK: top_k, threshold, graph }) + if (reviews.length === 0) return { content: [{ type: 'text', text: 'No changed files with added code to review.' }] } + + const lines = [`Semantic code review — files reviewed: ${hunks.length} | threshold: ${threshold}`, ''] + for (const r of reviews) { + lines.push(`${r.file} (regression risk: ${r.regressionRisk})`) + if (r.analogues.length > 0) { + for (const a of r.analogues) lines.push(` ${a.score.toFixed(4)} ${a.path}`) + } else { + lines.push(' No historical analogues found above threshold.') + } + } + return { content: [{ type: 'text', text: lines.join('\n') }] } + }, + ) + + // activity_heatmap + registerTool( + server, + 'activity_heatmap', + 'Semantic activity heatmap: count of distinct blob changes per time period (week or month).', + { + period: z.enum(['week', 'month']).optional().default('week').describe('Aggregation period.'), + }, + async ({ period }) => { + const { rawDb } = getActiveSession() + const fmt = period === 'month' ? `'%Y-%m'` : `'%Y-%W'` + const rows = rawDb.prepare( + `SELECT strftime(${fmt}, datetime(c.timestamp, 'unixepoch')) AS period, COUNT(DISTINCT b.blob_hash) AS cnt + FROM blob_commits b JOIN commits c ON b.commit_hash = c.commit_hash + GROUP BY period ORDER BY period`, + ).all() as Array<{ period: string; cnt: number }> + if (rows.length === 0) return { content: [{ type: 'text', text: 'No commit/blob activity indexed yet.' }] } + const lines = [`Activity heatmap (${period}):`, '', ...rows.slice(-52).map((r) => ` ${r.period}: ${r.cnt}`)] + return { content: [{ type: 'text', text: lines.join('\n') }] } + }, + ) + + // semantic_map + registerTool( + server, + 'semantic_map', + 'Semantic codebase map: the most recent k-means cluster snapshot (labels, sizes, representative paths) and blob-assignment counts per cluster — the data source behind `gitsema tools serve --ui`\'s cluster overlay.', + {}, + async () => { + const { rawDb } = getActiveSession() + const clusters = rawDb.prepare('SELECT id, label, size, representative_paths FROM blob_clusters').all() as Array<{ id: number; label: string; size: number; representative_paths: string | null }> + if (clusters.length === 0) return { content: [{ type: 'text', text: 'No cluster snapshot found — run `gitsema clusters` first.' }] } + const assignmentRows = rawDb.prepare('SELECT cluster_id, COUNT(*) AS cnt FROM cluster_assignments GROUP BY cluster_id').all() as Array<{ cluster_id: number; cnt: number }> + const assignmentCounts: Record = {} + for (const r of assignmentRows) assignmentCounts[r.cluster_id] = r.cnt + const lines = ['Semantic map (cluster snapshot):', ''] + for (const c of clusters) { + const paths = c.representative_paths ? (JSON.parse(c.representative_paths) as string[]).slice(0, 3).join(', ') : '' + lines.push(` [${c.id}] ${c.label} size=${c.size} assigned=${assignmentCounts[c.id] ?? 0} paths: ${paths}`) + } + return { content: [{ type: 'text', text: lines.join('\n') }] } + }, + ) +} diff --git a/src/server/app.ts b/src/server/app.ts index 050de6c..4c098e8 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -35,6 +35,16 @@ * POST /analysis/ownership (Phase 67) * POST /analysis/workflow (Phase 68) * POST /analysis/eval (Phase 64) + * POST /insights/bisect (Phase 148 — remaining CLI-only commands) + * POST /insights/refactor-candidates + * POST /insights/lifecycle + * POST /insights/cherry-pick-suggest + * POST /insights/file-diff + * POST /insights/pr-report + * POST /insights/regression-gate + * POST /insights/code-review + * POST /insights/heatmap + * POST /insights/map * POST /protocol/:operation (Phase 113 — LSP/MCP remote delegation) * GET /metrics (P2 — Prometheus exposition) * GET /openapi.json (P2 — OpenAPI 3.1 spec) @@ -63,6 +73,7 @@ import { searchRouter } from './routes/search.js' import { evolutionRouter } from './routes/evolution.js' import { remoteRouter } from './routes/remote.js' import { analysisRouter } from './routes/analysis.js' +import { insightsRouter } from './routes/insights.js' import { graphRouter } from './routes/graph.js' import { protocolRouter } from './routes/protocol.js' import { watchRouter } from './routes/watch.js' @@ -192,6 +203,11 @@ export function createApp(options: AppOptions): Express { app.use(`${base}/analysis`, repoSessionMiddleware, analysisRouter({ textProvider })) + // Phase 148: bisect/refactor-candidates/lifecycle/cherry-pick-suggest/ + // file-diff/pr-report/regression-gate/code-review/heatmap/map — CLI + // commands that previously had no HTTP route or MCP tool at all. + app.use(`${base}/insights`, repoSessionMiddleware, insightsRouter({ textProvider })) + // Phase 110/111: structural knowledge-graph routes (hotspots, …) app.use(`${base}/graph`, repoSessionMiddleware, graphRouter()) @@ -240,6 +256,16 @@ export function createApp(options: AppOptions): Express { 'early_cut', 'projections', 'watch', + 'semantic_bisect', + 'refactor_candidates', + 'concept_lifecycle', + 'cherry_pick_suggest', + 'file_diff', + 'pr_report', + 'regression_gate', + 'code_review', + 'activity_heatmap', + 'semantic_map', ], providers: { text: textProvider.model, diff --git a/src/server/routes/analysis.ts b/src/server/routes/analysis.ts index b573a71..e362a64 100644 --- a/src/server/routes/analysis.ts +++ b/src/server/routes/analysis.ts @@ -47,12 +47,23 @@ import { getActiveSession } from '../../core/db/sqlite.js' import { multiRepoSearch } from '../../core/indexing/repoRegistry.js' import { embedQuery } from '../../core/embedding/embedQuery.js' import { modelOverrideSchema, resolveRequestProvider, ModelOverrideError } from '../lib/modelOverrides.js' +import { getCachedStorageProfile } from '../../core/storage/resolveProfile.js' +import { blastRadius } from '../../core/graph/blastRadius.js' +import { parseLens } from '../../cli/lib/lens.js' +// Phase 143: `clusters`, `merge-preview` additionally accept `iterations` +// (k-means max iterations), `edgeThreshold` (concept-graph edge cutoff), and +// `enhancedKeywordsN` (TF-IDF keyword count when `useEnhancedLabels` is on) — +// mirrors the CLI's `--iterations`/`--edge-threshold`/`--enhanced-keywords-n` +// flags (see `src/cli/commands/clusters.ts` / `mergePreview.ts`). const ClustersBodySchema = z.object({ ...modelOverrideSchema.shape, k: z.number().int().positive().optional().default(8), topKeywords: z.number().int().positive().optional().default(5), useEnhancedLabels: z.boolean().optional().default(false), + enhancedKeywordsN: z.number().int().positive().optional().default(5), + iterations: z.number().int().positive().optional().default(20), + edgeThreshold: z.number().min(0).optional().default(0.3), branch: z.string().optional(), }) @@ -86,11 +97,21 @@ const AuthorBodySchema = z.object({ vss: z.boolean().optional().default(false), }) +// Phase 143: `chunks`/`level` mirror the CLI's `--chunks`/`--level` flags +// (src/cli/commands/impact.ts). `lens` (semantic/structural/hybrid, default +// `semantic`) closes the gap noted in review10/PLAN Phase 143 — this route +// previously only ever did semantic-lens impact analysis, silently diverging +// from the CLI's `impact` command, whose default lens (via `addLensOption`) +// is also `semantic` but which additionally supports `--lens +// structural|hybrid` as a thin alias over `blast-radius`. const ImpactBodySchema = z.object({ ...modelOverrideSchema.shape, file: z.string().min(1), topK: z.number().int().positive().optional().default(10), branch: z.string().optional(), + chunks: z.boolean().optional().default(false), + level: z.enum(['file', 'chunk', 'symbol']).optional(), + lens: z.enum(['semantic', 'structural', 'hybrid']).optional().default('semantic'), }) const ExpertsBodySchema = z.object({ @@ -137,6 +158,9 @@ export function analysisRouter(deps: AnalysisRouterDeps): Router { k: opts.k, topKeywords: opts.topKeywords, useEnhancedLabels: opts.useEnhancedLabels, + enhancedKeywordsN: opts.enhancedKeywordsN, + maxIterations: opts.iterations, + edgeThreshold: opts.edgeThreshold, blobHashFilter, }) res.json(report) @@ -269,6 +293,23 @@ export function analysisRouter(deps: AnalysisRouterDeps): Router { return } const opts = parsed.data + const lens = parseLens(opts.lens, 'semantic') + + // `--lens structural|hybrid` makes impact a thin alias over blast-radius + // (mirrors `impactCommand` in src/cli/commands/impact.ts, Phase 109/143) — + // true structural dependents instead of (or alongside) semantic similarity. + if (lens !== 'semantic') { + try { + const profile = getCachedStorageProfile(process.cwd()) + const normalised = opts.file.trim().replace(/\\/g, '/').replace(/^\.\//, '') + const result = await blastRadius(profile.graph, normalised, { lens, topK: opts.topK }) + res.json(result) + } catch (err) { + res.status(500).json({ error: err instanceof Error ? err.message : String(err) }) + } + return + } + let provider: EmbeddingProvider try { provider = resolveRequestProvider(opts, textProvider) @@ -280,6 +321,8 @@ export function analysisRouter(deps: AnalysisRouterDeps): Router { const report = await computeImpact(opts.file, provider, { topK: opts.topK, branch: opts.branch, + searchChunks: opts.level === 'chunk' || opts.chunks, + searchSymbols: opts.level === 'symbol', }) res.json(report) } catch (err) { @@ -288,6 +331,11 @@ export function analysisRouter(deps: AnalysisRouterDeps): Router { }) // POST /analysis/semantic-diff + // Phase 143: `hybrid`/`bm25Weight` mirror the CLI `diff + // ` command's `--hybrid`/`--bm25-weight` flags (src/cli/commands/ + // semanticDiff.ts) — previously declared on the CLI but never wired to + // anything; both the CLI and this route now actually blend BM25 keyword + // matching in via `hybridSearch()` when `hybrid` is set. const SemanticDiffBodySchema = z.object({ ...modelOverrideSchema.shape, ref1: z.string().min(1), @@ -295,6 +343,8 @@ export function analysisRouter(deps: AnalysisRouterDeps): Router { query: z.string().min(1), topK: z.number().int().positive().optional().default(10), branch: z.string().optional(), + hybrid: z.boolean().optional().default(false), + bm25Weight: z.number().min(0).max(1).optional().default(0.3), }) router.post('/semantic-diff', async (req, res) => { const parsed = SemanticDiffBodySchema.safeParse(req.body) @@ -312,8 +362,22 @@ export function analysisRouter(deps: AnalysisRouterDeps): Router { res.status(status).json({ error: `Embedding failed: ${err instanceof Error ? err.message : String(err)}` }) return } + let candidateBlobs: Array<{ blobHash: string; score: number }> | undefined + if (opts.hybrid) { + try { + const hybridResults = await hybridSearch(opts.query, qEmb, { + topK: Math.max(opts.topK * 5, 100), + bm25Weight: opts.bm25Weight, + branch: opts.branch, + }) + candidateBlobs = hybridResults.map((r) => ({ blobHash: r.blobHash, score: r.score })) + } catch (err) { + res.status(500).json({ error: `Hybrid search failed: ${err instanceof Error ? err.message : String(err)}` }) + return + } + } try { - const result = computeSemanticDiff(qEmb, opts.query, opts.ref1, opts.ref2, opts.topK, opts.branch) + const result = computeSemanticDiff(qEmb, opts.query, opts.ref1, opts.ref2, opts.topK, opts.branch, candidateBlobs) res.json(result) } catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : String(err) }) @@ -321,12 +385,18 @@ export function analysisRouter(deps: AnalysisRouterDeps): Router { }) // POST /analysis/semantic-blame + // Phase 143: `level` (file/symbol) mirrors the CLI `blame`/`semantic-blame` + // command's `--level` flag (src/cli/commands/semanticBlame.ts) — `level: + // 'symbol'` is equivalent to `searchSymbols: true`, kept for backward + // compatibility with existing HTTP callers that already pass `searchSymbols` + // directly. const SemanticBlameBodySchema = z.object({ ...modelOverrideSchema.shape, filePath: z.string().min(1), content: z.string().min(1), topK: z.number().int().positive().optional().default(3), searchSymbols: z.boolean().optional().default(false), + level: z.enum(['file', 'symbol']).optional(), branch: z.string().optional(), }) router.post('/semantic-blame', async (req, res) => { @@ -344,7 +414,8 @@ export function analysisRouter(deps: AnalysisRouterDeps): Router { return } try { - const entries = await computeSemanticBlame(opts.filePath, opts.content, qEmbProvider, { topK: opts.topK, searchSymbols: opts.searchSymbols, branch: opts.branch }) + const searchSymbols = opts.level === 'symbol' || opts.searchSymbols + const entries = await computeSemanticBlame(opts.filePath, opts.content, qEmbProvider, { topK: opts.topK, searchSymbols, branch: opts.branch }) res.json(entries) } catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : String(err) }) @@ -382,9 +453,13 @@ export function analysisRouter(deps: AnalysisRouterDeps): Router { }) // POST /analysis/merge-audit + // Phase 143: `base` mirrors the CLI `merge-audit` command's `--base ` + // flag (src/cli/commands/mergeAudit.ts) — overrides merge-base detection + // (useful when the true merge-base isn't reachable, e.g. shallow clones). const MergeAuditBodySchema = z.object({ branchA: z.string().min(1), branchB: z.string().min(1), + base: z.string().optional(), threshold: z.number().min(0).max(1).optional().default(0.85), topK: z.number().int().positive().optional().default(10), }) @@ -396,7 +471,7 @@ export function analysisRouter(deps: AnalysisRouterDeps): Router { } const opts = parsed.data try { - const mergeBase = getMergeBase(opts.branchA, opts.branchB) + const mergeBase = opts.base ?? getMergeBase(opts.branchA, opts.branchB) const blobsA = getBranchExclusiveBlobs(opts.branchA, mergeBase) const blobsB = getBranchExclusiveBlobs(opts.branchB, mergeBase) const report = computeSemanticCollisions(blobsA, blobsB, opts.branchA, opts.branchB, mergeBase, { threshold: opts.threshold, topK: opts.topK }) @@ -407,10 +482,23 @@ export function analysisRouter(deps: AnalysisRouterDeps): Router { }) // POST /analysis/merge-preview + // Phase 143: `top`/`iterations`/`edgeThreshold`/`enhancedKeywordsN` mirror + // the CLI `merge-preview` command's `--top`/`--iterations`/ + // `--edge-threshold`/`--enhanced-keywords-n` flags (src/cli/commands/ + // mergePreview.ts). `useEnhancedLabels` isn't in the CLI's flag list for + // this route per the Phase 143 scope, but is added here too (deviation, + // noted in docs/PLAN.md) since `enhancedKeywordsN` is a no-op without it — + // `computeClusters`/`computeMergeImpact` only compute TF-IDF-enhanced + // keywords when `useEnhancedLabels` is true. const MergePreviewBodySchema = z.object({ branch: z.string().min(1), into: z.string().optional().default('main'), k: z.number().int().positive().optional().default(8), + top: z.number().int().positive().optional().default(5), + iterations: z.number().int().positive().optional().default(20), + edgeThreshold: z.number().min(0).optional().default(0.3), + useEnhancedLabels: z.boolean().optional().default(false), + enhancedKeywordsN: z.number().int().positive().optional().default(5), }) router.post('/merge-preview', async (req, res) => { const parsed = MergePreviewBodySchema.safeParse(req.body) @@ -420,7 +508,14 @@ export function analysisRouter(deps: AnalysisRouterDeps): Router { } const opts = parsed.data try { - const report = await computeMergeImpact(opts.branch, opts.into, { k: opts.k }) + const report = await computeMergeImpact(opts.branch, opts.into, { + k: opts.k, + topPaths: opts.top, + maxIterations: opts.iterations, + edgeThreshold: opts.edgeThreshold, + useEnhancedLabels: opts.useEnhancedLabels, + enhancedKeywordsN: opts.enhancedKeywordsN, + }) res.json(report) } catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : String(err) }) @@ -428,10 +523,23 @@ export function analysisRouter(deps: AnalysisRouterDeps): Router { }) // POST /analysis/branch-summary + // Phase 143: `enhancedLabels`/`enhancedKeywordsN` mirror the CLI + // `branch-summary` command's `--enhanced-labels`/`--enhanced-keywords-n` + // flags (src/cli/commands/branchSummary.ts). Unlike `clusters`/ + // `merge-preview`, `computeBranchSummary()` itself has no enhanced-labels + // concept — in the CLI these flags only control how many of each concept's + // already-computed `topKeywords` are *displayed* in the text renderer + // (`printResult`), while the CLI's own `--dump` JSON always returns the + // full unsliced array. Since this HTTP route's response is JSON-only (the + // API's only "view"), we apply that same slicing here so the flags have an + // observable effect: sliced to `enhancedKeywordsN` (default 8) when + // `enhancedLabels` is set, else the CLI's non-enhanced text default of 5. const BranchSummaryBodySchema = z.object({ branch: z.string().min(1), baseBranch: z.string().optional().default('main'), topConcepts: z.number().int().positive().optional().default(5), + enhancedLabels: z.boolean().optional().default(false), + enhancedKeywordsN: z.number().int().positive().optional().default(8), }) router.post('/branch-summary', async (req, res) => { const parsed = BranchSummaryBodySchema.safeParse(req.body) @@ -442,7 +550,12 @@ export function analysisRouter(deps: AnalysisRouterDeps): Router { const opts = parsed.data try { const report = await computeBranchSummary(opts.branch, opts.baseBranch, { topConcepts: opts.topConcepts }) - res.json(report) + const keywordsN = opts.enhancedLabels ? opts.enhancedKeywordsN : 5 + const nearestConcepts = report.nearestConcepts.map((c) => ({ + ...c, + topKeywords: c.topKeywords.slice(0, keywordsN), + })) + res.json({ ...report, nearestConcepts }) } catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : String(err) }) } @@ -451,9 +564,13 @@ export function analysisRouter(deps: AnalysisRouterDeps): Router { // POST /analysis/security-scan // Returns semantic similarity findings for common vulnerability patterns. // Results are similarity scores, NOT confirmed vulnerabilities. + // Phase 143: `highConfidenceOnly` mirrors the CLI `security-scan` command's + // `--high-confidence-only` flag (src/cli/commands/securityScan.ts) — filters + // findings down to `confidence === 'high'` (both semantic + structural signal). const SecurityScanBodySchema = z.object({ top: z.number().int().positive().optional().default(10), model: z.string().optional(), + highConfidenceOnly: z.boolean().optional().default(false), }) router.post('/security-scan', async (req, res) => { const parsed = SecurityScanBodySchema.safeParse(req.body) @@ -464,7 +581,10 @@ export function analysisRouter(deps: AnalysisRouterDeps): Router { const opts = parsed.data try { const session = getActiveSession() - const findings = await scanForVulnerabilities(session, textProvider, { top: opts.top, model: opts.model }) + let findings = await scanForVulnerabilities(session, textProvider, { top: opts.top, model: opts.model }) + if (opts.highConfidenceOnly) { + findings = findings.filter((f) => f.confidence === 'high') + } res.json({ disclaimer: 'Results are semantic similarity scores, not confirmed vulnerabilities. Manual review required.', findings }) } catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : String(err) }) @@ -752,14 +872,31 @@ export function analysisRouter(deps: AnalysisRouterDeps): Router { }) // POST /analysis/workflow - // Run a named workflow template (pr-review | incident | release-audit). + // Run a named workflow template — all 8 productized patterns supported by + // the CLI's `workflow run` (Phase 142): pr-review, incident, release-audit, + // onboarding, ownership-intel, arch-drift, knowledge-portal, + // regression-forecast. `role`/`ref`/`base` are accepted generally (not + // gated to a single template) mirroring the CLI's `workflow run` flags — + // see docs/parity.md for the note on `base` remaining a no-op (same as + // the CLI, which never reads `options.base` in `workflowCommand`). const WorkflowBodySchema = z.object({ ...modelOverrideSchema.shape, - template: z.enum(['pr-review', 'incident', 'release-audit']), + template: z.enum([ + 'pr-review', + 'incident', + 'release-audit', + 'onboarding', + 'ownership-intel', + 'arch-drift', + 'knowledge-portal', + 'regression-forecast', + ]), query: z.string().optional(), file: z.string().optional(), top: z.number().int().positive().optional().default(5), base: z.string().optional(), + role: z.string().optional(), + ref: z.string().optional(), }) router.post('/workflow', async (req, res) => { const parsed = WorkflowBodySchema.safeParse(req.body) @@ -776,6 +913,18 @@ export function analysisRouter(deps: AnalysisRouterDeps): Router { res.status(400).json({ error: '`query` is required for the incident template' }) return } + if (opts.template === 'ownership-intel' && !opts.query) { + res.status(400).json({ error: '`query` is required for the ownership-intel template' }) + return + } + if (opts.template === 'knowledge-portal' && !opts.query) { + res.status(400).json({ error: '`query` is required for the knowledge-portal template' }) + return + } + if (opts.template === 'regression-forecast' && !opts.query) { + res.status(400).json({ error: '`query` is required for the regression-forecast template' }) + return + } let provider: EmbeddingProvider try { provider = resolveRequestProvider(opts, textProvider) @@ -817,8 +966,7 @@ export function analysisRouter(deps: AnalysisRouterDeps): Router { try { sections.experts = computeExperts({ topN: top }) } catch (err) { sections.experts = { error: err instanceof Error ? err.message : String(err) } } - } else { - // release-audit + } else if (opts.template === 'release-audit') { const query = opts.query ?? 'architecture changes quality' let emb: number[] try { @@ -833,6 +981,100 @@ export function analysisRouter(deps: AnalysisRouterDeps): Router { catch (err) { sections.changePoints = { error: err instanceof Error ? err.message : String(err) } } try { sections.experts = computeExperts({ topN: top }) } catch (err) { sections.experts = { error: err instanceof Error ? err.message : String(err) } } + + } else if (opts.template === 'onboarding') { + const topic = opts.role ?? opts.query ?? 'authentication' + let emb: number[] + try { + emb = await embedQuery(provider, topic) as number[] + } catch (err) { + res.status(502).json({ error: `Embedding failed: ${err instanceof Error ? err.message : String(err)}` }) + return + } + try { sections.relevantBlobs = await vectorSearch(emb, { topK: top }) } + catch (err) { sections.relevantBlobs = { error: err instanceof Error ? err.message : String(err) } } + try { sections.changePoints = computeConceptChangePoints(topic, emb, { topK: top }) } + catch (err) { sections.changePoints = { error: err instanceof Error ? err.message : String(err) } } + try { sections.keyExperts = computeExperts({ topN: top }) } + catch (err) { sections.keyExperts = { error: err instanceof Error ? err.message : String(err) } } + + } else if (opts.template === 'ownership-intel') { + let emb: number[] + try { + emb = await embedQuery(provider, opts.query!) as number[] + } catch (err) { + res.status(502).json({ error: `Embedding failed: ${err instanceof Error ? err.message : String(err)}` }) + return + } + try { + const contributions = await computeAuthorContributions(emb, { topAuthors: top }) + sections.suggestedReviewers = contributions.map((c) => ({ + author: c.authorName, + email: c.authorEmail, + score: c.totalScore, + blobCount: c.blobCount, + })) + } catch (err) { + sections.suggestedReviewers = { error: err instanceof Error ? err.message : String(err) } + } + try { sections.topResults = await vectorSearch(emb, { topK: top }) } + catch (err) { sections.topResults = { error: err instanceof Error ? err.message : String(err) } } + + } else if (opts.template === 'arch-drift') { + try { + const session = getActiveSession() + sections.health = computeHealthTimeline(session, { buckets: Math.min(top, 12) }) + } catch (err) { + sections.health = { error: err instanceof Error ? err.message : String(err) } + } + try { + const session = getActiveSession() + sections.debt = await scoreDebt(session, provider, { top }) + } catch (err) { + sections.debt = { error: err instanceof Error ? err.message : String(err) } + } + try { + const query = opts.query ?? 'architecture structure modules' + const emb = await embedQuery(provider, query) as number[] + sections.changePoints = computeConceptChangePoints(query, emb, { topK: top }) + } catch (err) { + sections.changePoints = { error: err instanceof Error ? err.message : String(err) } + } + + } else if (opts.template === 'knowledge-portal') { + let emb: number[] + try { + emb = await embedQuery(provider, opts.query!) as number[] + } catch (err) { + res.status(502).json({ error: `Embedding failed: ${err instanceof Error ? err.message : String(err)}` }) + return + } + try { sections.results = await vectorSearch(emb, { topK: top }) } + catch (err) { sections.results = { error: err instanceof Error ? err.message : String(err) } } + try { sections.relatedConcepts = computeConceptChangePoints(opts.query!, emb, { topK: top }) } + catch (err) { sections.relatedConcepts = { error: err instanceof Error ? err.message : String(err) } } + try { sections.owners = computeExperts({ topN: top }) } + catch (err) { sections.owners = { error: err instanceof Error ? err.message : String(err) } } + + } else { + // regression-forecast + let emb: number[] + try { + emb = await embedQuery(provider, opts.query!) as number[] + } catch (err) { + res.status(502).json({ error: `Embedding failed: ${err instanceof Error ? err.message : String(err)}` }) + return + } + try { sections.currentNeighbourhood = await vectorSearch(emb, { topK: top }) } + catch (err) { sections.currentNeighbourhood = { error: err instanceof Error ? err.message : String(err) } } + try { sections.changePoints = computeConceptChangePoints(opts.query!, emb, { topK: top }) } + catch (err) { sections.changePoints = { error: err instanceof Error ? err.message : String(err) } } + try { sections.riskOwners = computeExperts({ topN: top }) } + catch (err) { sections.riskOwners = { error: err instanceof Error ? err.message : String(err) } } + if (opts.ref) { + sections.baseRef = opts.ref + sections.note = 'Run `gitsema diff HEAD ` for a full semantic diff between refs.' + } } res.json({ template: opts.template, sections }) diff --git a/src/server/routes/graph.ts b/src/server/routes/graph.ts index b1764db..8fd3ef8 100644 --- a/src/server/routes/graph.ts +++ b/src/server/routes/graph.ts @@ -1,15 +1,39 @@ /** - * HTTP routes for the structural knowledge graph (Phase 110/111). + * HTTP routes for the structural knowledge graph (Phase 110/111, extended + * Phase 147). * * Routes (under /api/v1/graph/): - * POST /hotspots — architectural risk = co-change × call-coupling × churn + * POST /hotspots — architectural risk = co-change × call-coupling × churn + * POST /callers — reverse `calls` traversal (who calls a symbol) + * POST /callees — forward `calls` traversal (what a symbol calls) + * POST /neighbors — typed neighborhood of a node (any edge kinds) + * POST /path — shortest typed path between two nodes + * POST /relate — structural callers/callees + semantic neighbors + * POST /similar — structural (Jaccard shape) + semantic similarity + * POST /unused — nodes with no inbound calls/imports edges + * POST /cycles — cycle detection over typed edges (default: imports) + * POST /deps — dependency/dependent closure (imports/calls/extends/implements) + * POST /co-change — files that historically change together with a path + * POST /blast-radius — structural dependents + semantic neighbors ("what breaks if I touch this") + * + * `graph build` (Phase 107's truncate-and-rebuild of `graph_nodes`/`edges`) + * is intentionally **not** exposed here — see the note above `graphRouter()`. */ import { Router } from 'express' import { z } from 'zod' import { getCachedStorageProfile } from '../../core/storage/resolveProfile.js' import { computeHotspots, churnByPath } from '../../core/graph/hotspots.js' +import { callers, callees, path as graphPath, neighbors } from '../../core/graph/traversal.js' +import { relate } from '../../core/graph/relate.js' +import { similar } from '../../core/graph/similar.js' +import { unused, UNUSED_EDGE_TYPES } from '../../core/graph/unused.js' +import { findCycles } from '../../core/graph/cycles.js' +import { deps, DEPS_EDGE_TYPES } from '../../core/graph/deps.js' +import { coChange } from '../../core/graph/coChange.js' +import { blastRadius } from '../../core/graph/blastRadius.js' import { parseLens } from '../../cli/lib/lens.js' +import type { EdgeType } from '../../core/storage/types.js' const HotspotsBodySchema = z.object({ lens: z.enum(['semantic', 'structural', 'hybrid']).optional().default('hybrid'), @@ -21,6 +45,68 @@ const HotspotsBodySchema = z.object({ weightStructural: z.number().optional(), }) +const EdgeTypeSchema = z.enum(['contains', 'defines', 'imports', 'calls', 'extends', 'implements', 'references', 'co_change', 'similar_to']) + +const TraversalBodySchema = z.object({ + symbol: z.string().min(1), + depth: z.number().int().min(1).max(3).optional(), +}) + +const PathBodySchema = z.object({ + from: z.string().min(1), + to: z.string().min(1), +}) + +const NeighborsBodySchema = z.object({ + node: z.string().min(1), + edgeTypes: z.array(EdgeTypeSchema).optional(), + direction: z.enum(['out', 'in', 'both']).optional().default('both'), + depth: z.number().int().min(1).max(3).optional(), +}) + +const LensQueryBodySchema = z.object({ + symbol: z.string().min(1), + lens: z.enum(['semantic', 'structural', 'hybrid']).optional().default('hybrid'), + topK: z.number().int().positive().optional(), +}) + +const UnusedBodySchema = z.object({ + edgeTypes: z.array(EdgeTypeSchema).optional(), +}) + +const CyclesBodySchema = z.object({ + edgeTypes: z.array(EdgeTypeSchema).optional(), +}) + +const DepsBodySchema = z.object({ + identifier: z.string().min(1), + reverse: z.boolean().optional(), + depth: z.number().int().positive().optional(), + edgeTypes: z.array(EdgeTypeSchema).optional(), +}) + +const CoChangeBodySchema = z.object({ + path: z.string().min(1), + top: z.number().int().positive().optional().default(10), +}) + +const BlastRadiusBodySchema = z.object({ + symbol: z.string().min(1), + lens: z.enum(['semantic', 'structural', 'hybrid']).optional().default('hybrid'), + depth: z.number().int().positive().optional(), + topK: z.number().int().positive().optional(), +}) + +/** + * `graph build` (Phase 107) truncates and rebuilds `graph_nodes`/`edges` from + * `structural_refs`/`symbols`/`blob_commits` — a mutating, full-table-rewrite + * index-maintenance operation, not a query. None of gitsema's other + * maintenance commands with the same shape (`index vacuum`, `index gc`, + * `index rebuild-fts`, `index update-modules`, `index clear-model`, + * `index build-vss`) have an HTTP route either — they're CLI/local-only by + * existing convention. `graph build` follows that precedent rather than + * becoming the first mutating maintenance op exposed over HTTP (Phase 147). + */ export function graphRouter(): Router { const router = Router() @@ -44,5 +130,181 @@ export function graphRouter(): Router { } }) + router.post('/callers', async (req, res) => { + const parsed = TraversalBodySchema.safeParse(req.body ?? {}) + if (!parsed.success) { + res.status(400).json({ error: 'Invalid request body', details: parsed.error.flatten() }) + return + } + try { + const profile = getCachedStorageProfile(process.cwd()) + const result = await callers(profile.graph, parsed.data.symbol, parsed.data.depth) + res.json(result) + } catch (err) { + res.status(500).json({ error: err instanceof Error ? err.message : String(err) }) + } + }) + + router.post('/callees', async (req, res) => { + const parsed = TraversalBodySchema.safeParse(req.body ?? {}) + if (!parsed.success) { + res.status(400).json({ error: 'Invalid request body', details: parsed.error.flatten() }) + return + } + try { + const profile = getCachedStorageProfile(process.cwd()) + const result = await callees(profile.graph, parsed.data.symbol, parsed.data.depth) + res.json(result) + } catch (err) { + res.status(500).json({ error: err instanceof Error ? err.message : String(err) }) + } + }) + + router.post('/neighbors', async (req, res) => { + const parsed = NeighborsBodySchema.safeParse(req.body ?? {}) + if (!parsed.success) { + res.status(400).json({ error: 'Invalid request body', details: parsed.error.flatten() }) + return + } + try { + const profile = getCachedStorageProfile(process.cwd()) + const { node, edgeTypes, direction, depth } = parsed.data + const result = await neighbors(profile.graph, node, { edgeTypes: edgeTypes as EdgeType[] | undefined, direction, depth }) + res.json(result) + } catch (err) { + res.status(500).json({ error: err instanceof Error ? err.message : String(err) }) + } + }) + + router.post('/path', async (req, res) => { + const parsed = PathBodySchema.safeParse(req.body ?? {}) + if (!parsed.success) { + res.status(400).json({ error: 'Invalid request body', details: parsed.error.flatten() }) + return + } + try { + const profile = getCachedStorageProfile(process.cwd()) + const result = await graphPath(profile.graph, parsed.data.from, parsed.data.to) + res.json(result) + } catch (err) { + res.status(500).json({ error: err instanceof Error ? err.message : String(err) }) + } + }) + + router.post('/relate', async (req, res) => { + const parsed = LensQueryBodySchema.safeParse(req.body ?? {}) + if (!parsed.success) { + res.status(400).json({ error: 'Invalid request body', details: parsed.error.flatten() }) + return + } + try { + const profile = getCachedStorageProfile(process.cwd()) + const { symbol, lens, topK } = parsed.data + const result = await relate(profile.graph, symbol, { lens: parseLens(lens, 'hybrid'), topK }) + res.json(result) + } catch (err) { + res.status(500).json({ error: err instanceof Error ? err.message : String(err) }) + } + }) + + router.post('/similar', async (req, res) => { + const parsed = LensQueryBodySchema.safeParse(req.body ?? {}) + if (!parsed.success) { + res.status(400).json({ error: 'Invalid request body', details: parsed.error.flatten() }) + return + } + try { + const profile = getCachedStorageProfile(process.cwd()) + const { symbol, lens, topK } = parsed.data + const result = await similar(profile.graph, symbol, { lens: parseLens(lens, 'hybrid'), topK }) + res.json(result) + } catch (err) { + res.status(500).json({ error: err instanceof Error ? err.message : String(err) }) + } + }) + + router.post('/unused', async (req, res) => { + const parsed = UnusedBodySchema.safeParse(req.body ?? {}) + if (!parsed.success) { + res.status(400).json({ error: 'Invalid request body', details: parsed.error.flatten() }) + return + } + try { + const profile = getCachedStorageProfile(process.cwd()) + const edgeTypes = (parsed.data.edgeTypes as EdgeType[] | undefined) ?? UNUSED_EDGE_TYPES + const result = await unused(profile.graph, { edgeTypes }) + res.json(result) + } catch (err) { + res.status(500).json({ error: err instanceof Error ? err.message : String(err) }) + } + }) + + router.post('/cycles', async (req, res) => { + const parsed = CyclesBodySchema.safeParse(req.body ?? {}) + if (!parsed.success) { + res.status(400).json({ error: 'Invalid request body', details: parsed.error.flatten() }) + return + } + try { + const profile = getCachedStorageProfile(process.cwd()) + const edgeTypes = (parsed.data.edgeTypes as EdgeType[] | undefined) ?? (['imports'] as EdgeType[]) + const cycles = await findCycles(profile.graph, edgeTypes) + res.json({ edgeTypes, cycles }) + } catch (err) { + res.status(500).json({ error: err instanceof Error ? err.message : String(err) }) + } + }) + + router.post('/deps', async (req, res) => { + const parsed = DepsBodySchema.safeParse(req.body ?? {}) + if (!parsed.success) { + res.status(400).json({ error: 'Invalid request body', details: parsed.error.flatten() }) + return + } + try { + const profile = getCachedStorageProfile(process.cwd()) + const { identifier, reverse, depth, edgeTypes } = parsed.data + const result = await deps(profile.graph, identifier, { + reverse, + depth, + edgeTypes: (edgeTypes as EdgeType[] | undefined) ?? DEPS_EDGE_TYPES, + }) + res.json(result) + } catch (err) { + res.status(500).json({ error: err instanceof Error ? err.message : String(err) }) + } + }) + + router.post('/co-change', async (req, res) => { + const parsed = CoChangeBodySchema.safeParse(req.body ?? {}) + if (!parsed.success) { + res.status(400).json({ error: 'Invalid request body', details: parsed.error.flatten() }) + return + } + try { + const profile = getCachedStorageProfile(process.cwd()) + const result = await coChange(profile.graph, parsed.data.path, parsed.data.top) + res.json(result) + } catch (err) { + res.status(500).json({ error: err instanceof Error ? err.message : String(err) }) + } + }) + + router.post('/blast-radius', async (req, res) => { + const parsed = BlastRadiusBodySchema.safeParse(req.body ?? {}) + if (!parsed.success) { + res.status(400).json({ error: 'Invalid request body', details: parsed.error.flatten() }) + return + } + try { + const profile = getCachedStorageProfile(process.cwd()) + const { symbol, lens, depth, topK } = parsed.data + const result = await blastRadius(profile.graph, symbol, { lens: parseLens(lens, 'hybrid'), depth, topK }) + res.json(result) + } catch (err) { + res.status(500).json({ error: err instanceof Error ? err.message : String(err) }) + } + }) + return router } diff --git a/src/server/routes/guide.ts b/src/server/routes/guide.ts index 5645b00..f240b03 100644 --- a/src/server/routes/guide.ts +++ b/src/server/routes/guide.ts @@ -4,9 +4,22 @@ * POST /api/v1/guide/chat — single-turn chat endpoint backed by the gitsema * guide agent loop (chattydeer `runAgentLoop` — repo_stats, recent_commits, * narrate_repo, explain_topic, semantic_search; up to 5 roundtrips). - * Body: { question: string, model?: string, guideModelId?: number, includeContext?: boolean } + * Body: { question: string, model?: string, guideModelId?: number, includeContext?: boolean, lens?: string } * Response: { answer, contextUsed, llmEnabled, roundtrips?, toolCallsUsed? } * + * `lens` (Phase 145) mirrors CLI `guide --lens ` + * (Phase 111): it doesn't change `runGuide`'s options, it appends the same + * "(Lens preference: ...)" hint suffix to the question before it's sent to + * the agent loop, biasing tool choice toward call_graph/blast_radius/hotspots + * for structural/hybrid — see `guideCommand`'s `withLens()` in + * `src/cli/commands/guide.ts`, which this route replicates exactly so + * CLI and HTTP callers get identical prompting for the same lens value. + * + * Remote multi-turn sessions (CLI `--interactive`/`-i`, which reuses one + * agent session across turns) are explicitly out of scope for this route — + * see docs/PLAN.md Phase 145 Status note and docs/feature-ideas.md for the + * deferred session-design question. + * * For full OpenAI /v1/chat/completions pass-through (not yet wired), see * docs/chattydeer_contract.md. */ @@ -14,6 +27,7 @@ import { Router, type Request, type Response } from 'express' import { z } from 'zod' import { runGuide } from '../../cli/commands/guide.js' +import { parseLens } from '../../cli/lib/lens.js' const router = Router() @@ -35,6 +49,7 @@ const GuideChatBodySchema = z.object({ model: z.string().optional().describe('Guide/narrator model name to use (overrides active selection)'), guide_model_id: z.number().int().positive().optional().describe('embed_config.id of the guide model'), include_context: z.boolean().optional().default(true).describe('Whether to gather git context before answering (default: true)'), + lens: z.enum(['semantic', 'structural', 'hybrid']).optional().describe('Structural/hybrid tool-bias hint (Phase 111) — prefers call_graph/blast_radius/hotspots when set to structural or hybrid (default: semantic)'), byok: ByokSchema.optional().describe('Request-scoped bring-your-own-key credentials, bypasses configured/allow-listed models, never persisted'), }) @@ -52,10 +67,19 @@ router.post('/chat', async (req: Request, res: Response) => { return } - const { question, model, guide_model_id, include_context, byok } = parsed.data + const { question, model, guide_model_id, include_context, lens: lensOpt, byok } = parsed.data + + // Mirrors `guideCommand`'s `withLens()` in src/cli/commands/guide.ts: a + // structural/hybrid lens appends a hint to the question so the agent loop + // prefers the structural tools; semantic (default) leaves it unchanged. + const lens = parseLens(lensOpt, 'semantic') + const lensHint = lens !== 'semantic' + ? `\n\n(Lens preference: ${lens} — prefer the structural tools call_graph, blast_radius, and hotspots where relevant.)` + : '' + const questionWithLens = question + lensHint try { - const result = await runGuide(question, { + const result = await runGuide(questionWithLens, { guideModelId: guide_model_id, model, includeContext: include_context, diff --git a/src/server/routes/insights.ts b/src/server/routes/insights.ts new file mode 100644 index 0000000..f6645fd --- /dev/null +++ b/src/server/routes/insights.ts @@ -0,0 +1,396 @@ +/** + * HTTP routes for "insights" commands (Phase 148 — triage of remaining + * zero-HTTP/MCP-exposure CLI commands). + * + * Routes (under /api/v1/insights/): + * POST /bisect — semantic git bisect + * POST /refactor-candidates — near-duplicate symbol/chunk/file pairs + * POST /lifecycle — concept lifecycle stages over history + * POST /cherry-pick-suggest — commit suggestions by message similarity + * POST /file-diff — single-file semantic diff between two refs + * POST /pr-report — composite PR report (diff/impact/change-points/reviewers) + * POST /regression-gate — CI gate: per-query base/head drift check + * POST /code-review — historical analogues + regression risk for a diff + * POST /heatmap — activity heatmap by time bucket + * POST /map — cluster snapshot + blob assignments (semantic map) + * + * These commands had a CLI surface (and in most cases a `gitsema guide` tool) + * but no HTTP route or MCP tool at all before this phase — see + * docs/parity.md §1 for the full bucket (a)/(b)/(c) triage. `diff`, + * `ci-diff`, `cross-repo-similarity`, and `project` were judged redundant or + * CLI-shaped and are intentionally not exposed here. + * + * All routes here accept the CLI's `--model`/`--text-model`/`--code-model` + * override triplet via `modelOverrideSchema` + `resolveRequestProvider()`, + * same convention as `analysis.ts` (Phase 140). + */ + +import { Router } from 'express' +import { z } from 'zod' +import type { EmbeddingProvider } from '../../core/embedding/provider.js' +import type { Embedding } from '../../core/models/types.js' +import { getActiveSession } from '../../core/db/sqlite.js' +import { computeSemanticBisect } from '../../core/search/semanticBisect.js' +import { computeRefactorCandidates } from '../../core/search/refactorCandidates.js' +import { computeConceptLifecycle } from '../../core/search/conceptLifecycle.js' +import { suggestCherryPicks } from '../../core/search/cherryPick.js' +import { computeDiff } from '../../core/search/temporal/evolution.js' +import { computeSemanticDiff } from '../../core/search/semanticDiff.js' +import { computeImpact } from '../../core/search/impact.js' +import { computeConceptChangePoints } from '../../core/search/temporal/changePoints.js' +import { computeExperts } from '../../core/search/experts.js' +import { computeRegressionGate, type RegressionGateQuery } from '../../core/search/regressionGate.js' +import { parseDiff, computeCodeReview } from '../../core/search/codeReview.js' +import { getCachedStorageProfile } from '../../core/storage/resolveProfile.js' +import { parseLens } from '../../cli/lib/lens.js' +import { modelOverrideSchema, resolveRequestProvider, ModelOverrideError } from '../lib/modelOverrides.js' + +export interface InsightsRouterDeps { + textProvider: EmbeddingProvider +} + +export function insightsRouter(deps: InsightsRouterDeps): Router { + const { textProvider } = deps + const router = Router() + + // POST /insights/bisect + const BisectBodySchema = z.object({ + ...modelOverrideSchema.shape, + goodRef: z.string().min(1), + badRef: z.string().min(1), + query: z.string().min(1), + topK: z.number().int().positive().max(50).optional().default(20), + maxSteps: z.number().int().positive().max(25).optional().default(10), + }) + router.post('/bisect', async (req, res) => { + const parsed = BisectBodySchema.safeParse(req.body) + if (!parsed.success) { + res.status(400).json({ error: 'Invalid request', details: parsed.error.flatten() }) + return + } + const opts = parsed.data + let qEmb: Embedding + try { + const provider = resolveRequestProvider(opts, textProvider) + qEmb = await provider.embed(opts.query) + } catch (err) { + const status = err instanceof ModelOverrideError ? 400 : 502 + res.status(status).json({ error: `Embedding failed: ${err instanceof Error ? err.message : String(err)}` }) + return + } + try { + const result = computeSemanticBisect(qEmb, opts.query, opts.goodRef, opts.badRef, { topK: opts.topK, maxSteps: opts.maxSteps }) + res.json(result) + } catch (err) { + res.status(500).json({ error: err instanceof Error ? err.message : String(err) }) + } + }) + + // POST /insights/refactor-candidates + const RefactorCandidatesBodySchema = z.object({ + threshold: z.number().min(0).max(1).optional().default(0.88), + topK: z.number().int().positive().max(50).optional().default(50), + level: z.enum(['symbol', 'chunk', 'file']).optional().default('symbol'), + }) + router.post('/refactor-candidates', (req, res) => { + const parsed = RefactorCandidatesBodySchema.safeParse(req.body) + if (!parsed.success) { + res.status(400).json({ error: 'Invalid request', details: parsed.error.flatten() }) + return + } + try { + const report = computeRefactorCandidates(parsed.data) + res.json(report) + } catch (err) { + res.status(500).json({ error: err instanceof Error ? err.message : String(err) }) + } + }) + + // POST /insights/lifecycle + const LifecycleBodySchema = z.object({ + ...modelOverrideSchema.shape, + query: z.string().min(1), + steps: z.number().int().min(2).max(50).optional().default(10), + threshold: z.number().min(0).max(1).optional().default(0.7), + }) + router.post('/lifecycle', async (req, res) => { + const parsed = LifecycleBodySchema.safeParse(req.body) + if (!parsed.success) { + res.status(400).json({ error: 'Invalid request', details: parsed.error.flatten() }) + return + } + const opts = parsed.data + let qEmb: Embedding + try { + const provider = resolveRequestProvider(opts, textProvider) + qEmb = await provider.embed(opts.query) + } catch (err) { + const status = err instanceof ModelOverrideError ? 400 : 502 + res.status(status).json({ error: `Embedding failed: ${err instanceof Error ? err.message : String(err)}` }) + return + } + try { + const result = computeConceptLifecycle(qEmb, opts.query, { steps: opts.steps, threshold: opts.threshold }) + res.json(result) + } catch (err) { + res.status(500).json({ error: err instanceof Error ? err.message : String(err) }) + } + }) + + // POST /insights/cherry-pick-suggest + const CherryPickBodySchema = z.object({ + ...modelOverrideSchema.shape, + query: z.string().min(1), + topK: z.number().int().positive().max(25).optional().default(10), + }) + router.post('/cherry-pick-suggest', async (req, res) => { + const parsed = CherryPickBodySchema.safeParse(req.body) + if (!parsed.success) { + res.status(400).json({ error: 'Invalid request', details: parsed.error.flatten() }) + return + } + const opts = parsed.data + let qEmb: Embedding + let provider: EmbeddingProvider + try { + provider = resolveRequestProvider(opts, textProvider) + qEmb = await provider.embed(opts.query) + } catch (err) { + const status = err instanceof ModelOverrideError ? 400 : 502 + res.status(status).json({ error: `Embedding failed: ${err instanceof Error ? err.message : String(err)}` }) + return + } + try { + const results = await suggestCherryPicks(qEmb, { topK: opts.topK, model: provider.model }) + res.json({ query: opts.query, results }) + } catch (err) { + res.status(500).json({ error: err instanceof Error ? err.message : String(err) }) + } + }) + + // POST /insights/file-diff + const FileDiffBodySchema = z.object({ + ref1: z.string().min(1), + ref2: z.string().min(1), + path: z.string().min(1), + neighbors: z.number().int().min(0).max(10).optional().default(0), + }) + router.post('/file-diff', async (req, res) => { + const parsed = FileDiffBodySchema.safeParse(req.body) + if (!parsed.success) { + res.status(400).json({ error: 'Invalid request', details: parsed.error.flatten() }) + return + } + const opts = parsed.data + try { + const result = await computeDiff(opts.ref1, opts.ref2, opts.path, { neighbors: opts.neighbors }) + res.json(result) + } catch (err) { + res.status(500).json({ error: err instanceof Error ? err.message : String(err) }) + } + }) + + // POST /insights/pr-report + const PrReportBodySchema = z.object({ + ...modelOverrideSchema.shape, + ref1: z.string().optional().default('HEAD~1'), + ref2: z.string().optional().default('HEAD'), + file: z.string().optional(), + query: z.string().optional(), + top: z.number().int().positive().max(25).optional().default(10), + }) + router.post('/pr-report', async (req, res) => { + const parsed = PrReportBodySchema.safeParse(req.body) + if (!parsed.success) { + res.status(400).json({ error: 'Invalid request', details: parsed.error.flatten() }) + return + } + const opts = parsed.data + const report: Record = { ref1: opts.ref1, ref2: opts.ref2 } + + let provider: EmbeddingProvider | undefined + if (opts.file || opts.query) { + try { + provider = resolveRequestProvider(opts, textProvider) + } catch (err) { + const status = err instanceof ModelOverrideError ? 400 : 502 + res.status(status).json({ error: `Provider resolution failed: ${err instanceof Error ? err.message : String(err)}` }) + return + } + } + + if (opts.file && provider) { + try { + const emb = await provider.embed(opts.file) + const diff = computeSemanticDiff(emb, opts.file, opts.ref1, opts.ref2, opts.top) + report.semanticDiff = { gained: diff.gained.length, lost: diff.lost.length, stable: diff.stable.length } + } catch (err) { + report.semanticDiff = { error: err instanceof Error ? err.message : String(err) } + } + try { + const impactReport = await computeImpact(opts.file, provider, { topK: opts.top }) + report.impactedModules = impactReport.results.map((r) => ({ path: r.paths[0] ?? null, score: r.score })) + } catch (err) { + report.impactedModules = { error: err instanceof Error ? err.message : String(err) } + } + } + + if (opts.query && provider) { + try { + const emb = await provider.embed(opts.query) + const cpReport = computeConceptChangePoints(opts.query, emb, { topK: opts.top, topPoints: 5 }) + report.changePoints = cpReport.points.map((p) => ({ + distance: p.distance, + before: { commit: p.before.commit, date: p.before.date }, + after: { commit: p.after.commit, date: p.after.date }, + })) + } catch (err) { + report.changePoints = { error: err instanceof Error ? err.message : String(err) } + } + } + + try { + report.reviewerSuggestions = computeExperts({ topN: 5, minBlobs: 1, topClusters: 3 }) + .map((e) => ({ authorName: e.authorName, authorEmail: e.authorEmail, blobCount: e.blobCount })) + } catch (err) { + report.reviewerSuggestions = { error: err instanceof Error ? err.message : String(err) } + } + + res.json(report) + }) + + // POST /insights/regression-gate + // Mirrors the CLI's exit-code contract (0 pass / 3 gate-failed) via HTTP + // status: 200 when all concepts pass, 422 when any concept's drift exceeds + // its threshold — same convention as POST /analysis/policy-check. + const RegressionGateBodySchema = z.object({ + ...modelOverrideSchema.shape, + base: z.string().optional().default('main'), + head: z.string().optional().default('HEAD'), + queries: z.array(z.object({ + query: z.string().min(1), + threshold: z.number().min(0).max(2).optional(), + })).min(1), + threshold: z.number().min(0).max(2).optional().default(0.15), + topK: z.number().int().positive().max(50).optional().default(10), + }) + router.post('/regression-gate', async (req, res) => { + const parsed = RegressionGateBodySchema.safeParse(req.body) + if (!parsed.success) { + res.status(400).json({ error: 'Invalid request', details: parsed.error.flatten() }) + return + } + const opts = parsed.data + let provider: EmbeddingProvider + try { + provider = resolveRequestProvider(opts, textProvider) + } catch (err) { + const status = err instanceof ModelOverrideError ? 400 : 502 + res.status(status).json({ error: `Embedding failed: ${err instanceof Error ? err.message : String(err)}` }) + return + } + try { + const embeddedQueries: RegressionGateQuery[] = [] + for (const q of opts.queries) { + const emb = await provider.embed(q.query) + embeddedQueries.push({ query: q.query, embedding: emb, threshold: q.threshold ?? opts.threshold }) + } + const report = await computeRegressionGate(embeddedQueries, { baseRef: opts.base, headRef: opts.head, topK: opts.topK }) + res.status(report.allPassed ? 200 : 422).json(report) + } catch (err) { + res.status(500).json({ error: err instanceof Error ? err.message : String(err) }) + } + }) + + // POST /insights/code-review + const CodeReviewBodySchema = z.object({ + ...modelOverrideSchema.shape, + diffText: z.string().min(1), + topK: z.number().int().positive().max(25).optional().default(5), + threshold: z.number().min(0).max(1).optional().default(0.75), + lens: z.enum(['semantic', 'structural', 'hybrid']).optional().default('semantic'), + }) + router.post('/code-review', async (req, res) => { + const parsed = CodeReviewBodySchema.safeParse(req.body) + if (!parsed.success) { + res.status(400).json({ error: 'Invalid request', details: parsed.error.flatten() }) + return + } + const opts = parsed.data + const hunks = parseDiff(opts.diffText) + if (hunks.length === 0) { + res.json({ reviews: [] }) + return + } + let provider: EmbeddingProvider + try { + provider = resolveRequestProvider(opts, textProvider) + } catch (err) { + const status = err instanceof ModelOverrideError ? 400 : 502 + res.status(status).json({ error: `Embedding failed: ${err instanceof Error ? err.message : String(err)}` }) + return + } + try { + const lens = parseLens(opts.lens, 'semantic') + const graph = lens !== 'semantic' ? getCachedStorageProfile(process.cwd()).graph : undefined + const reviews = await computeCodeReview(hunks, provider, { topK: opts.topK, threshold: opts.threshold, graph }) + res.json({ reviews }) + } catch (err) { + res.status(500).json({ error: err instanceof Error ? err.message : String(err) }) + } + }) + + // POST /insights/heatmap + const HeatmapBodySchema = z.object({ + period: z.enum(['week', 'month']).optional().default('week'), + }) + router.post('/heatmap', (req, res) => { + const parsed = HeatmapBodySchema.safeParse(req.body) + if (!parsed.success) { + res.status(400).json({ error: 'Invalid request', details: parsed.error.flatten() }) + return + } + const { period } = parsed.data + try { + const { rawDb } = getActiveSession() + const fmt = period === 'month' ? `'%Y-%m'` : `'%Y-%W'` + const rows = rawDb.prepare( + `SELECT strftime(${fmt}, datetime(c.timestamp, 'unixepoch')) AS period, COUNT(DISTINCT b.blob_hash) AS cnt + FROM blob_commits b JOIN commits c ON b.commit_hash = c.commit_hash + GROUP BY period ORDER BY period`, + ).all() as Array<{ period: string; cnt: number }> + const buckets: Record = {} + for (const r of rows) buckets[r.period] = r.cnt + res.json({ period, buckets }) + } catch (err) { + res.status(500).json({ error: err instanceof Error ? err.message : String(err) }) + } + }) + + // POST /insights/map + // Serves the most recent k-means cluster snapshot + per-cluster blob- + // assignment counts — the data source behind the CLI `map` command and + // `gitsema tools serve --ui`'s cluster overlay (companion to the existing + // `GET /projections` route, Phase 55). + router.post('/map', (_req, res) => { + try { + const { rawDb } = getActiveSession() + const clusters = rawDb.prepare('SELECT id, label, size, representative_paths FROM blob_clusters').all() as Array<{ id: number; label: string; size: number; representative_paths: string | null }> + const assignmentRows = rawDb.prepare('SELECT cluster_id, COUNT(*) AS cnt FROM cluster_assignments GROUP BY cluster_id').all() as Array<{ cluster_id: number; cnt: number }> + const assignmentCounts: Record = {} + for (const r of assignmentRows) assignmentCounts[r.cluster_id] = r.cnt + const clusterList = clusters.map((c) => ({ + id: c.id, + label: c.label, + size: c.size, + representativePaths: c.representative_paths ? (JSON.parse(c.representative_paths) as string[]) : [], + assignedBlobCount: assignmentCounts[c.id] ?? 0, + })) + res.json({ clusters: clusterList }) + } catch (err) { + res.status(500).json({ error: err instanceof Error ? err.message : String(err) }) + } + }) + + return router +} diff --git a/src/server/routes/narrator.ts b/src/server/routes/narrator.ts index 647ec67..1de952d 100644 --- a/src/server/routes/narrator.ts +++ b/src/server/routes/narrator.ts @@ -6,13 +6,16 @@ * POST /explain — explain a bug/error topic by tracing through git history * * Both routes use the DB-backed narrator model config system. - * Safe-by-default: returns a placeholder when no narrator model is configured. + * Safe-by-default: returns evidence only unless `evidenceOnly: false` is sent + * explicitly (Phase 144) — mirrors the CLI's `--narrate`/`--evidence-only` + * default (no LLM/network call unless explicitly opted in). */ import { Router } from 'express' import { z } from 'zod' import { resolveNarratorProvider } from '../../core/narrator/resolveNarrator.js' import { runNarrate, runExplain } from '../../core/narrator/narrator.js' +import { parseLens } from '../../cli/lib/lens.js' export const narratorRouter = Router() @@ -33,6 +36,9 @@ const ByokSchema = z.object({ temperature: z.number().optional(), }) +/** Cross-cutting `--lens` toggle (Phase 109/111): which signal(s) drive structural enrichment. */ +const LensSchema = z.enum(['semantic', 'structural', 'hybrid']) + const NarrateBodySchema = z.object({ since: z.string().optional(), until: z.string().optional(), @@ -43,6 +49,19 @@ const NarrateBodySchema = z.object({ narratorModelId: z.number().int().positive().optional(), model: z.string().optional(), byok: ByokSchema.optional(), + /** + * Phase 144: mirrors the CLI's `--narrate`/`--evidence-only` toggle, which + * HTTP callers previously had no way to request explicitly. Default + * (omitted/`undefined`) matches `runNarrate`'s own default of `true` — + * evidence-only, no LLM call. + */ + evidenceOnly: z.boolean().optional(), + /** + * Phase 111/144: accepted for CLI flag-surface parity. `narrate` has no + * single-file target to enrich (unlike `explain`'s `--files`), so this is + * currently a no-op for `/narrate` — see Phase 144 deviation notes. + */ + lens: LensSchema.optional(), }) const ExplainBodySchema = z.object({ @@ -53,6 +72,19 @@ const ExplainBodySchema = z.object({ narratorModelId: z.number().int().positive().optional(), model: z.string().optional(), byok: ByokSchema.optional(), + /** Phase 144: same semantics as `NarrateBodySchema.evidenceOnly`. */ + evidenceOnly: z.boolean().optional(), + /** Phase 144: mirrors CLI `explain --log ` — error/stack-trace context file. */ + log: z.string().optional(), + /** Phase 144: mirrors CLI `explain --files ` — restricts search scope. */ + files: z.string().optional(), + /** + * Phase 111/144: mirrors CLI `explain --lens`. When `structural`/`hybrid` + * and `files` is a concrete path, the response includes a `structuralContext` + * field (call-graph / co-change enrichment), same as the CLI's post-run + * structural context append. + */ + lens: LensSchema.optional(), }) // --------------------------------------------------------------------------- @@ -81,6 +113,7 @@ narratorRouter.post('/narrate', async (req, res) => { focus: body.focus, format: body.format, maxCommits: body.maxCommits, + evidenceOnly: body.evidenceOnly, }) res.json({ prose: result.prose, @@ -89,6 +122,7 @@ narratorRouter.post('/narrate', async (req, res) => { redactedFields: result.redactedFields, llmEnabled: result.llmEnabled, format: result.format, + evidence: result.evidence, }) } catch (err) { const msg = err instanceof Error ? err.message : String(err) @@ -120,8 +154,32 @@ narratorRouter.post('/explain', async (req, res) => { const result = await runExplain(provider, body.topic, { since: body.since, until: body.until, + log: body.log, + files: body.files, format: body.format, + evidenceOnly: body.evidenceOnly, }) + + // Structural enrichment (Phase 110/111/144): mirrors the CLI's own + // post-run append in `explainCommand` — when a structural/hybrid lens is + // requested and a concrete `files` path is given, include grounded + // call-graph / co-change context in the response. Default `semantic` + // lens (or no `files`) adds nothing, keeping the response shape + // unchanged for existing callers. + let structuralContext: string | undefined + const lens = parseLens(body.lens, 'semantic') + if (lens !== 'semantic' && body.files) { + try { + const { getCachedStorageProfile } = await import('../../core/storage/resolveProfile.js') + const { structuralContextForPath, formatStructuralContext } = await import('../../core/graph/structuralContext.js') + const graph = getCachedStorageProfile(process.cwd()).graph + const ctx = await structuralContextForPath(graph, body.files) + structuralContext = formatStructuralContext(ctx) + } catch { + // graph unavailable — skip enrichment silently, same as the CLI + } + } + res.json({ prose: result.prose, commitCount: result.commitCount, @@ -129,6 +187,8 @@ narratorRouter.post('/explain', async (req, res) => { redactedFields: result.redactedFields, llmEnabled: result.llmEnabled, format: result.format, + evidence: result.evidence, + ...(structuralContext ? { structuralContext, lens } : {}), }) } catch (err) { const msg = err instanceof Error ? err.message : String(err) diff --git a/src/server/routes/protocol.ts b/src/server/routes/protocol.ts index 0d58f83..7aa9efa 100644 --- a/src/server/routes/protocol.ts +++ b/src/server/routes/protocol.ts @@ -23,6 +23,7 @@ import { registerWorkflowTools } from '../../mcp/tools/workflow.js' import { registerInfrastructureTools } from '../../mcp/tools/infrastructure.js' import { registerNarratorTools } from '../../mcp/tools/narrator.js' import { registerGraphTools } from '../../mcp/tools/graph.js' +import { registerInsightsTools } from '../../mcp/tools/insights.js' type McpHandlerFn = (args: unknown) => Promise @@ -43,6 +44,7 @@ function getMcpDispatch(): Map { registerInfrastructureTools(fakeServer as any) registerNarratorTools(fakeServer as any) registerGraphTools(fakeServer as any) + registerInsightsTools(fakeServer as any) _mcpDispatch = map return map } diff --git a/src/server/routes/watch.ts b/src/server/routes/watch.ts index 11c1c84..5993743 100644 --- a/src/server/routes/watch.ts +++ b/src/server/routes/watch.ts @@ -1,7 +1,9 @@ /** - * Watch routes for saved searches (Phase 53). - * POST /watch/add — save a named query - * POST /watch/run — execute all saved queries and return new matches + * Watch routes for saved searches (Phase 53; list/remove added Phase 146). + * POST /watch/add — save a named query + * POST /watch/run — execute all saved queries and return new matches + * GET /watch — list all saved queries + * DELETE /watch/:name — remove a saved query by name */ import { Router } from 'express' @@ -105,5 +107,39 @@ export function watchRouter(deps: WatchRouterDeps): Router { } }) + router.get('/', (_req, res) => { + try { + const session = getActiveSession() + const rows = session.rawDb.prepare( + `SELECT id, name, query_text, last_run_ts, webhook_url FROM saved_queries ORDER BY created_at DESC`, + ).all() as Array<{ id: number; name: string; query_text: string; last_run_ts: number | null; webhook_url: string | null }> + const watches = rows.map((r) => ({ + id: r.id, + name: r.name, + query: r.query_text, + lastRunAt: r.last_run_ts, + webhookUrl: r.webhook_url, + })) + res.json({ watches }) + } catch (err) { + res.status(500).json({ error: err instanceof Error ? err.message : String(err) }) + } + }) + + router.delete('/:name', (req, res) => { + const { name } = req.params + try { + const session = getActiveSession() + const result = session.rawDb.prepare(`DELETE FROM saved_queries WHERE name = ?`).run(name) + if (result.changes === 0) { + res.status(404).json({ error: `No saved query found with name '${name}'` }) + return + } + res.json({ ok: true, name }) + } catch (err) { + res.status(500).json({ error: err instanceof Error ? err.message : String(err) }) + } + }) + return router } diff --git a/tests/evolutionBranch.test.ts b/tests/evolutionBranch.test.ts index 087cdba..8f533ae 100644 --- a/tests/evolutionBranch.test.ts +++ b/tests/evolutionBranch.test.ts @@ -35,6 +35,7 @@ describe('computeEvolution branch filter', () => { afterEach(() => { __setDefaultSessionForTesting(undefined) + session?.rawDb.close() if (tmpDir) rmSync(tmpDir, { recursive: true, force: true }) session = undefined tmpDir = undefined diff --git a/tests/insightsRoutes.test.ts b/tests/insightsRoutes.test.ts new file mode 100644 index 0000000..e6cbdb9 --- /dev/null +++ b/tests/insightsRoutes.test.ts @@ -0,0 +1,183 @@ +/** + * HTTP integration tests for the Phase 148 `/api/v1/insights/*` routes — + * new routes for CLI commands (`bisect`, `refactor-candidates`, `lifecycle`, + * `cherry-pick-suggest`, `file-diff`, `pr-report`, `regression-gate`, + * `code-review`, `heatmap`, `map`) that previously had no HTTP route or MCP + * tool at all. + * + * Kept in a dedicated file (rather than appended to tests/serverRoutes.test.ts) + * to avoid merge conflicts with other phases landing HTTP route tests in + * parallel. Same setup pattern: a real Express app (createApp) backed by an + * in-memory SQLite DB and a deterministic mock embedding provider. + */ + +import { describe, it, expect, beforeAll } from 'vitest' +import request from 'supertest' +import { vi } from 'vitest' + +vi.mock('../src/core/db/sqlite.js', async (importOriginal) => { + const actual = await importOriginal() + const session = actual.openDatabaseAt(':memory:') + return { + ...actual, + getActiveSession: () => session, + db: session.db, + } +}) + +import { createApp } from '../src/server/app.js' +import type { EmbeddingProvider } from '../src/core/embedding/provider.js' + +const MOCK_VEC = [0.1, 0.2, 0.3, 0.4] +const mockProvider: EmbeddingProvider = { + model: 'mock', + embed: async () => [...MOCK_VEC], + embedBatch: async (texts: string[]) => texts.map(() => [...MOCK_VEC]), + dimensions: 4, +} + +let app: ReturnType + +beforeAll(async () => { + app = createApp({ textProvider: mockProvider }) +}) + +describe('POST /api/v1/insights/bisect', () => { + it('returns 200 with a bisect report on an empty DB', async () => { + const res = await request(app) + .post('/api/v1/insights/bisect') + // A date string (not a branch name) for goodRef: resolveRefToTimestamp() + // tries Date-parsing before falling back to `git log`, so this resolves + // without depending on a local `main` branch ref — CI checkouts are a + // shallow, detached-HEAD checkout of just the PR commit, with no other + // branch refs available locally. + .send({ goodRef: '2020-01-01', badRef: 'HEAD', query: 'authentication flow' }) + expect(res.status).toBe(200) + expect(res.body).toHaveProperty('query', 'authentication flow') + expect(Array.isArray(res.body.steps)).toBe(true) + }) + + it('returns 400 on missing required fields', async () => { + const res = await request(app).post('/api/v1/insights/bisect').send({}) + expect(res.status).toBe(400) + }) +}) + +describe('POST /api/v1/insights/refactor-candidates', () => { + it('returns 200 with an empty report on an empty DB', async () => { + const res = await request(app).post('/api/v1/insights/refactor-candidates').send({}) + expect(res.status).toBe(200) + expect(res.body.pairs).toEqual([]) + }) +}) + +describe('POST /api/v1/insights/lifecycle', () => { + it('returns 200 with a lifecycle report on an empty DB', async () => { + const res = await request(app) + .post('/api/v1/insights/lifecycle') + .send({ query: 'authentication flow' }) + expect(res.status).toBe(200) + expect(res.body).toHaveProperty('query', 'authentication flow') + expect(res.body).toHaveProperty('currentStage') + }) + + it('returns 400 without a query', async () => { + const res = await request(app).post('/api/v1/insights/lifecycle').send({}) + expect(res.status).toBe(400) + }) +}) + +describe('POST /api/v1/insights/cherry-pick-suggest', () => { + it('returns 200 with an empty results array on an empty DB', async () => { + const res = await request(app) + .post('/api/v1/insights/cherry-pick-suggest') + .send({ query: 'fix login bug' }) + expect(res.status).toBe(200) + expect(res.body.results).toEqual([]) + }) +}) + +describe('POST /api/v1/insights/file-diff', () => { + it('returns 200 with null blob hashes for a nonexistent path', async () => { + const res = await request(app) + .post('/api/v1/insights/file-diff') + .send({ ref1: 'HEAD', ref2: 'HEAD', path: 'definitely-not-a-real-file-xyz.ts' }) + expect(res.status).toBe(200) + expect(res.body).toHaveProperty('cosineDistance', null) + }) + + it('returns 400 on missing fields', async () => { + const res = await request(app).post('/api/v1/insights/file-diff').send({ ref1: 'HEAD' }) + expect(res.status).toBe(400) + }) +}) + +describe('POST /api/v1/insights/pr-report', () => { + it('returns 200 with a reviewerSuggestions section on an empty DB', async () => { + const res = await request(app).post('/api/v1/insights/pr-report').send({}) + expect(res.status).toBe(200) + expect(res.body).toHaveProperty('ref1', 'HEAD~1') + expect(res.body).toHaveProperty('reviewerSuggestions') + }) +}) + +describe('POST /api/v1/insights/regression-gate', () => { + it('returns 200 (all passed) for a zero-drift empty DB', async () => { + const res = await request(app) + .post('/api/v1/insights/regression-gate') + .send({ queries: [{ query: 'authentication flow' }] }) + expect(res.status).toBe(200) + expect(res.body.allPassed).toBe(true) + }) + + // The "gate failed" (422) path needs a genuine base/head score delta, + // which requires seeded, branch-tagged embeddings — exercised at the core + // function level instead (see computeRegressionGate tests in + // tests/insightsTools.test.ts, which pass an out-of-schema-range threshold + // directly to force `passed: false` without needing indexed data). + it('rejects an out-of-range top-level threshold with 400', async () => { + const res = await request(app) + .post('/api/v1/insights/regression-gate') + .send({ queries: [{ query: 'authentication flow' }], threshold: -1 }) + expect(res.status).toBe(400) + }) + + it('returns 400 on an empty queries array', async () => { + const res = await request(app).post('/api/v1/insights/regression-gate').send({ queries: [] }) + expect(res.status).toBe(400) + }) +}) + +describe('POST /api/v1/insights/code-review', () => { + it('returns 200 with a reviews array for a simple diff', async () => { + const diffText = 'diff --git a/foo.ts b/foo.ts\n+function hello() {}\n' + const res = await request(app) + .post('/api/v1/insights/code-review') + .send({ diffText }) + expect(res.status).toBe(200) + expect(Array.isArray(res.body.reviews)).toBe(true) + expect(res.body.reviews[0]).toHaveProperty('regressionRisk', 'low') + }) + + it('returns 400 on missing diffText', async () => { + const res = await request(app).post('/api/v1/insights/code-review').send({}) + expect(res.status).toBe(400) + }) +}) + +describe('POST /api/v1/insights/heatmap', () => { + it('returns 200 with an empty buckets object on an empty DB', async () => { + const res = await request(app).post('/api/v1/insights/heatmap').send({}) + expect(res.status).toBe(200) + expect(res.body).toHaveProperty('period', 'week') + expect(res.body.buckets).toEqual({}) + }) +}) + +describe('POST /api/v1/insights/map', () => { + it('returns 200 with an empty clusters array on an empty DB', async () => { + const res = await request(app).post('/api/v1/insights/map').send({}) + expect(res.status).toBe(200) + expect(res.body.clusters).toEqual([]) + }) +}) diff --git a/tests/insightsTools.test.ts b/tests/insightsTools.test.ts new file mode 100644 index 0000000..fd25d2d --- /dev/null +++ b/tests/insightsTools.test.ts @@ -0,0 +1,186 @@ +/** + * Tests for Phase 148 "insights" core functions — the shared implementations + * behind the CLI `regression-gate`/`code-review` commands, the new + * `regression_gate`/`code_review`/`semantic_bisect`/`refactor_candidates`/ + * `concept_lifecycle`/`cherry_pick_suggest`/`file_diff` MCP tools, and the + * new `POST /insights/*` HTTP routes. + * + * Uses a real in-memory SQLite DB and a mock embedding provider, following + * the same pattern as tests/mcpTools.test.ts. + */ + +import { describe, it, expect, vi } from 'vitest' + +vi.mock('../src/core/db/sqlite.js', async (importOriginal) => { + const actual = await importOriginal() + const inMemorySession = actual.openDatabaseAt(':memory:') + return { + ...actual, + getActiveSession: () => inMemorySession, + db: inMemorySession.db, + } +}) + +const MOCK_VEC = [0.1, 0.2, 0.3, 0.4] +vi.mock('../src/core/embedding/providerFactory.js', async (importOriginal) => { + const actual = await importOriginal() + const mockProvider = { + model: 'mock', + embed: async () => [...MOCK_VEC], + embedBatch: async (texts: string[]) => texts.map(() => [...MOCK_VEC]), + dimensions: 4, + } + return { + ...actual, + getTextProvider: () => mockProvider, + getCodeProvider: () => undefined, + buildProvider: () => mockProvider, + } +}) + +import { computeRegressionGate, type RegressionGateQuery } from '../src/core/search/regressionGate.js' +import { parseDiff, computeCodeReview } from '../src/core/search/codeReview.js' +import { computeSemanticBisect } from '../src/core/search/semanticBisect.js' +import { computeRefactorCandidates } from '../src/core/search/refactorCandidates.js' +import { computeConceptLifecycle } from '../src/core/search/conceptLifecycle.js' +import { suggestCherryPicks } from '../src/core/search/cherryPick.js' + +const mockProvider = { + model: 'mock', + embed: async (_text: string) => [...MOCK_VEC], + embedBatch: async (texts: string[]) => texts.map(() => [...MOCK_VEC]), + dimensions: 4, +} + +// =========================================================================== +// computeRegressionGate +// =========================================================================== +describe('computeRegressionGate', () => { + it('passes with zero drift on an empty DB (both base/head scores are 0)', async () => { + const queries: RegressionGateQuery[] = [ + { query: 'authentication flow', embedding: MOCK_VEC, threshold: 0.15 }, + ] + const report = await computeRegressionGate(queries, { baseRef: 'main', headRef: 'HEAD', topK: 10 }) + expect(report.allPassed).toBe(true) + expect(report.results).toHaveLength(1) + expect(report.results[0]).toMatchObject({ query: 'authentication flow', baseScore: 0, headScore: 0, drift: 0, passed: true }) + expect(report.baseRef).toBe('main') + expect(report.headRef).toBe('HEAD') + }) + + it('evaluates multiple queries independently with their own thresholds', async () => { + const queries: RegressionGateQuery[] = [ + { query: 'a', embedding: MOCK_VEC, threshold: 0.1 }, + { query: 'b', embedding: MOCK_VEC, threshold: 0.5 }, + ] + const report = await computeRegressionGate(queries, { baseRef: 'main', headRef: 'HEAD' }) + expect(report.results).toHaveLength(2) + expect(report.results.map((r) => r.query)).toEqual(['a', 'b']) + expect(report.allPassed).toBe(true) + }) + + it('reports allPassed=false when any result fails', async () => { + // Empty DB always yields 0/0 scores (drift 0), so to exercise the + // "failed" branch we pass a negative threshold, which no non-negative + // drift can satisfy. + const queries: RegressionGateQuery[] = [ + { query: 'x', embedding: MOCK_VEC, threshold: -1 }, + ] + const report = await computeRegressionGate(queries, { baseRef: 'main', headRef: 'HEAD' }) + expect(report.results[0].passed).toBe(false) + expect(report.allPassed).toBe(false) + }) +}) + +// =========================================================================== +// parseDiff +// =========================================================================== +describe('parseDiff', () => { + it('parses a unified diff into per-file hunks with added/removed lines', () => { + const diffText = [ + 'diff --git a/src/foo.ts b/src/foo.ts', + '--- a/src/foo.ts', + '+++ b/src/foo.ts', + '@@ -1,3 +1,3 @@', + '-const x = 1', + '+const x = 2', + ' const y = 3', + ].join('\n') + const hunks = parseDiff(diffText) + expect(hunks).toHaveLength(1) + expect(hunks[0].file).toBe('src/foo.ts') + expect(hunks[0].addedLines).toEqual(['const x = 2']) + expect(hunks[0].removedLines).toEqual(['const x = 1']) + }) + + it('returns an empty array for empty input', () => { + expect(parseDiff('')).toEqual([]) + }) + + it('handles multiple files in one diff', () => { + const diffText = [ + 'diff --git a/a.ts b/a.ts', + '+added in a', + 'diff --git a/b.ts b/b.ts', + '-removed from b', + ].join('\n') + const hunks = parseDiff(diffText) + expect(hunks).toHaveLength(2) + expect(hunks[0].file).toBe('a.ts') + expect(hunks[1].file).toBe('b.ts') + }) +}) + +// =========================================================================== +// computeCodeReview +// =========================================================================== +describe('computeCodeReview', () => { + it('returns an empty analogues list on an empty DB (low regression risk)', async () => { + const hunks = parseDiff('diff --git a/foo.ts b/foo.ts\n+function hello() {}\n') + const reviews = await computeCodeReview(hunks, mockProvider, { topK: 5, threshold: 0.75 }) + expect(reviews).toHaveLength(1) + expect(reviews[0].file).toBe('foo.ts') + expect(reviews[0].analogues).toEqual([]) + expect(reviews[0].regressionRisk).toBe('low') + }) + + it('skips hunks with no added or removed lines', async () => { + const hunks = [{ file: 'empty.ts', addedLines: [], removedLines: [] }] + const reviews = await computeCodeReview(hunks, mockProvider) + expect(reviews).toEqual([]) + }) +}) + +// =========================================================================== +// Sanity checks for pre-existing core functions newly wrapped by MCP/HTTP +// (Phase 148) — confirm they still run cleanly against an empty DB. +// =========================================================================== +describe('insights core functions — empty-DB sanity', () => { + it('computeSemanticBisect returns a report shape on an empty DB', () => { + // A date string (not a branch name) for goodRef: resolveRefToTimestamp() + // tries Date-parsing before falling back to `git log`, so this resolves + // without depending on a local `main` branch ref — CI checkouts are a + // shallow, detached-HEAD checkout of just the PR commit, with no other + // branch refs available locally. + const result = computeSemanticBisect(MOCK_VEC, 'auth flow', '2020-01-01', 'HEAD', { topK: 5, maxSteps: 3 }) + expect(result.query).toBe('auth flow') + expect(Array.isArray(result.steps)).toBe(true) + }) + + it('computeRefactorCandidates returns an empty report on an empty DB', () => { + const report = computeRefactorCandidates({ threshold: 0.88, topK: 10, level: 'file' }) + expect(report.pairs).toEqual([]) + expect(report.totalScanned).toBe(0) + }) + + it('computeConceptLifecycle returns a report shape on an empty DB', () => { + const result = computeConceptLifecycle(MOCK_VEC, 'auth flow', { steps: 5, threshold: 0.7 }) + expect(result.query).toBe('auth flow') + expect(Array.isArray(result.points)).toBe(true) + }) + + it('suggestCherryPicks returns an empty array on an empty DB', async () => { + const results = await suggestCherryPicks(MOCK_VEC, { topK: 5, model: 'mock' }) + expect(results).toEqual([]) + }) +}) diff --git a/tests/semanticDiff.test.ts b/tests/semanticDiff.test.ts index c268ca4..936ebac 100644 --- a/tests/semanticDiff.test.ts +++ b/tests/semanticDiff.test.ts @@ -1,8 +1,16 @@ import { describe, it, expect } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { openDatabaseAt, withDbSession } from '../src/core/db/sqlite.js' import { computeSemanticDiff } from '../src/core/search/semanticDiff.js' import type { SemanticDiffResult } from '../src/core/search/semanticDiff.js' import { cosineSimilarity } from '../src/core/search/analysis/vectorSearch.js' +function bufFromArray(arr: number[]) { + return Buffer.from(new Float32Array(arr).buffer) +} + // --------------------------------------------------------------------------- // cosineSimilarity (used internally by computeSemanticDiff) // --------------------------------------------------------------------------- @@ -75,3 +83,71 @@ describe('computeSemanticDiff — logic guards (no DB required)', () => { expect(typeof computeSemanticDiff).toBe('function') }) }) + +// --------------------------------------------------------------------------- +// computeSemanticDiff — candidateBlobs (Phase 143: --hybrid support) +// --------------------------------------------------------------------------- + +describe('computeSemanticDiff — candidateBlobs (hybrid scoring)', () => { + it('uses candidateBlobs scores instead of cosine similarity when supplied', async () => { + const tmpDir = mkdtempSync(join(tmpdir(), 'gitsema-semanticdiff-')) + const dbPath = join(tmpDir, 'test.db') + const session = openDatabaseAt(dbPath) + + try { + // blobGained: introduced after ref1, present at ref2 → "gained" + session.rawDb.prepare('INSERT OR IGNORE INTO blobs (blob_hash, size, indexed_at) VALUES (?, ?, ?)').run('blobGained', 10, 1) + session.rawDb.prepare('INSERT INTO paths (blob_hash, path) VALUES (?, ?)').run('blobGained', 'src/new.ts') + session.rawDb.prepare('INSERT INTO embeddings (blob_hash, model, dimensions, vector, file_type) VALUES (?, ?, ?, ?, ?)') + .run('blobGained', 'm', 4, bufFromArray([0, 1, 0, 0]), 'code') + session.rawDb.prepare("INSERT INTO commits (commit_hash, timestamp, message) VALUES (?, ?, ?)") + .run('commit2', Math.floor(new Date('2023-06-01').getTime() / 1000), 'add new.ts') + session.rawDb.prepare('INSERT INTO blob_commits (blob_hash, commit_hash) VALUES (?, ?)').run('blobGained', 'commit2') + + const queryEmbedding = [1, 0, 0, 0] + + const result = await withDbSession(session, async () => + computeSemanticDiff(queryEmbedding, 'test topic', '2020-01-01', '2024-01-01', 10, undefined, [ + { blobHash: 'blobGained', score: 0.42 }, + ]), + ) + + expect(result.gained.length).toBe(1) + // Cosine similarity between [1,0,0,0] and [0,1,0,0] is 0 — if the + // candidate score weren't used, score would be 0, not 0.42. + expect(result.gained[0].score).toBe(0.42) + expect(result.gained[0].blobHash).toBe('blobGained') + } finally { + session.rawDb.close() + rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + it('falls back to cosine similarity when candidateBlobs is not supplied', async () => { + const tmpDir = mkdtempSync(join(tmpdir(), 'gitsema-semanticdiff-')) + const dbPath = join(tmpDir, 'test.db') + const session = openDatabaseAt(dbPath) + + try { + session.rawDb.prepare('INSERT OR IGNORE INTO blobs (blob_hash, size, indexed_at) VALUES (?, ?, ?)').run('blobGained', 10, 1) + session.rawDb.prepare('INSERT INTO paths (blob_hash, path) VALUES (?, ?)').run('blobGained', 'src/new.ts') + session.rawDb.prepare('INSERT INTO embeddings (blob_hash, model, dimensions, vector, file_type) VALUES (?, ?, ?, ?, ?)') + .run('blobGained', 'm', 4, bufFromArray([1, 0, 0, 0]), 'code') + session.rawDb.prepare("INSERT INTO commits (commit_hash, timestamp, message) VALUES (?, ?, ?)") + .run('commit2', Math.floor(new Date('2023-06-01').getTime() / 1000), 'add new.ts') + session.rawDb.prepare('INSERT INTO blob_commits (blob_hash, commit_hash) VALUES (?, ?)').run('blobGained', 'commit2') + + const queryEmbedding = [1, 0, 0, 0] + + const result = await withDbSession(session, async () => + computeSemanticDiff(queryEmbedding, 'test topic', '2020-01-01', '2024-01-01', 10), + ) + + expect(result.gained.length).toBe(1) + expect(result.gained[0].score).toBeCloseTo(1) + } finally { + session.rawDb.close() + rmSync(tmpDir, { recursive: true, force: true }) + } + }) +}) diff --git a/tests/serverGuideRoute.test.ts b/tests/serverGuideRoute.test.ts new file mode 100644 index 0000000..528eb42 --- /dev/null +++ b/tests/serverGuideRoute.test.ts @@ -0,0 +1,104 @@ +/** + * HTTP integration tests for POST /api/v1/guide/chat, focused on the Phase + * 145 `lens` schema addition — verifies the route accepts `lens`, validates + * its enum, and forwards a lens-hint-suffixed question to `runGuide` that + * matches CLI `guide --lens`'s `withLens()` behavior byte-for-byte (see + * src/cli/commands/guide.ts and src/server/routes/guide.ts). + */ + +import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest' +import request from 'supertest' + +// Mock sqlite DB module to return an in-memory session for all imports. +vi.mock('../src/core/db/sqlite.js', async (importOriginal) => { + const actual = await importOriginal() + const session = actual.openDatabaseAt(':memory:') + return { + ...actual, + getActiveSession: () => session, + db: session.db, + } +}) + +// Mock runGuide so we can assert exactly what question string the route +// forwards to it, without needing a real narrator/guide model configured. +const runGuideMock = vi.fn(async (question: string) => ({ + answer: `echo: ${question}`, + contextUsed: false, + llmEnabled: false, +})) + +vi.mock('../src/cli/commands/guide.js', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + runGuide: (...args: Parameters) => runGuideMock(...args), + } +}) + +import { createApp } from '../src/server/app.js' +import type { EmbeddingProvider } from '../src/core/embedding/provider.js' + +const MOCK_VEC = [0.1, 0.2, 0.3, 0.4] +const mockProvider: EmbeddingProvider = { + model: 'mock', + embed: async () => [...MOCK_VEC], + embedBatch: async (texts: string[]) => texts.map(() => [...MOCK_VEC]), + dimensions: 4, +} + +let app: ReturnType + +beforeAll(() => { + app = createApp({ textProvider: mockProvider }) +}) + +afterEach(() => { + runGuideMock.mockClear() + delete process.env.GITSEMA_SERVE_KEY +}) + +describe('POST /api/v1/guide/chat — lens (Phase 145)', () => { + it('defaults to semantic lens: question forwarded unchanged', async () => { + const res = await request(app) + .post('/api/v1/guide/chat') + .send({ question: 'what does the indexer do?' }) + + expect(res.status).toBe(200) + expect(runGuideMock).toHaveBeenCalledTimes(1) + const [forwardedQuestion] = runGuideMock.mock.calls[0]! + expect(forwardedQuestion).toBe('what does the indexer do?') + }) + + it('structural lens appends the same hint suffix as the CLI', async () => { + const res = await request(app) + .post('/api/v1/guide/chat') + .send({ question: 'what calls foo()?', lens: 'structural' }) + + expect(res.status).toBe(200) + const [forwardedQuestion] = runGuideMock.mock.calls[0]! + expect(forwardedQuestion).toBe( + 'what calls foo()?\n\n(Lens preference: structural — prefer the structural tools call_graph, blast_radius, and hotspots where relevant.)', + ) + }) + + it('hybrid lens appends the hybrid hint suffix', async () => { + await request(app) + .post('/api/v1/guide/chat') + .send({ question: 'blast radius of auth.ts', lens: 'hybrid' }) + + const [forwardedQuestion] = runGuideMock.mock.calls[0]! + expect(forwardedQuestion).toBe( + 'blast radius of auth.ts\n\n(Lens preference: hybrid — prefer the structural tools call_graph, blast_radius, and hotspots where relevant.)', + ) + }) + + it('rejects an invalid lens value with 400', async () => { + const res = await request(app) + .post('/api/v1/guide/chat') + .send({ question: 'hi', lens: 'nonsense' }) + + expect(res.status).toBe(400) + expect(runGuideMock).not.toHaveBeenCalled() + }) +}) diff --git a/tests/serverRoutes.test.ts b/tests/serverRoutes.test.ts index 94dac87..65793a6 100644 --- a/tests/serverRoutes.test.ts +++ b/tests/serverRoutes.test.ts @@ -732,6 +732,18 @@ describe('POST /api/v1/analysis/security-scan', () => { expect(res.status).toBe(200) expect(res.body.disclaimer).toMatch(/not confirmed/i) }) + + // Phase 143: --high-confidence-only flag parity + it('accepts highConfidenceOnly and returns only high-confidence findings', async () => { + const res = await request(app) + .post('/api/v1/analysis/security-scan') + .send({ highConfidenceOnly: true }) + expect(res.status).toBe(200) + expect(Array.isArray(res.body.findings)).toBe(true) + for (const f of res.body.findings) { + expect(f.confidence).toBe('high') + } + }) }) // =========================================================================== @@ -816,6 +828,21 @@ describe('POST /api/v1/analysis/clusters', () => { .send({ k: 0 }) expect(res.status).toBe(400) }) + + // Phase 143: iterations/edgeThreshold/enhancedKeywordsN flag parity + it('accepts iterations, edgeThreshold, and enhancedKeywordsN', async () => { + const res = await request(app) + .post('/api/v1/analysis/clusters') + .send({ k: 3, iterations: 5, edgeThreshold: 0.5, useEnhancedLabels: true, enhancedKeywordsN: 8 }) + expect([200, 500]).toContain(res.status) + }) + + it('returns 400 for invalid iterations', async () => { + const res = await request(app) + .post('/api/v1/analysis/clusters') + .send({ iterations: 'bad' }) + expect(res.status).toBe(400) + }) }) // =========================================================================== @@ -942,6 +969,122 @@ describe('POST /api/v1/analysis/impact', () => { // May 500 because there's no git repo; should not be 400 expect([200, 500]).toContain(res.status) }) + + // Phase 143: chunks/level flag parity + it('accepts chunks and level (chunk/symbol) options', async () => { + const res = await request(app) + .post('/api/v1/analysis/impact') + .send({ file: 'src/index.ts', chunks: true, level: 'symbol' }) + expect([200, 500]).toContain(res.status) + }) + + it('returns 400 for an invalid level enum value', async () => { + const res = await request(app) + .post('/api/v1/analysis/impact') + .send({ file: 'src/index.ts', level: 'not-a-real-level' }) + expect(res.status).toBe(400) + }) + + // Phase 143: --lens structural|hybrid makes impact a blast-radius alias + it('accepts lens=structural and returns a blast-radius-shaped result', async () => { + const res = await request(app) + .post('/api/v1/analysis/impact') + .send({ file: 'src/index.ts', lens: 'structural' }) + expect([200, 500]).toContain(res.status) + if (res.status === 200) { + expect(res.body).toHaveProperty('resolved') + expect(res.body).toHaveProperty('lens', 'structural') + } + }) + + it('accepts lens=hybrid', async () => { + const res = await request(app) + .post('/api/v1/analysis/impact') + .send({ file: 'src/index.ts', lens: 'hybrid' }) + expect([200, 500]).toContain(res.status) + }) + + it('returns 400 for an invalid lens value', async () => { + const res = await request(app) + .post('/api/v1/analysis/impact') + .send({ file: 'src/index.ts', lens: 'not-a-real-lens' }) + expect(res.status).toBe(400) + }) +}) + +// =========================================================================== +// POST /api/v1/analysis/merge-audit (Phase 143: --base flag parity) +// =========================================================================== +describe('POST /api/v1/analysis/merge-audit', () => { + it('returns 400 for missing branchA/branchB', async () => { + const res = await request(app) + .post('/api/v1/analysis/merge-audit') + .send({}) + expect(res.status).toBe(400) + }) + + it('returns 200 or 500 for a valid request without --base', async () => { + const res = await request(app) + .post('/api/v1/analysis/merge-audit') + .send({ branchA: 'main', branchB: 'main' }) + expect([200, 500]).toContain(res.status) + }) + + it('accepts a --base override and returns 200 or 500', async () => { + const res = await request(app) + .post('/api/v1/analysis/merge-audit') + .send({ branchA: 'main', branchB: 'main', base: 'HEAD' }) + expect([200, 500]).toContain(res.status) + }) +}) + +// =========================================================================== +// POST /api/v1/analysis/merge-preview (Phase 143: top/iterations/edgeThreshold/enhancedKeywordsN) +// =========================================================================== +describe('POST /api/v1/analysis/merge-preview', () => { + it('returns 400 for missing branch', async () => { + const res = await request(app) + .post('/api/v1/analysis/merge-preview') + .send({}) + expect(res.status).toBe(400) + }) + + it('returns 200 or 500 for a valid request with the new flags', async () => { + const res = await request(app) + .post('/api/v1/analysis/merge-preview') + .send({ + branch: 'main', + into: 'main', + top: 3, + iterations: 5, + edgeThreshold: 0.5, + useEnhancedLabels: true, + enhancedKeywordsN: 8, + }) + expect([200, 500]).toContain(res.status) + }) +}) + +// =========================================================================== +// POST /api/v1/analysis/branch-summary (Phase 143: enhancedLabels/enhancedKeywordsN) +// =========================================================================== +describe('POST /api/v1/analysis/branch-summary', () => { + it('returns 400 for missing branch', async () => { + const res = await request(app) + .post('/api/v1/analysis/branch-summary') + .send({}) + expect(res.status).toBe(400) + }) + + it('returns 200 or 500 for a valid request with enhancedLabels/enhancedKeywordsN', async () => { + const res = await request(app) + .post('/api/v1/analysis/branch-summary') + .send({ branch: 'main', baseBranch: 'main', enhancedLabels: true, enhancedKeywordsN: 8 }) + expect([200, 500]).toContain(res.status) + if (res.status === 200) { + expect(Array.isArray(res.body.nearestConcepts)).toBe(true) + } + }) }) // =========================================================================== @@ -989,6 +1132,21 @@ describe('POST /api/v1/analysis/semantic-diff', () => { .send({ ref1: 'abc123', ref2: 'def456', query: 'authentication' }) expect([200, 500]).toContain(res.status) }) + + // Phase 143: hybrid/bm25Weight flag parity + it('accepts hybrid + bm25Weight without error', async () => { + const res = await request(app) + .post('/api/v1/analysis/semantic-diff') + .send({ ref1: 'abc123', ref2: 'def456', query: 'authentication', hybrid: true, bm25Weight: 0.5 }) + expect([200, 500]).toContain(res.status) + }) + + it('returns 400 for an out-of-range bm25Weight', async () => { + const res = await request(app) + .post('/api/v1/analysis/semantic-diff') + .send({ ref1: 'abc123', ref2: 'def456', query: 'authentication', bm25Weight: 5 }) + expect(res.status).toBe(400) + }) }) // =========================================================================== @@ -1016,6 +1174,22 @@ describe('POST /api/v1/analysis/semantic-blame', () => { expect(res.status).toBe(200) expect(Array.isArray(res.body)).toBe(true) }) + + // Phase 143: --level (file/symbol) flag parity + it('accepts level=symbol and returns array', async () => { + const res = await request(app) + .post('/api/v1/analysis/semantic-blame') + .send({ filePath: 'src/index.ts', content: 'export function auth() {}', level: 'symbol' }) + expect(res.status).toBe(200) + expect(Array.isArray(res.body)).toBe(true) + }) + + it('returns 400 for an invalid level enum value', async () => { + const res = await request(app) + .post('/api/v1/analysis/semantic-blame') + .send({ filePath: 'src/index.ts', content: 'x', level: 'not-a-real-level' }) + expect(res.status).toBe(400) + }) }) // =========================================================================== @@ -1161,6 +1335,100 @@ describe('POST /api/v1/analysis/workflow', () => { .send({ template: 'incident' }) expect(res.status).toBe(400) }) + + // Phase 142: 5 newly-exposed templates (onboarding, ownership-intel, + // arch-drift, knowledge-portal, regression-forecast) — enum was previously + // limited to pr-review/incident/release-audit. + it('returns 200 for onboarding template using --role', async () => { + const res = await request(app) + .post('/api/v1/analysis/workflow') + .send({ template: 'onboarding', role: 'auth', top: 3 }) + expect(res.status).toBe(200) + expect(res.body).toHaveProperty('template', 'onboarding') + expect(res.body.sections).toHaveProperty('relevantBlobs') + expect(res.body.sections).toHaveProperty('changePoints') + expect(res.body.sections).toHaveProperty('keyExperts') + }) + + it('returns 200 for onboarding template with no role/query (defaults)', async () => { + const res = await request(app) + .post('/api/v1/analysis/workflow') + .send({ template: 'onboarding' }) + expect(res.status).toBe(200) + expect(res.body).toHaveProperty('template', 'onboarding') + }) + + it('returns 200 for ownership-intel template with query', async () => { + const res = await request(app) + .post('/api/v1/analysis/workflow') + .send({ template: 'ownership-intel', query: 'database crash', top: 3 }) + expect(res.status).toBe(200) + expect(res.body).toHaveProperty('template', 'ownership-intel') + expect(res.body.sections).toHaveProperty('suggestedReviewers') + expect(res.body.sections).toHaveProperty('topResults') + }) + + it('returns 400 for ownership-intel without query', async () => { + const res = await request(app) + .post('/api/v1/analysis/workflow') + .send({ template: 'ownership-intel' }) + expect(res.status).toBe(400) + }) + + it('returns 200 for arch-drift template without query', async () => { + const res = await request(app) + .post('/api/v1/analysis/workflow') + .send({ template: 'arch-drift', top: 3 }) + expect(res.status).toBe(200) + expect(res.body).toHaveProperty('template', 'arch-drift') + expect(res.body.sections).toHaveProperty('health') + expect(res.body.sections).toHaveProperty('debt') + expect(res.body.sections).toHaveProperty('changePoints') + }) + + it('returns 200 for knowledge-portal template with query', async () => { + const res = await request(app) + .post('/api/v1/analysis/workflow') + .send({ template: 'knowledge-portal', query: 'platform architecture', top: 3 }) + expect(res.status).toBe(200) + expect(res.body).toHaveProperty('template', 'knowledge-portal') + expect(res.body.sections).toHaveProperty('results') + expect(res.body.sections).toHaveProperty('relatedConcepts') + expect(res.body.sections).toHaveProperty('owners') + }) + + it('returns 400 for knowledge-portal without query', async () => { + const res = await request(app) + .post('/api/v1/analysis/workflow') + .send({ template: 'knowledge-portal' }) + expect(res.status).toBe(400) + }) + + it('returns 200 for regression-forecast template with query and ref', async () => { + const res = await request(app) + .post('/api/v1/analysis/workflow') + .send({ template: 'regression-forecast', query: 'auth refactor', ref: 'main', top: 3 }) + expect(res.status).toBe(200) + expect(res.body).toHaveProperty('template', 'regression-forecast') + expect(res.body.sections).toHaveProperty('currentNeighbourhood') + expect(res.body.sections).toHaveProperty('changePoints') + expect(res.body.sections).toHaveProperty('riskOwners') + expect(res.body.sections).toHaveProperty('baseRef', 'main') + }) + + it('returns 400 for regression-forecast without query', async () => { + const res = await request(app) + .post('/api/v1/analysis/workflow') + .send({ template: 'regression-forecast' }) + expect(res.status).toBe(400) + }) + + it('accepts base for pr-review without error (no-op, mirrors CLI dead flag)', async () => { + const res = await request(app) + .post('/api/v1/analysis/workflow') + .send({ template: 'pr-review', file: 'src/index.ts', base: 'main', top: 3 }) + expect(res.status).toBe(200) + }) }) // =========================================================================== @@ -1235,6 +1503,186 @@ describe('POST /api/v1/graph/hotspots', () => { }) }) +// =========================================================================== +// Phase 147: graph command family HTTP exposure +// +// Every route below reads from the (empty, in-memory) graph store — since no +// `gitsema graph build` has run, every lookup resolves to `not-found`. These +// tests assert 200 + the resolution-failure shape (not a 500), and that Zod +// validation rejects malformed bodies with 400 — the same contract the +// pre-existing `/hotspots` route follows. +// =========================================================================== +describe('POST /api/v1/graph/callers', () => { + it('returns 200 with a not-found resolution on an empty graph', async () => { + const res = await request(app).post('/api/v1/graph/callers').send({ symbol: 'foo' }) + expect(res.status).toBe(200) + expect(res.body).toHaveProperty('resolved') + expect(res.body.resolved.status).toBe('not-found') + expect(res.body).toHaveProperty('hits') + }) + + it('rejects a missing symbol', async () => { + const res = await request(app).post('/api/v1/graph/callers').send({}) + expect(res.status).toBe(400) + }) +}) + +describe('POST /api/v1/graph/callees', () => { + it('returns 200 with a not-found resolution on an empty graph', async () => { + const res = await request(app).post('/api/v1/graph/callees').send({ symbol: 'foo' }) + expect(res.status).toBe(200) + expect(res.body.resolved.status).toBe('not-found') + }) + + it('rejects a missing symbol', async () => { + const res = await request(app).post('/api/v1/graph/callees').send({}) + expect(res.status).toBe(400) + }) +}) + +describe('POST /api/v1/graph/neighbors', () => { + it('returns 200 with a not-found resolution on an empty graph', async () => { + const res = await request(app).post('/api/v1/graph/neighbors').send({ node: 'foo' }) + expect(res.status).toBe(200) + expect(res.body.resolved.status).toBe('not-found') + }) + + it('rejects an invalid edge type', async () => { + const res = await request(app).post('/api/v1/graph/neighbors').send({ node: 'foo', edgeTypes: ['bogus'] }) + expect(res.status).toBe(400) + }) +}) + +describe('POST /api/v1/graph/path', () => { + it('returns 200 with not-found resolutions on an empty graph', async () => { + const res = await request(app).post('/api/v1/graph/path').send({ from: 'a', to: 'b' }) + expect(res.status).toBe(200) + expect(res.body.from.status).toBe('not-found') + expect(res.body.to.status).toBe('not-found') + expect(res.body.path).toBeNull() + }) + + it('rejects a missing "to"', async () => { + const res = await request(app).post('/api/v1/graph/path').send({ from: 'a' }) + expect(res.status).toBe(400) + }) +}) + +describe('POST /api/v1/graph/relate', () => { + it('returns 200 with a not-found resolution on an empty graph', async () => { + const res = await request(app).post('/api/v1/graph/relate').send({ symbol: 'foo' }) + expect(res.status).toBe(200) + expect(res.body.resolved.status).toBe('not-found') + expect(res.body).toHaveProperty('lens', 'hybrid') + }) + + it('accepts a lens override', async () => { + const res = await request(app).post('/api/v1/graph/relate').send({ symbol: 'foo', lens: 'structural' }) + expect(res.status).toBe(200) + expect(res.body).toHaveProperty('lens', 'structural') + }) + + it('rejects a missing symbol', async () => { + const res = await request(app).post('/api/v1/graph/relate').send({}) + expect(res.status).toBe(400) + }) +}) + +describe('POST /api/v1/graph/similar', () => { + it('returns 200 with a not-found resolution on an empty graph', async () => { + const res = await request(app).post('/api/v1/graph/similar').send({ symbol: 'foo' }) + expect(res.status).toBe(200) + expect(res.body.resolved.status).toBe('not-found') + }) + + it('rejects a missing symbol', async () => { + const res = await request(app).post('/api/v1/graph/similar').send({}) + expect(res.status).toBe(400) + }) +}) + +describe('POST /api/v1/graph/unused', () => { + it('returns 200 with an empty nodes array on an empty graph', async () => { + const res = await request(app).post('/api/v1/graph/unused').send({}) + expect(res.status).toBe(200) + expect(res.body).toHaveProperty('nodes') + expect(Array.isArray(res.body.nodes)).toBe(true) + }) + + it('rejects an invalid edge type', async () => { + const res = await request(app).post('/api/v1/graph/unused').send({ edgeTypes: ['bogus'] }) + expect(res.status).toBe(400) + }) +}) + +describe('POST /api/v1/graph/cycles', () => { + it('returns 200 with an empty cycles array on an empty graph', async () => { + const res = await request(app).post('/api/v1/graph/cycles').send({}) + expect(res.status).toBe(200) + expect(res.body).toHaveProperty('cycles') + expect(Array.isArray(res.body.cycles)).toBe(true) + expect(res.body).toHaveProperty('edgeTypes', ['imports']) + }) + + it('accepts an edgeTypes override', async () => { + const res = await request(app).post('/api/v1/graph/cycles').send({ edgeTypes: ['calls'] }) + expect(res.status).toBe(200) + expect(res.body).toHaveProperty('edgeTypes', ['calls']) + }) + + it('rejects an invalid edge type', async () => { + const res = await request(app).post('/api/v1/graph/cycles').send({ edgeTypes: ['bogus'] }) + expect(res.status).toBe(400) + }) +}) + +describe('POST /api/v1/graph/deps', () => { + it('returns 200 with a not-found resolution on an empty graph', async () => { + const res = await request(app).post('/api/v1/graph/deps').send({ identifier: 'foo' }) + expect(res.status).toBe(200) + expect(res.body.resolved.status).toBe('not-found') + expect(res.body).toHaveProperty('hits') + }) + + it('rejects a missing identifier', async () => { + const res = await request(app).post('/api/v1/graph/deps').send({}) + expect(res.status).toBe(400) + }) +}) + +describe('POST /api/v1/graph/co-change', () => { + it('returns 200 with found:false on an empty graph', async () => { + const res = await request(app).post('/api/v1/graph/co-change').send({ path: 'src/foo.ts' }) + expect(res.status).toBe(200) + expect(res.body).toHaveProperty('found', false) + }) + + it('rejects a missing path', async () => { + const res = await request(app).post('/api/v1/graph/co-change').send({}) + expect(res.status).toBe(400) + }) +}) + +describe('POST /api/v1/graph/blast-radius', () => { + it('returns 200 with a not-found resolution on an empty graph', async () => { + const res = await request(app).post('/api/v1/graph/blast-radius').send({ symbol: 'foo' }) + expect(res.status).toBe(200) + expect(res.body.resolved.status).toBe('not-found') + expect(res.body).toHaveProperty('lens', 'hybrid') + }) + + it('accepts a lens override', async () => { + const res = await request(app).post('/api/v1/graph/blast-radius').send({ symbol: 'foo', lens: 'structural' }) + expect(res.status).toBe(200) + expect(res.body).toHaveProperty('lens', 'structural') + }) + + it('rejects a missing symbol', async () => { + const res = await request(app).post('/api/v1/graph/blast-radius').send({}) + expect(res.status).toBe(400) + }) +}) + // =========================================================================== // Phase 140: model-override triplet on analysis.ts routes // @@ -1449,6 +1897,167 @@ describe('POST /api/v1/protocol/:operation', () => { }) }) +// =========================================================================== +// POST /api/v1/narrate, POST /api/v1/explain — Phase 144 +// +// evidenceOnly toggle + explain's log/files/lens fields. No narrator model is +// configured in the mocked in-memory DB, so `evidenceOnly: false` still makes +// no network call (safe-by-default disabled provider) — these assertions +// focus on request parsing / response shape, not real LLM output. `since: +// '2099-01-01'` guarantees zero matching commits so results are deterministic. +// =========================================================================== +describe('POST /api/v1/narrate — Phase 144 evidenceOnly toggle', () => { + it('defaults to evidence-only (llmEnabled false, evidence array present)', async () => { + const res = await request(app) + .post('/api/v1/narrate') + .send({ since: '2099-01-01' }) + expect(res.status).toBe(200) + expect(res.body.llmEnabled).toBe(false) + expect(Array.isArray(res.body.evidence)).toBe(true) + }) + + it('accepts evidenceOnly: false explicitly without erroring (no narrator configured → still safe)', async () => { + const res = await request(app) + .post('/api/v1/narrate') + .send({ since: '2099-01-01', evidenceOnly: false }) + expect(res.status).toBe(200) + expect(res.body.llmEnabled).toBe(false) + }) + + it('accepts a lens field for CLI flag-surface parity (no-op for narrate)', async () => { + const res = await request(app) + .post('/api/v1/narrate') + .send({ since: '2099-01-01', lens: 'hybrid' }) + expect(res.status).toBe(200) + }) + + it('rejects an invalid lens value', async () => { + const res = await request(app) + .post('/api/v1/narrate') + .send({ since: '2099-01-01', lens: 'nonsense' }) + expect(res.status).toBe(400) + }) + + it('rejects a non-boolean evidenceOnly value', async () => { + const res = await request(app) + .post('/api/v1/narrate') + .send({ since: '2099-01-01', evidenceOnly: 'yes' }) + expect(res.status).toBe(400) + }) +}) + +describe('POST /api/v1/explain — Phase 144 evidenceOnly/log/files/lens', () => { + it('defaults to evidence-only (llmEnabled false, evidence array present)', async () => { + const res = await request(app) + .post('/api/v1/explain') + .send({ topic: 'xyzzythiscannotexist_98765', since: '2099-01-01' }) + expect(res.status).toBe(200) + expect(res.body.llmEnabled).toBe(false) + expect(Array.isArray(res.body.evidence)).toBe(true) + }) + + it('accepts a log path and a files glob without erroring', async () => { + const res = await request(app) + .post('/api/v1/explain') + .send({ + topic: 'xyzzythiscannotexist_98765', + since: '2099-01-01', + log: '/nonexistent/path/to.log', + files: 'src/**/*.ts', + }) + expect(res.status).toBe(200) + }) + + it('does not include structuralContext for the default semantic lens', async () => { + const res = await request(app) + .post('/api/v1/explain') + .send({ + topic: 'xyzzythiscannotexist_98765', + since: '2099-01-01', + files: 'src/server/routes/narrator.ts', + }) + expect(res.status).toBe(200) + expect(res.body).not.toHaveProperty('structuralContext') + }) + + it('accepts a structural/hybrid lens with files without erroring (empty graph → no structuralContext)', async () => { + const res = await request(app) + .post('/api/v1/explain') + .send({ + topic: 'xyzzythiscannotexist_98765', + since: '2099-01-01', + files: 'src/server/routes/narrator.ts', + lens: 'hybrid', + }) + expect(res.status).toBe(200) + // No graph data indexed in this test DB, so structuralContextForPath finds + // nothing and the field is omitted rather than throwing. + expect(res.body).not.toHaveProperty('structuralContext') + }) + + it('rejects an invalid lens value', async () => { + const res = await request(app) + .post('/api/v1/explain') + .send({ topic: 'test', lens: 'nonsense' }) + expect(res.status).toBe(400) + }) + + it('rejects a non-boolean evidenceOnly value', async () => { + const res = await request(app) + .post('/api/v1/explain') + .send({ topic: 'test', evidenceOnly: 'yes' }) + expect(res.status).toBe(400) + }) +}) + +// =========================================================================== +// /api/v1/watch (Phase 53 add/run; Phase 146 list/remove) +// =========================================================================== +describe('/api/v1/watch', () => { + it('POST /watch/add saves a named query', async () => { + const res = await request(app) + .post('/api/v1/watch/add') + .send({ name: 'watch-test-alpha', query: 'authentication middleware' }) + expect(res.status).toBe(200) + expect(res.body).toEqual({ ok: true, name: 'watch-test-alpha', query: 'authentication middleware' }) + }) + + it('GET /watch lists saved queries newest-first, including the one just added', async () => { + const res = await request(app).get('/api/v1/watch') + expect(res.status).toBe(200) + expect(Array.isArray(res.body.watches)).toBe(true) + const entry = res.body.watches.find((w: { name: string }) => w.name === 'watch-test-alpha') + expect(entry).toBeDefined() + expect(entry).toMatchObject({ name: 'watch-test-alpha', query: 'authentication middleware' }) + expect(entry).toHaveProperty('id') + expect(entry).toHaveProperty('lastRunAt') + expect(entry).toHaveProperty('webhookUrl') + }) + + it('DELETE /watch/:name removes a saved query by name', async () => { + const res = await request(app).delete('/api/v1/watch/watch-test-alpha') + expect(res.status).toBe(200) + expect(res.body).toEqual({ ok: true, name: 'watch-test-alpha' }) + + const listRes = await request(app).get('/api/v1/watch') + expect(listRes.body.watches.find((w: { name: string }) => w.name === 'watch-test-alpha')).toBeUndefined() + }) + + it('DELETE /watch/:name returns 404 for an unknown name', async () => { + const res = await request(app).delete('/api/v1/watch/watch-test-does-not-exist') + expect(res.status).toBe(404) + expect(res.body).toHaveProperty('error') + }) + + it('POST /watch/run returns an array (empty saved-queries set is fine)', async () => { + const res = await request(app) + .post('/api/v1/watch/run') + .send({}) + expect(res.status).toBe(200) + expect(Array.isArray(res.body)).toBe(true) + }) +}) + // =========================================================================== // Non-existent routes // ===========================================================================