diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5cfdc64..1453bf7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -68,6 +68,25 @@ node dist/index.js /path/to/test/project The server logs to stderr, so you can see what it's doing. +## Evaluation Harness + +Run `pnpm eval` to measure search/ranking quality against frozen fixtures. Use this before releases or after changing search/ranking/chunking logic. + +```bash +# Two codebases (defaults to bundled fixtures) +pnpm eval -- + +# Offline smoke test (no network) +pnpm eval -- tests/fixtures/codebases/eval-controlled tests/fixtures/codebases/eval-controlled \ + --fixture-a=tests/fixtures/eval-controlled.json \ + --fixture-b=tests/fixtures/eval-controlled.json \ + --skip-reindex --no-rerank +``` + +Flags: `--help`, `--fixture-a`, `--fixture-b`, `--skip-reindex`, `--no-rerank`, `--no-redact`. + +Save a report: `pnpm eval -- --skip-reindex > eval-report.txt` + ## Pull Requests - Fork, branch, make changes diff --git a/README.md b/README.md index 918d3f2..76b5956 100644 --- a/README.md +++ b/README.md @@ -16,11 +16,9 @@ Here's what codebase-context does: **Remembers across sessions** - Decisions, failures, workarounds that look wrong but exist for a reason - the battle scars that aren't in the comments. Recorded once, surfaced automatically so the agent doesn't "clean up" something you spent a week getting right. Conventional git commits (`refactor:`, `migrate:`, `fix:`) auto-extract into memory with zero effort. Stale memories decay and get flagged instead of blindly trusted. -**Checks before editing** - Before editing something, you get a decision card showing whether there's enough evidence to proceed. If a symbol has four callers (files that import or reference it) and only two appear in your search results, the card shows that coverage gap. If coverage is low, `whatWouldHelp` lists the specific searches to run before you touch anything. When code, team memories, and patterns contradict each other, it tells you to look deeper instead of guessing. +**Checks before editing** - Before editing something, you get a decision card showing whether there's enough evidence to proceed. If a symbol has four callers and only two appear in your search results, the card shows that coverage gap. If coverage is low, `whatWouldHelp` lists the specific searches to run before you touch anything. -One tool call returns all of it. Local-first - your code never leaves your machine by default. Opt into `EMBEDDING_PROVIDER=openai` for cloud speed, but then code is sent externally. - -The index auto-refreshes as you edit - a file watcher triggers incremental reindex in the background when the MCP server is running. No stale context between tool calls. +One tool call returns all of it. Local-first - your code never leaves your machine by default. ### What it looks like @@ -38,289 +36,36 @@ This is the part most tools miss: what the team is doing now, what it is moving When the agent searches with edit intent, it gets a compact decision card: confidence, whether it's safe to proceed, which patterns apply, the best example, and which files are likely to be affected. -More CLI examples: - -- `refs --symbol "ComponentStore"` for concrete static references -- `metadata` for a quick codebase overview -- Full gallery in `docs/cli.md` - -## Table of Contents - -- [Quick Start](#quick-start) -- [Multi-Project and Monorepos](#multi-project-and-monorepos) -- [Test It Yourself](#test-it-yourself) -- [Common First Commands](#common-first-commands) -- [What It Actually Does](#what-it-actually-does) -- [Evaluation Harness (`npm run eval`)](#evaluation-harness-npm-run-eval) -- [How the Search Works](#how-the-search-works) -- [Language Support](#language-support) -- [Configuration](#configuration) -- [Performance](#performance) -- [File Structure](#file-structure) -- [CLI Reference](#cli-reference) -- [What to add to your CLAUDE.md / AGENTS.md](#what-to-add-to-your-claudemd--agentsmd) -- [Links](#links) -- [License](#license) +More CLI examples in [`docs/cli.md`](./docs/cli.md). ## Quick Start -Start with the default setup: - -- Configure one `codebase-context` server with no project path. -- If a tool call later returns `selection_required`, retry with `project`. -- If you only use one repo, you can also append that repo path up front. - -### Pick the right setup - -| Situation | Recommended config | -| ------------------------------------- | ---------------------------------------------------------------------------------------------------- | -| Default setup | Run `npx -y codebase-context` with no project path | -| Single repo setup | Append one project path or set `CODEBASE_ROOT` | -| Multi-project call is still ambiguous | Retry with `project`, or keep separate server entries if your client cannot preserve project context | - -### Recommended setup - -Add it to the configuration of your AI Agent of preference: - -### Claude Code - ```bash claude mcp add codebase-context -- npx -y codebase-context ``` -### Claude Desktop - -Add to `claude_desktop_config.json`: - -```json -{ - "mcpServers": { - "codebase-context": { - "command": "npx", - "args": ["-y", "codebase-context"] - } - } -} -``` - -### VS Code (Copilot) - -Add `.vscode/mcp.json` to your project root: - -```json -{ - "servers": { - "codebase-context": { - "command": "npx", - "args": ["-y", "codebase-context"] // Or append "${workspaceFolder}" for single-project use - } - } -} -``` - -### Cursor - -Add to `.cursor/mcp.json` in your project: - -```json -{ - "mcpServers": { - "codebase-context": { - "command": "npx", - "args": ["-y", "codebase-context"] - } - } -} -``` - -### Windsurf - -Open Settings > MCP and add: - -```json -{ - "mcpServers": { - "codebase-context": { - "command": "npx", - "args": ["-y", "codebase-context"] - } - } -} -``` - -### OpenCode - -Add `opencode.json` to your project root: - -```json -{ - "$schema": "https://opencode.ai/config.json", - "mcp": { - "codebase-context": { - "type": "local", - "command": ["npx", "-y", "codebase-context"], - "enabled": true - } - } -} -``` - -OpenCode also supports interactive setup via `opencode mcp add`. - -### Codex - -```bash -codex mcp add codebase-context npx -y codebase-context -``` - -That single config entry is the intended starting point. - -### Fallback setup for single-project use - -If you only use one repo, append a project path: - -```bash -codex mcp add codebase-context npx -y codebase-context "/path/to/your/project" -``` - -Or set: - -```bash -CODEBASE_ROOT=/path/to/your/project -``` - -## Multi-Project and Monorepos - -The MCP server can serve multiple projects in one session without requiring one MCP config entry per repo. - -Three cases matter: - -| Case | What happens | -| ------------------------------------------------------------------ | ------------------------------------------------------------- | -| One project | Routing is automatic | -| Multiple projects and the client provides enough workspace context | The server can route across those projects in one MCP session | -| Multiple projects and the target is still ambiguous | The server does not guess. Use `project` explicitly | - -Important rules: - -- `project` is the explicit override when routing is ambiguous. -- `project` accepts a project root path, file path, `file://` URI, or a relative subproject path under a configured workspace such as `apps/dashboard`. -- If a client reads `codebase://context` before any project is active, the server returns a workspace overview instead of guessing. -- The server does not rely on `cwd` walk-up in MCP mode. - -Typical explicit retry in a monorepo: - -```json -{ - "name": "search_codebase", - "arguments": { - "query": "auth interceptor", - "project": "apps/dashboard" - } -} -``` - -Or target a repo directly: - -```json -{ - "name": "search_codebase", - "arguments": { - "query": "auth interceptor", - "project": "/repos/customer-portal" - } -} -``` - -Or pass a file path and let the server resolve the nearest trusted project boundary: - -```json -{ - "name": "search_codebase", - "arguments": { - "query": "auth interceptor", - "project": "/repos/monorepo/apps/dashboard/src/auth/guard.ts" - } -} -``` - -If you see `selection_required`, the server could not tell which project you meant. The response looks like this: - -```json -{ - "status": "selection_required", - "errorCode": "selection_required", - "message": "Multiple projects are available and no active project could be inferred. Retry with project.", - "nextAction": "retry_with_project", - "availableProjects": [ - { "label": "app-a", "project": "/repos/app-a", "indexStatus": "idle", "source": "root" }, - { "label": "app-b", "project": "/repos/app-b", "indexStatus": "ready", "source": "root" } - ] -} -``` - -Retry the call with `project` set to one of the listed paths. - -`codebase://context` follows the active project in the session. In unresolved multi-project sessions it returns a workspace overview. Project-scoped resources are also available via the URIs listed in that overview. - -The CLI stays intentionally simpler: it targets one repo per invocation via `CODEBASE_ROOT` or the current working directory. Multi-project discovery and routing are MCP-only features, not a second CLI session model. - -## Test It Yourself - -Build the local branch first: - -```bash -pnpm build -``` - -Then point your MCP client at the local build: - -```json -{ - "mcpServers": { - "codebase-context": { - "command": "node", - "args": ["/dist/index.js"] - } - } -} -``` - -If the default setup is not enough for your client, use this instead: - -```json -{ - "mcpServers": { - "codebase-context": { - "command": "node", - "args": ["/dist/index.js", "/path/to/your/project"] - } - } -} -``` +The server runs in two modes. Use stdio unless you need multiple clients connected at once: -Check these three flows: +| Mode | How it runs | When to use | +| ---- | ----------- | ------------ | +| **stdio** (default) | Process spawned by the client | One AI client talking to one or more repos | +| **HTTP** | Long-lived server at `http://127.0.0.1:3100/mcp` | Multiple clients sharing one server | -1. Single project - Ask for `search_codebase` or `metadata`. - Expected: routing is automatic. +Client support at a glance: -2. Multiple projects with one server entry - Open two repos or one monorepo workspace. - Ask for `codebase://context`. - Expected: workspace overview first, then automatic routing once one project is active or unambiguous. +| Client | stdio | HTTP | +| ------ | ----- | ---- | +| Claude Code | Yes | No (stdio only) | +| Claude Desktop | Yes | No | +| Cursor | Yes | Yes — `.cursor/mcp.json` with `type: "http"` | +| Windsurf | Yes | Not yet | +| Codex | Yes | Yes — `--mcp-config` flag | +| VS Code (Copilot) | Yes | No | +| OpenCode | Yes | Not documented yet | -3. Ambiguous project selection - Start without a bootstrap path. - Ask for `search_codebase`. - Expected: `selection_required`. - Retry with `project`, for example `apps/dashboard` or `/repos/customer-portal`. +Copy-pasteable templates: [`templates/mcp/stdio/.mcp.json`](./templates/mcp/stdio/.mcp.json) and [`templates/mcp/http/.mcp.json`](./templates/mcp/http/.mcp.json). -For monorepos, verify all three selector forms: - -- relative subproject path: `apps/dashboard` -- repo path: `/repos/customer-portal` -- file path: `/repos/monorepo/apps/dashboard/src/auth/guard.ts` +Full per-client setup, HTTP server instructions, and local build testing: [`docs/client-setup.md`](./docs/client-setup.md). ## Common First Commands @@ -339,199 +84,78 @@ npx -y codebase-context memory list This is also what your AI agent consumes automatically via MCP tools; the CLI is the human-readable version. -### CLI preview - -```text -$ npx -y codebase-context patterns -┌─ Team Patterns ──────────────────────────────────────────────────────┐ -│ │ -│ UNIT TEST FRAMEWORK │ -│ USE: Vitest – 96% adoption │ -│ alt CAUTION: Jest – 4% minority pattern │ -│ │ -│ STATE MANAGEMENT │ -│ PREFER: RxJS – 63% adoption │ -│ alt Redux-style store – 25% │ -│ │ -└──────────────────────────────────────────────────────────────────────┘ -``` - -```text -$ npx -y codebase-context search --query "file watcher" --intent edit --limit 1 -┌─ Search: "file watcher" ─── intent: edit ────────────────────────────┐ -│ Quality: ok (1.00) │ -│ Ready to edit: YES │ -│ │ -│ Best example: index.ts │ -└──────────────────────────────────────────────────────────────────────┘ -``` - -```text -$ npx -y codebase-context metadata -┌─ codebase-context [monorepo] ────────────────────────────────────────┐ -│ │ -│ Framework: Angular unknown Architecture: mixed │ -│ 130 files · 24,211 lines · 1077 components │ -│ │ -│ Dependencies: @huggingface/transformers · @lancedb/lancedb · │ -│ @modelcontextprotocol/sdk · @typescript-eslint/typescript-estree · │ -│ chokidar · fuse.js (+14 more) │ -│ │ -└──────────────────────────────────────────────────────────────────────┘ -``` - -```text -$ npx -y codebase-context refs --symbol "startFileWatcher" -┌─ startFileWatcher ─── 11 references ─── static analysis ─────────────┐ -│ │ -│ startFileWatcher │ -│ │ │ -│ ├─ file-watcher.test.ts:5 │ -│ │ import { startFileWatcher } from '../src/core/file-watcher.... │ -│ │ -└──────────────────────────────────────────────────────────────────────┘ -``` - -```text -$ npx -y codebase-context cycles -┌─ Circular Dependencies ──────────────────────────────────────────────┐ -│ │ -│ No cycles found · 98 files · 260 edges · 2.7 avg deps │ -│ │ -└──────────────────────────────────────────────────────────────────────┘ -``` - -See `docs/cli.md` for the full CLI gallery. - -## What It Actually Does - -Other tools help AI find code. This one helps AI make the right decisions - by knowing what your team does, tracking where codebases are heading, and warning before mistakes happen. - -### The Difference - -| Without codebase-context | With codebase-context | -| ------------------------------------------------------- | --------------------------------------------------- | -| Generates code using whatever matches or "sounds" right | Generates code following your team conventions | -| Copies any example that fits | Follows your best implementations (golden files) | -| Repeats mistakes you already corrected | Surfaces failure memories right before trying again | -| You re-explain the same things every session | Remembers conventions and decisions automatically | -| Edits confidently even when context is weak | Flags high-risk changes when evidence is thin | -| Sees what the current code does and assumes | Sees how your code has evolved and why | +## What it does ### The Search Tool (`search_codebase`) -This is where it all comes together. One call returns: - -- **Code results** with `file` (path + line range), `summary`, `score` -- **Type** per result: compact `componentType:layer` (e.g., `service:data`) — helps agents orient -- **Pattern signals** per result: `trend` (Rising/Declining — Stable is omitted) and `patternWarning` when using legacy code -- **Relationships** per result: `importedByCount` and `hasTests` (condensed) + **hints** (capped ranked callers, consumers, tests) — so you see suggested next reads and know what you haven't looked at yet -- **Related memories**: up to 3 team decisions, gotchas, and failures matched to the query -- **Search quality**: `ok` or `low_confidence` with confidence score and `hint` when low -- **Preflight**: `ready` (boolean) with decision card when `intent="edit"|"refactor"|"migrate"`. Shows `nextAction` (if not ready), `warnings`, `patterns` (do/avoid), `bestExample`, `impact` (import-graph coverage — how many files that import or reference the result are in your search), and `whatWouldHelp` (next steps). If search quality is low, `ready` is always `false`. - -Snippets are optional (`includeSnippets: true`). When enabled, snippets that have symbol metadata (e.g. from the Generic analyzer's AST chunking or Angular component chunks) start with a scope header so you know where the code lives (e.g. `// AuthService.getToken()` or `// SpotifyApiService`). Example: +One call returns ranked results with `file`, `summary`, `score`, compact type (`componentType:layer`), pattern trend signals, relationship hints, related team memories, a search quality assessment, and a preflight decision card when `intent="edit"`. The decision card shows `ready` (boolean), `nextAction` when not ready, `patterns` (do/avoid), `bestExample`, impact coverage (`"3/5 callers in results"`), and `whatWouldHelp`. -```ts -// AuthService.getToken() -getToken(): string { - return this.token; -} -``` - -Default output is lean — if the agent wants code, it calls `read_file`. +Default output is lean — if the agent wants code, it calls `read_file`. Add `includeSnippets: true` for inline code with scope headers (e.g. `// AuthService.getToken()`). -For scripting and automation, every CLI command accepts `--json` for machine output (stdout = JSON; logs/errors go to stderr). -See `docs/capabilities.md` for the field reference. - -Lean enough to fit on one screen. If search quality is low, preflight blocks edits instead of faking confidence. +See [`docs/capabilities.md`](./docs/capabilities.md) for the full field reference. ### Patterns & Conventions (`get_team_patterns`) -Detects what your team actually does by analyzing the codebase: - -- Adoption percentages for dependency injection, state management, testing, libraries -- Patterns/conventions trend direction (Rising / Stable / Declining) based on git recency -- Golden files - your best implementations ranked by modern pattern density -- Conflicts - when the team hasn't converged (both approaches above 20% adoption) +Detects what your team actually does by analyzing the codebase: adoption percentages for DI, state management, testing, and library patterns; trend direction (Rising / Stable / Declining) from git recency; golden files ranked by modern pattern density; conflicts when two approaches both exceed 20%. ### Team Memory (`remember` + `get_memory`) -Record a decision once. It surfaces automatically in search results and preflight cards from then on. **Your git commits also become memories** - conventional commits like `refactor:`, `migrate:`, `fix:`, `revert:` from the last 90 days are auto-extracted during indexing. +Record a decision once. It surfaces automatically in search results and preflight cards from then on. Conventional commits (`refactor:`, `migrate:`, `fix:`, `revert:`) from the last 90 days auto-extract into memory during indexing — no setup required. -- **Types**: conventions (style rules), decisions (architecture choices), gotchas (things that break), failures (we tried X, it broke because Y) -- **Confidence decay**: decisions age over 180 days, gotchas and failures over 90 days. Stale memories get flagged instead of blindly trusted. -- **Zero-config git extraction**: runs automatically during `refresh_index`. No setup, no manual work. +Memory types: `convention`, `decision`, `gotcha`, `failure`. Confidence decay: conventions never decay, decisions 180-day half-life, gotchas/failures 90-day. Stale memories get flagged instead of blindly trusted. -### All Tools +## Tools -| Tool | What it does | -| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `search_codebase` | Hybrid search + decision card. Pass `intent="edit"` to get `ready`, `nextAction`, patterns, import-graph coverage, and `whatWouldHelp`. | -| `get_team_patterns` | Pattern frequencies, golden files, conflict detection | -| `get_symbol_references` | Find concrete references to a symbol (usageCount + top snippets). `confidence: "syntactic"` = static/source-based only; no runtime or dynamic dispatch. | -| `remember` | Record a convention, decision, gotcha, or failure | -| `get_memory` | Query team memory with confidence decay scoring | -| `get_codebase_metadata` | Project structure, frameworks, dependencies | -| `get_style_guide` | Style guide rules for the current project | -| `detect_circular_dependencies` | Import cycles between files | -| `refresh_index` | Re-index (full or incremental) + extract git memories | -| `get_indexing_status` | Progress and stats for the current index | +| Tool | What it does | +| ---- | ------------ | +| `search_codebase` | Hybrid search + decision card when `intent="edit"` | +| `get_team_patterns` | Pattern frequencies, golden files, conflict detection | +| `get_symbol_references` | Concrete references to a symbol (count + snippets) | +| `remember` | Record a convention, decision, gotcha, or failure | +| `get_memory` | Query team memory with confidence decay scoring | +| `get_codebase_metadata` | Project structure, frameworks, dependencies | +| `get_style_guide` | Style guide rules for the current project | +| `detect_circular_dependencies` | Import cycles between files | +| `refresh_index` | Full or incremental re-index + git memory extraction | +| `get_indexing_status` | Progress and stats for the current index | -## Evaluation Harness (`npm run eval`) +## Multi-project -Reproducible evaluation with frozen fixtures so ranking/chunking changes are measured honestly and regressions get caught. **For contributors and CI:** run before releases or after changing search/ranking/chunking to guard against regressions. +One server, multiple repos. Three cases: -- Two codebases: `npm run eval -- ` -- Defaults: fixture A = `tests/fixtures/eval-angular-spotify.json`, fixture B = `tests/fixtures/eval-controlled.json` -- Offline smoke (no network): +| Case | What happens | +| ---- | ------------ | +| One project | Routing is automatic | +| Multiple projects, active project already set | Routes to the active project | +| Multiple projects, ambiguous | Returns `selection_required` — retry with `project` | -```bash -npm run eval -- tests/fixtures/codebases/eval-controlled tests/fixtures/codebases/eval-controlled \ - --fixture-a=tests/fixtures/eval-controlled.json \ - --fixture-b=tests/fixtures/eval-controlled.json \ - --skip-reindex --no-rerank -``` - -- Flags: `--help`, `--fixture-a`, `--fixture-b`, `--skip-reindex`, `--no-rerank`, `--no-redact` -- To save a report for later comparison, redirect stdout (e.g. `pnpm run eval -- --skip-reindex > internal-docs/tests/eval-runs/angular-spotify-YYYY-MM-DD.txt`). - -## How the Search Works +`project` accepts a project root path, file path, `file://` URI, or relative subproject path (e.g. `apps/dashboard`). -The retrieval pipeline is designed around one goal: give the agent the right context, not just any file that matches. - -- **Definition-first ranking** - for exact-name lookups (e.g. a symbol name), the file that _defines_ the symbol ranks above files that only use it. -- **Intent classification** - knows whether "AuthService" is a name lookup or "how does auth work" is conceptual. Adjusts keyword/semantic weights accordingly. -- **Hybrid fusion (RRF)** - combines keyword and semantic search using Reciprocal Rank Fusion instead of brittle score averaging. -- **Query expansion** - conceptual queries automatically expand with domain-relevant terms (auth → login, token, session, guard). -- **Contamination control** - test files are filtered/demoted for non-test queries. -- **Import centrality** - files that are imported more often rank higher. -- **Cross-encoder reranking** - a stage-2 reranker triggers only when top scores are ambiguous. CPU-only, bounded to top-K. -- **Incremental indexing** - only re-indexes files that changed since last run (SHA-256 manifest diffing). -- **Version gating** - index artifacts are versioned; mismatches trigger automatic rebuild so mixed-version data is never served. -- **Auto-heal** - if the index corrupts, search triggers a full re-index automatically. +```json +{ + "name": "search_codebase", + "arguments": { + "query": "auth interceptor", + "project": "apps/dashboard" + } +} +``` -**Index reliability:** Rebuilds write to a staging directory and swap atomically only on success, so a failed rebuild never corrupts the active index. Version mismatches or corruption trigger an automatic full re-index (no user action required). +If you get `selection_required`, retry with one of the paths from `availableProjects`. Full routing details and response shapes in [`docs/capabilities.md`](./docs/capabilities.md#project-routing). ## Language Support -**10 languages** have full symbol extraction (Tree-sitter): TypeScript, JavaScript, Python, Java, Kotlin, C, C++, C#, Go, Rust. **30+ languages** have indexing and retrieval coverage (keyword + semantic), including PHP, Ruby, Swift, Scala, Shell, and config/markup (JSON/YAML/TOML/XML, etc.). - -Enrichment is framework-specific: right now only **Angular** has a dedicated analyzer for rich conventions/context (signals, standalone components, control flow, DI patterns). - -For non-Angular projects, the **Generic** analyzer uses **AST-aligned chunking** when a Tree-sitter grammar is available: symbol-bounded chunks with **scope-aware prefixes** (e.g. `// ClassName.methodName`) so snippets show where code lives. Without a grammar it falls back to safe line-based chunking. - -Structured filters available: `framework`, `language`, `componentType`, `layer` (presentation, business, data, state, core, shared). +10 languages with full symbol extraction via Tree-sitter: TypeScript, JavaScript, Python, Java, Kotlin, C, C++, C#, Go, Rust. 30+ languages with indexing and retrieval coverage, including PHP, Ruby, Swift, Scala, Shell, and config formats. Angular has a dedicated analyzer; everything else uses the Generic analyzer with AST-aligned chunking when a grammar is available. ## Configuration -| Variable | Default | Description | -| ------------------------ | -------------------------- | ---------------------------------------------------------------------------------------------------------- | -| `EMBEDDING_PROVIDER` | `transformers` | `openai` (fast, cloud) or `transformers` (local, private) | -| `OPENAI_API_KEY` | - | Required only if using `openai` provider | -| `CODEBASE_ROOT` | - | Optional bootstrap root for CLI and single-project MCP clients without roots | -| `CODEBASE_CONTEXT_DEBUG` | - | Set to `1` for verbose logging | -| `EMBEDDING_MODEL` | `Xenova/bge-small-en-v1.5` | Local embedding model override (e.g. `onnx-community/granite-embedding-small-english-r2-ONNX` for Granite) | +| Variable | Default | Description | +| -------- | ------- | ----------- | +| `EMBEDDING_PROVIDER` | `transformers` | `openai` (fast, cloud) or `transformers` (local, private) | +| `OPENAI_API_KEY` | — | Required only if using `openai` provider | +| `CODEBASE_ROOT` | — | Bootstrap root for CLI and single-project MCP clients | +| `CODEBASE_CONTEXT_DEBUG` | — | Set to `1` for verbose logging | +| `EMBEDDING_MODEL` | `Xenova/bge-small-en-v1.5` | Local embedding model override | ## Performance @@ -559,54 +183,6 @@ Structured filters available: `framework`, `language`, `componentType`, `layer` !.codebase-context/memory.json ``` -## CLI Reference - -Repo-scoped analysis commands are available via the CLI — no AI agent required. MCP multi-project routing uses the shared `project` selector when needed; the CLI stays one-root-per-invocation. -For formatted examples and “money shots”, see `docs/cli.md`. - -Set `CODEBASE_ROOT` to your project root, or run from the project directory. - -```bash -# Search the indexed codebase -npx -y codebase-context search --query "authentication middleware" -npx -y codebase-context search --query "auth" --intent edit --limit 5 - -# Project structure, frameworks, and dependencies -npx -y codebase-context metadata - -# Index state and progress -npx -y codebase-context status - -# Re-index the codebase -npx -y codebase-context reindex -npx -y codebase-context reindex --incremental --reason "added new service" - -# Style guide rules -npx -y codebase-context style-guide -npx -y codebase-context style-guide --query "naming" --category patterns - -# Team patterns (DI, state, testing, etc.) -npx -y codebase-context patterns -npx -y codebase-context patterns --category testing - -# Symbol references -npx -y codebase-context refs --symbol "UserService" -npx -y codebase-context refs --symbol "handleLogin" --limit 20 - -# Circular dependency detection -npx -y codebase-context cycles -npx -y codebase-context cycles --scope src/features - -# Memory management -npx -y codebase-context memory list -npx -y codebase-context memory list --category conventions --type convention -npx -y codebase-context memory list --query "auth" --json -npx -y codebase-context memory add --type convention --category tooling --memory "Use pnpm, not npm" --reason "Workspace support and speed" -npx -y codebase-context memory remove -``` - -All commands accept `--json` for raw JSON output suitable for piping and scripting. - ## What to add to your CLAUDE.md / AGENTS.md Paste this into `.cursorrules`, `CLAUDE.md`, `AGENTS.md`, or wherever your AI reads project instructions: @@ -629,9 +205,12 @@ These are the behaviors that make the most difference day-to-day. Copy, trim wha ## Links -- [Motivation](./MOTIVATION.md) - Research and design rationale -- [Changelog](./CHANGELOG.md) - Version history -- [Contributing](./CONTRIBUTING.md) - How to add analyzers +- [Client Setup](./docs/client-setup.md) — per-client config, HTTP setup, local build testing +- [Capabilities Reference](./docs/capabilities.md) — tool API, retrieval pipeline, decision card schema +- [CLI Gallery](./docs/cli.md) — formatted command output examples +- [Motivation](./MOTIVATION.md) — research and design rationale +- [Contributing](./CONTRIBUTING.md) — dev setup and eval harness +- [Changelog](./CHANGELOG.md) ## License diff --git a/docs/capabilities.md b/docs/capabilities.md index bc0828b..60796fb 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -2,6 +2,32 @@ Technical reference for what `codebase-context` ships today. For the user-facing overview, see [README.md](../README.md). +## Transport Modes + +The server supports two transport modes: + +| Mode | Command | MCP endpoint | +| ---- | ------- | ------------ | +| **stdio** (default) | `npx -y codebase-context` | Spawned process stdin/stdout | +| **HTTP** | `npx -y codebase-context --http [--port N]` | `http://127.0.0.1:3100/mcp` | + +HTTP defaults to `127.0.0.1:3100`. Override with `--port`, `CODEBASE_CONTEXT_PORT`, or `server.port` in `~/.codebase-context/config.json`. + +Config-registered project roots (from `~/.codebase-context/config.json`) are loaded at startup in both modes. + +Per-project config overrides supported today: + +- `projects[].excludePatterns`: merged with the built-in exclusion set for that project at index time +- `projects[].analyzerHints.analyzer`: prefers a registered analyzer by name for that project and falls back safely when the name is missing or invalid +- `projects[].analyzerHints.extensions`: adds project-local source extensions for indexing and auto-refresh watching without changing defaults for other projects + +Copy-pasteable client config templates are shipped in the package: + +- `templates/mcp/stdio/.mcp.json` — stdio setup for `.mcp.json`-style clients +- `templates/mcp/http/.mcp.json` — HTTP setup for `.mcp.json`-style clients + +Client transport support varies — see [README.md](../README.md) for a per-client matrix covering Claude Code, Cursor, Codex, Windsurf, VS Code, Claude Desktop, and OpenCode. + ## CLI Reference Repo-scoped capabilities are available locally via the CLI (human-readable by default, `--json` for automation). @@ -86,6 +112,61 @@ Rules: - `codebase://context` serves the active project. Before selection in an unresolved multi-project session, it returns a workspace overview with candidate projects, readiness state, and project-scoped resource URIs. - `codebase://context/project/` serves a specific project directly and also makes that project active for later tool calls. +### Examples + +Retry with a subproject path in a monorepo: + +```json +{ + "name": "search_codebase", + "arguments": { + "query": "auth interceptor", + "project": "apps/dashboard" + } +} +``` + +Target a repo directly: + +```json +{ + "name": "search_codebase", + "arguments": { + "query": "auth interceptor", + "project": "/repos/customer-portal" + } +} +``` + +Pass a file path and let the server resolve the nearest project boundary: + +```json +{ + "name": "search_codebase", + "arguments": { + "query": "auth interceptor", + "project": "/repos/monorepo/apps/dashboard/src/auth/guard.ts" + } +} +``` + +`selection_required` response shape: + +```json +{ + "status": "selection_required", + "errorCode": "selection_required", + "message": "Multiple projects are available and no active project could be inferred. Retry with project.", + "nextAction": "retry_with_project", + "availableProjects": [ + { "label": "app-a", "project": "/repos/app-a", "indexStatus": "idle", "source": "root" }, + { "label": "app-b", "project": "/repos/app-b", "indexStatus": "ready", "source": "root" } + ] +} +``` + +Retry the call with `project` set to one of the listed paths. + ## Retrieval Pipeline Ordered by execution: diff --git a/docs/client-setup.md b/docs/client-setup.md new file mode 100644 index 0000000..4acdb02 --- /dev/null +++ b/docs/client-setup.md @@ -0,0 +1,208 @@ +# Client Setup + +Full setup instructions for each AI client. For the quick-start summary, see [README.md](../README.md). + +## Transport modes + +| Mode | How it runs | When to use | +| ---- | ----------- | ------------ | +| **stdio** (default) | Process spawned by the client | One AI client, simple setup | +| **HTTP** | Long-lived server at `http://127.0.0.1:3100/mcp` | Multiple clients sharing one server | + +Start the HTTP server: + +```bash +npx -y codebase-context --http # default port 3100 +npx -y codebase-context --http --port 4000 +``` + +Copy-pasteable templates: [`templates/mcp/stdio/.mcp.json`](../templates/mcp/stdio/.mcp.json) and [`templates/mcp/http/.mcp.json`](../templates/mcp/http/.mcp.json). + +## Claude Code + +```bash +claude mcp add codebase-context -- npx -y codebase-context +``` + +Claude Code only supports stdio. HTTP is not available for this client. + +## Claude Desktop + +Add to `claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "codebase-context": { + "command": "npx", + "args": ["-y", "codebase-context"] + } + } +} +``` + +Claude Desktop only supports stdio. + +## Cursor + +**Stdio** — add to `.cursor/mcp.json` in your project (copy from [`templates/mcp/stdio/.mcp.json`](../templates/mcp/stdio/.mcp.json)): + +```json +{ + "mcpServers": { + "codebase-context": { + "command": "npx", + "args": ["-y", "codebase-context"] + } + } +} +``` + +**HTTP** — start the server first, then add to `.cursor/mcp.json` (copy from [`templates/mcp/http/.mcp.json`](../templates/mcp/http/.mcp.json)): + +```json +{ + "mcpServers": { + "codebase-context": { + "type": "http", + "url": "http://127.0.0.1:3100/mcp" + } + } +} +``` + +## Windsurf + +Open Settings > MCP and add (stdio only — HTTP is not documented for Windsurf yet): + +```json +{ + "mcpServers": { + "codebase-context": { + "command": "npx", + "args": ["-y", "codebase-context"] + } + } +} +``` + +## Codex + +**Stdio:** + +```bash +codex mcp add codebase-context npx -y codebase-context +``` + +**HTTP** — start the server first (`npx -y codebase-context --http`), then save a config file and pass it: + +```json +{ + "mcpServers": { + "codebase-context": { + "type": "http", + "url": "http://127.0.0.1:3100/mcp" + } + } +} +``` + +```bash +codex --mcp-config /path/to/mcp-http.json +``` + +## VS Code (Copilot) + +Add `.vscode/mcp.json` to your project root. VS Code uses `servers` instead of `mcpServers`: + +```json +{ + "servers": { + "codebase-context": { + "command": "npx", + "args": ["-y", "codebase-context"] + } + } +} +``` + +## OpenCode + +Add `opencode.json` to your project root: + +```json +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "codebase-context": { + "type": "local", + "command": ["npx", "-y", "codebase-context"], + "enabled": true + } + } +} +``` + +OpenCode also supports interactive setup via `opencode mcp add`. + +## Single-project fallback + +If you only use one repo, append a project path: + +```bash +codex mcp add codebase-context npx -y codebase-context "/path/to/your/project" +``` + +Or set an environment variable: + +```bash +CODEBASE_ROOT=/path/to/your/project +``` + +## Test a local build + +Build the local branch first: + +```bash +pnpm build +``` + +Then point your MCP client at the local build: + +```json +{ + "mcpServers": { + "codebase-context": { + "command": "node", + "args": ["/dist/index.js"] + } + } +} +``` + +If the default setup is not enough for your client, pass a project path explicitly: + +```json +{ + "mcpServers": { + "codebase-context": { + "command": "node", + "args": ["/dist/index.js", "/path/to/your/project"] + } + } +} +``` + +Check these three flows: + +1. **Single project** — call `search_codebase` or `metadata`. Routing is automatic. + +2. **Multiple projects, one server entry** — open two repos or a monorepo. Call `codebase://context`. Expected: workspace overview, then automatic routing once a project is active. + +3. **Ambiguous selection** — start without a bootstrap path, call `search_codebase`. Expected: `selection_required`. Retry with `project` set to `apps/dashboard` or `/repos/customer-portal`. + +For monorepos, test all three selector forms: + +- relative subproject path: `apps/dashboard` +- repo path: `/repos/customer-portal` +- file path: `/repos/monorepo/apps/dashboard/src/auth/guard.ts` diff --git a/package.json b/package.json index 787a580..14881a5 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,9 @@ "README.md", "LICENSE", "docs/cli.md", - "docs/capabilities.md" + "docs/capabilities.md", + "docs/client-setup.md", + "templates" ], "packageManager": "pnpm@10.27.0", "engines": { @@ -112,7 +114,7 @@ "start": "node dist/index.js", "dev": "tsx src/index.ts", "watch": "tsc -w", - "test": "vitest run", + "test": "node scripts/run-vitest.mjs", "test:watch": "vitest", "lint": "eslint \"src/**/*.ts\"", "format": "prettier --write \"src/**/*.ts\"", @@ -167,6 +169,8 @@ "@modelcontextprotocol/sdk>@hono/node-server": "1.19.11", "@modelcontextprotocol/sdk>express-rate-limit": "8.2.2", "@huggingface/transformers>onnxruntime-node": "1.24.2", + "path-to-regexp": "8.4.0", + "brace-expansion": "5.0.5", "micromatch>picomatch": "2.3.2", "anymatch>picomatch": "2.3.2", "readdirp>picomatch": "2.3.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 55025da..46d3421 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,6 +9,8 @@ overrides: '@modelcontextprotocol/sdk>@hono/node-server': 1.19.11 '@modelcontextprotocol/sdk>express-rate-limit': 8.2.2 '@huggingface/transformers>onnxruntime-node': 1.24.2 + path-to-regexp: 8.4.0 + brace-expansion: 5.0.5 micromatch>picomatch: 2.3.2 anymatch>picomatch: 2.3.2 readdirp>picomatch: 2.3.2 @@ -989,9 +991,9 @@ packages: resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. - brace-expansion@5.0.2: - resolution: {integrity: sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==} - engines: {node: 20 || >=22} + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} @@ -1932,8 +1934,8 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} - path-to-regexp@8.3.0: - resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + path-to-regexp@8.4.0: + resolution: {integrity: sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==} path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} @@ -3270,7 +3272,7 @@ snapshots: boolean@3.2.0: {} - brace-expansion@5.0.2: + brace-expansion@5.0.5: dependencies: balanced-match: 4.0.3 @@ -4185,7 +4187,7 @@ snapshots: minimatch@10.2.3: dependencies: - brace-expansion: 5.0.2 + brace-expansion: 5.0.5 minimist@1.2.8: {} @@ -4327,7 +4329,7 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.2 - path-to-regexp@8.3.0: {} + path-to-regexp@8.4.0: {} path-type@4.0.0: {} @@ -4476,7 +4478,7 @@ snapshots: depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 - path-to-regexp: 8.3.0 + path-to-regexp: 8.4.0 transitivePeerDependencies: - supports-color diff --git a/scripts/run-vitest.mjs b/scripts/run-vitest.mjs new file mode 100644 index 0000000..32e164e --- /dev/null +++ b/scripts/run-vitest.mjs @@ -0,0 +1,29 @@ +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const forwardedArgs = process.argv.slice(2); +const vitestArgs = + forwardedArgs[0] === '--' ? forwardedArgs.slice(1) : forwardedArgs; + +const vitestEntrypoint = fileURLToPath( + new URL('../node_modules/vitest/vitest.mjs', import.meta.url) +); + +const child = spawn(process.execPath, [vitestEntrypoint, 'run', ...vitestArgs], { + stdio: 'inherit', + env: process.env +}); + +child.on('exit', (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + + process.exit(code ?? 1); +}); + +child.on('error', (error) => { + console.error('[test] Failed to start vitest:', error); + process.exit(1); +}); diff --git a/src/core/analyzer-registry.ts b/src/core/analyzer-registry.ts index e264b1b..6f4df74 100644 --- a/src/core/analyzer-registry.ts +++ b/src/core/analyzer-registry.ts @@ -3,8 +3,14 @@ * Automatically selects the best analyzer based on file type and priority */ +import path from 'path'; import { FrameworkAnalyzer, AnalysisResult } from '../types/index.js'; +export interface AnalyzerSelectionOptions { + preferredAnalyzer?: string; + extraFileExtensions?: string[]; +} + export class AnalyzerRegistry { private analyzers: Map = new Map(); private sortedAnalyzers: FrameworkAnalyzer[] = []; @@ -43,11 +49,42 @@ export class AnalyzerRegistry { return [...this.sortedAnalyzers]; } + private isExtraExtension(filePath: string, extraFileExtensions?: string[]): boolean { + if (!extraFileExtensions?.length) { + return false; + } + + const extension = path.extname(filePath).toLowerCase(); + return extraFileExtensions.some((candidate) => { + const normalized = candidate.trim().toLowerCase(); + if (!normalized) { + return false; + } + return extension === (normalized.startsWith('.') ? normalized : `.${normalized}`); + }); + } + /** - * Find the best analyzer for a given file - * Returns the analyzer with highest priority that can handle the file + * Find the best analyzer for a given file. + * Returns the preferred analyzer when configured and applicable, otherwise the + * highest-priority analyzer that can handle the file. */ - findAnalyzer(filePath: string, content?: string): FrameworkAnalyzer | null { + findAnalyzer( + filePath: string, + content?: string, + options?: AnalyzerSelectionOptions + ): FrameworkAnalyzer | null { + if (options?.preferredAnalyzer) { + const preferred = this.analyzers.get(options.preferredAnalyzer); + if ( + preferred && + (preferred.canAnalyze(filePath, content) || + this.isExtraExtension(filePath, options.extraFileExtensions)) + ) { + return preferred; + } + } + for (const analyzer of this.sortedAnalyzers) { if (analyzer.canAnalyze(filePath, content)) { return analyzer; @@ -66,8 +103,12 @@ export class AnalyzerRegistry { /** * Analyze a file using the best available analyzer */ - async analyzeFile(filePath: string, content: string): Promise { - const analyzer = this.findAnalyzer(filePath, content); + async analyzeFile( + filePath: string, + content: string, + options?: AnalyzerSelectionOptions + ): Promise { + const analyzer = this.findAnalyzer(filePath, content, options); if (!analyzer) { if (process.env.CODEBASE_CONTEXT_DEBUG) { @@ -76,8 +117,6 @@ export class AnalyzerRegistry { return null; } - // console.error(`Analyzing ${filePath} with ${analyzer.name} analyzer`); - try { return await analyzer.analyze(filePath, content); } catch (error) { diff --git a/src/core/file-watcher.ts b/src/core/file-watcher.ts index 155ac6e..cf5542e 100644 --- a/src/core/file-watcher.ts +++ b/src/core/file-watcher.ts @@ -7,23 +7,22 @@ export interface FileWatcherOptions { rootPath: string; /** ms after last change before triggering. Default: 2000 */ debounceMs?: number; + /** Additional source extensions tracked for this project only. */ + extraExtensions?: string[]; /** Called once chokidar finishes initial scan and starts emitting change events */ onReady?: () => void; /** Called once the debounce window expires after the last detected change */ onChanged: () => void; } -const TRACKED_EXTENSIONS = new Set( - getSupportedExtensions().map((extension) => extension.toLowerCase()) -); - const TRACKED_METADATA_FILES = new Set(['.gitignore']); -function isTrackedSourcePath(filePath: string): boolean { +function isTrackedSourcePath(filePath: string, trackedExtensions: Set): boolean { const basename = path.basename(filePath).toLowerCase(); if (TRACKED_METADATA_FILES.has(basename)) return true; + const extension = path.extname(filePath).toLowerCase(); - return extension.length > 0 && TRACKED_EXTENSIONS.has(extension); + return extension.length > 0 && trackedExtensions.has(extension); } /** @@ -31,11 +30,14 @@ function isTrackedSourcePath(filePath: string): boolean { * Returns a stop() function that cancels the debounce timer and closes the watcher. */ export function startFileWatcher(opts: FileWatcherOptions): () => void { - const { rootPath, debounceMs = 2000, onReady, onChanged } = opts; + const { rootPath, debounceMs = 2000, extraExtensions, onReady, onChanged } = opts; + const trackedExtensions = new Set( + getSupportedExtensions(extraExtensions).map((extension) => extension.toLowerCase()) + ); let debounceTimer: ReturnType | undefined; const trigger = (filePath: string) => { - if (!isTrackedSourcePath(filePath)) return; + if (!isTrackedSourcePath(filePath, trackedExtensions)) return; if (debounceTimer !== undefined) clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { debounceTimer = undefined; diff --git a/src/core/indexer.ts b/src/core/indexer.ts index d0e06d4..ddb7140 100644 --- a/src/core/indexer.ts +++ b/src/core/indexer.ts @@ -20,7 +20,8 @@ import { IntelligenceData } from '../types/index.js'; import { analyzerRegistry } from './analyzer-registry.js'; -import { isCodeFile, isBinaryFile } from '../utils/language-detection.js'; +import type { AnalyzerSelectionOptions } from './analyzer-registry.js'; +import { getSupportedExtensions, isBinaryFile, isCodeFile } from '../utils/language-detection.js'; import { getEmbeddingProvider, getConfiguredDimensions, @@ -210,6 +211,7 @@ async function cleanupDirectory(dirPath: string): Promise { export interface IndexerOptions { rootPath: string; config?: Partial; + projectOptions?: AnalyzerSelectionOptions; onProgress?: (progress: IndexingProgress) => void; incrementalOnly?: boolean; } @@ -224,6 +226,8 @@ interface PersistedIndexingStats { export class CodebaseIndexer { private rootPath: string; private config: CodebaseConfig; + private projectOptions: AnalyzerSelectionOptions; + private supportedCodeExtensions: Set; private progress: IndexingProgress; private onProgressCallback?: (progress: IndexingProgress) => void; private incrementalOnly: boolean; @@ -231,6 +235,15 @@ export class CodebaseIndexer { constructor(options: IndexerOptions) { this.rootPath = path.resolve(options.rootPath); this.config = this.mergeConfig(options.config); + this.projectOptions = { + preferredAnalyzer: options.projectOptions?.preferredAnalyzer, + extraFileExtensions: options.projectOptions?.extraFileExtensions + }; + this.supportedCodeExtensions = new Set( + getSupportedExtensions(this.projectOptions.extraFileExtensions).map((extension) => + extension.toLowerCase() + ) + ); this.onProgressCallback = options.onProgress; this.incrementalOnly = options.incrementalOnly ?? false; @@ -321,6 +334,45 @@ export class CodebaseIndexer { }; } + private getProjectOptions(): AnalyzerSelectionOptions { + const preferredAnalyzer = this.projectOptions.preferredAnalyzer?.trim(); + if (!preferredAnalyzer) { + return this.projectOptions; + } + + if (!analyzerRegistry.get(preferredAnalyzer)) { + console.warn( + `[indexer] Preferred analyzer "${preferredAnalyzer}" is not registered. Falling back to default analyzer selection.` + ); + return { + ...this.projectOptions, + preferredAnalyzer: undefined + }; + } + + return { + ...this.projectOptions, + preferredAnalyzer + }; + } + + private getIncludePatterns(): string[] { + const includePatterns = this.config.include || ['**/*']; + const extraFileExtensions = this.projectOptions.extraFileExtensions ?? []; + + if (extraFileExtensions.length === 0) { + return includePatterns; + } + + const extraPatterns = extraFileExtensions + .map((extension) => extension.trim().toLowerCase()) + .filter((extension) => extension.length > 0) + .map((extension) => (extension.startsWith('.') ? extension : `.${extension}`)) + .map((extension) => `**/*${extension}`); + + return Array.from(new Set([...includePatterns, ...extraPatterns])); + } + async index(): Promise { const startTime = Date.now(); const stats: IndexingStats = { @@ -357,6 +409,8 @@ export class CodebaseIndexer { analyzerRegistry.register(new GenericAnalyzer()); } + const resolvedProjectOptions = this.getProjectOptions(); + const buildId = randomUUID(); const generatedAt = new Date().toISOString(); const toolVersion = await getToolVersion(); @@ -529,7 +583,7 @@ export class CodebaseIndexer { // Normalize line endings to \n for consistent cross-platform output const rawContent = await fs.readFile(file, 'utf-8'); const content = rawContent.replace(/\r\n/g, '\n'); - const result = await analyzerRegistry.analyzeFile(file, content); + const result = await analyzerRegistry.analyzeFile(file, content, resolvedProjectOptions); if (result) { const isFileChanged = !filesToProcessSet || filesToProcessSet.has(file); @@ -1027,7 +1081,7 @@ export class CodebaseIndexer { } // Scan with glob - const includePatterns = this.config.include || ['**/*']; + const includePatterns = this.getIncludePatterns(); const excludePatterns = this.config.exclude || []; for (const pattern of includePatterns) { @@ -1053,7 +1107,7 @@ export class CodebaseIndexer { } // Check if it's a code file - if (!isCodeFile(file) || isBinaryFile(file)) { + if (!isCodeFile(file, this.supportedCodeExtensions) || isBinaryFile(file)) { continue; } diff --git a/src/index.ts b/src/index.ts index aa8ddb0..a520ed2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { createServer } from './server/factory.js'; import { startHttpServer } from './server/http.js'; import { loadServerConfig } from './server/config.js'; +import type { ProjectConfig } from './server/config.js'; import { CallToolRequestSchema, ListToolsRequestSchema, @@ -64,6 +65,7 @@ import { makeLegacyPaths, normalizeRootKey, removeProject, + type ProjectRuntimeOverrides, type ProjectState } from './project-state.js'; @@ -1251,8 +1253,21 @@ async function performIndexingOnce( let lastLoggedProgress = { phase: '', percentage: -1 }; const indexer = new CodebaseIndexer({ rootPath: project.rootPath, - ...(project.extraExcludePatterns?.length - ? { config: { exclude: [...EXCLUDED_GLOB_PATTERNS, ...project.extraExcludePatterns] } } + ...(project.runtimeOverrides.extraExcludePatterns?.length + ? { + config: { + exclude: [...EXCLUDED_GLOB_PATTERNS, ...project.runtimeOverrides.extraExcludePatterns] + } + } + : {}), + ...(project.runtimeOverrides.preferredAnalyzer || + project.runtimeOverrides.extraSourceExtensions?.length + ? { + projectOptions: { + preferredAnalyzer: project.runtimeOverrides.preferredAnalyzer, + extraFileExtensions: project.runtimeOverrides.extraSourceExtensions + } + } : {}), incrementalOnly, onProgress: (progress) => { @@ -1544,6 +1559,7 @@ function ensureProjectWatcher(project: ProjectState, debounceMs: number): void { project.stopWatcher = startFileWatcher({ rootPath: project.rootPath, debounceMs, + extraExtensions: project.runtimeOverrides.extraSourceExtensions, onChanged: () => { const shouldRunNow = project.autoRefresh.onFileChange( project.indexState.status === 'indexing' @@ -1601,6 +1617,42 @@ async function initProject( } } +function normalizeRuntimeExtensions(extensions?: string[]): string[] | undefined { + if (!extensions?.length) { + return undefined; + } + + const normalized = Array.from( + new Set( + extensions + .map((extension) => extension.trim().toLowerCase()) + .filter((extension) => extension.length > 0) + .map((extension) => (extension.startsWith('.') ? extension : `.${extension}`)) + ) + ); + + return normalized.length > 0 ? normalized : undefined; +} + +function buildProjectRuntimeOverrides(projectConfig: ProjectConfig): ProjectRuntimeOverrides { + const runtimeOverrides: ProjectRuntimeOverrides = {}; + + if (projectConfig.excludePatterns?.length) { + runtimeOverrides.extraExcludePatterns = [...projectConfig.excludePatterns]; + } + + if (projectConfig.analyzerHints?.analyzer) { + runtimeOverrides.preferredAnalyzer = projectConfig.analyzerHints.analyzer.trim(); + } + + const extraSourceExtensions = normalizeRuntimeExtensions(projectConfig.analyzerHints?.extensions); + if (extraSourceExtensions) { + runtimeOverrides.extraSourceExtensions = extraSourceExtensions; + } + + return runtimeOverrides; +} + async function applyServerConfig( serverConfig: Awaited> ): Promise { @@ -1614,9 +1666,10 @@ async function applyServerConfig( const rootKey = normalizeRootKey(proj.root); configRoots.set(rootKey, { rootPath: proj.root }); registerKnownRoot(proj.root); - if (proj.excludePatterns?.length) { + const runtimeOverrides = buildProjectRuntimeOverrides(proj); + if (Object.keys(runtimeOverrides).length > 0) { const project = getOrCreateProject(proj.root); - project.extraExcludePatterns = proj.excludePatterns; + project.runtimeOverrides = runtimeOverrides; } } catch { console.error(`[config] Skipping inaccessible project root: ${proj.root}`); diff --git a/src/project-state.ts b/src/project-state.ts index 02f070c..bf12129 100644 --- a/src/project-state.ts +++ b/src/project-state.ts @@ -10,6 +10,15 @@ import { createAutoRefreshController } from './core/auto-refresh.js'; import type { AutoRefreshController } from './core/auto-refresh.js'; import type { ToolPaths, IndexState } from './tools/types.js'; +export interface ProjectRuntimeOverrides { + /** Extra glob exclusion patterns merged with the default index-time exclusions. */ + extraExcludePatterns?: string[]; + /** Analyzer name to prefer for this project without mutating global registry order. */ + preferredAnalyzer?: string; + /** Additional source extensions treated as code for this project only. */ + extraSourceExtensions?: string[]; +} + export interface ProjectState { rootPath: string; paths: ToolPaths; @@ -17,8 +26,7 @@ export interface ProjectState { autoRefresh: AutoRefreshController; initPromise?: Promise; stopWatcher?: () => void; - /** Extra glob exclusion patterns from config file — merged with EXCLUDED_GLOB_PATTERNS at index time. */ - extraExcludePatterns?: string[]; + runtimeOverrides: ProjectRuntimeOverrides; } export function makePaths(rootPath: string): ToolPaths { @@ -59,7 +67,8 @@ export function createProjectState(rootPath: string): ProjectState { rootPath, paths: makePaths(rootPath), indexState: { status: 'idle' }, - autoRefresh: createAutoRefreshController() + autoRefresh: createAutoRefreshController(), + runtimeOverrides: {} }; } diff --git a/src/server/config.ts b/src/server/config.ts index 3e2d83b..b74ed6d 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -5,6 +5,10 @@ import path from 'node:path'; export interface ProjectConfig { root: string; excludePatterns?: string[]; + analyzerHints?: { + extensions?: string[]; + analyzer?: string; + }; } export interface ServerConfig { @@ -19,6 +23,19 @@ function expandTilde(filePath: string): string { return filePath; } +function parseStringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + + const parsed = value + .filter((entry): entry is string => typeof entry === 'string') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + + return parsed.length > 0 ? parsed : undefined; +} + export async function loadServerConfig(): Promise { const configPath = process.env.CODEBASE_CONTEXT_CONFIG_PATH ?? @@ -50,29 +67,55 @@ export async function loadServerConfig(): Promise { const config = parsed as Record; const result: ServerConfig = {}; - // Resolve projects if (Array.isArray(config.projects)) { result.projects = (config.projects as unknown[]) - .filter((p): p is Record => typeof p === 'object' && p !== null) - .map((p) => { - const rawRoot = typeof p.root === 'string' ? p.root.trim() : ''; + .filter( + (project): project is Record => + typeof project === 'object' && project !== null + ) + .map((project) => { + const rawRoot = typeof project.root === 'string' ? project.root.trim() : ''; if (!rawRoot) { console.error('[config] Skipping project entry with missing or empty root'); return null; } + const resolvedRoot = path.resolve(expandTilde(rawRoot)); - const proj: ProjectConfig = { root: resolvedRoot }; - if (Array.isArray(p.excludePatterns)) { - proj.excludePatterns = p.excludePatterns.filter( - (pattern): pattern is string => typeof pattern === 'string' - ); + const parsedProject: ProjectConfig = { root: resolvedRoot }; + const excludePatterns = parseStringArray(project.excludePatterns); + if (excludePatterns) { + parsedProject.excludePatterns = excludePatterns; } - return proj; + + if ( + typeof project.analyzerHints === 'object' && + project.analyzerHints !== null && + !Array.isArray(project.analyzerHints) + ) { + const analyzerHints = project.analyzerHints as Record; + const parsedHints: NonNullable = {}; + const extensions = parseStringArray(analyzerHints.extensions); + if (extensions) { + parsedHints.extensions = extensions; + } + + if (typeof analyzerHints.analyzer === 'string') { + const analyzer = analyzerHints.analyzer.trim(); + if (analyzer) { + parsedHints.analyzer = analyzer; + } + } + + if (parsedHints.analyzer || parsedHints.extensions) { + parsedProject.analyzerHints = parsedHints; + } + } + + return parsedProject; }) .filter((project): project is ProjectConfig => project !== null); } - // Resolve server options if (typeof config.server === 'object' && config.server !== null) { const srv = config.server as Record; result.server = {}; diff --git a/src/utils/language-detection.ts b/src/utils/language-detection.ts index 48d906a..7999d84 100644 --- a/src/utils/language-detection.ts +++ b/src/utils/language-detection.ts @@ -106,8 +106,7 @@ const binaryExtensions = new Set([ '.map' ]); -// Code file extensions -const codeExtensions = new Set([ +const baseCodeExtensions = new Set([ '.js', '.mjs', '.cjs', @@ -157,6 +156,26 @@ const codeExtensions = new Set([ '.sql' ]); +function normalizeExtension(extension: string): string | null { + const trimmed = extension.trim().toLowerCase(); + if (!trimmed) { + return null; + } + + return trimmed.startsWith('.') ? trimmed : `.${trimmed}`; +} + +function buildCodeExtensions(extraExtensions?: Iterable): Set { + const merged = new Set(baseCodeExtensions); + for (const extension of extraExtensions ?? []) { + const normalized = normalizeExtension(extension); + if (normalized) { + merged.add(normalized); + } + } + return merged; +} + /** * Detect language from file path */ @@ -168,9 +187,14 @@ export function detectLanguage(filePath: string): string { /** * Check if a file is a code file */ -export function isCodeFile(filePath: string): boolean { +export function isCodeFile( + filePath: string, + extensions?: Iterable | ReadonlySet +): boolean { const ext = path.extname(filePath).toLowerCase(); - return codeExtensions.has(ext); + const supportedExtensions = + extensions instanceof Set ? extensions : buildCodeExtensions(extensions); + return supportedExtensions.has(ext); } /** @@ -217,6 +241,6 @@ export function isDocumentationFile(filePath: string): boolean { /** * Get all supported extensions */ -export function getSupportedExtensions(): string[] { - return Array.from(codeExtensions); +export function getSupportedExtensions(extraExtensions?: Iterable): string[] { + return Array.from(buildCodeExtensions(extraExtensions)); } diff --git a/templates/mcp/http/.mcp.json b/templates/mcp/http/.mcp.json new file mode 100644 index 0000000..b1cc52c --- /dev/null +++ b/templates/mcp/http/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "codebase-context": { + "type": "http", + "url": "http://127.0.0.1:3100/mcp" + } + } +} diff --git a/templates/mcp/stdio/.mcp.json b/templates/mcp/stdio/.mcp.json new file mode 100644 index 0000000..cfedd20 --- /dev/null +++ b/templates/mcp/stdio/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "codebase-context": { + "command": "npx", + "args": ["-y", "codebase-context"] + } + } +} diff --git a/tests/file-watcher.test.ts b/tests/file-watcher.test.ts index be3e38e..cde1f45 100644 --- a/tests/file-watcher.test.ts +++ b/tests/file-watcher.test.ts @@ -159,4 +159,33 @@ describe('FileWatcher', () => { stop(); } }, 5000); + + it('tracks project-local extra extensions without changing defaults', async () => { + const debounceMs = 250; + let callCount = 0; + + let resolveReady!: () => void; + const ready = new Promise((resolve) => { + resolveReady = resolve; + }); + + const stop = startFileWatcher({ + rootPath: tempDir, + debounceMs, + extraExtensions: ['.sfc'], + onReady: () => resolveReady(), + onChanged: () => { + callCount++; + } + }); + + try { + await ready; + await fs.writeFile(path.join(tempDir, 'widget.sfc'), ''); + await new Promise((resolve) => setTimeout(resolve, debounceMs + 700)); + expect(callCount).toBe(1); + } finally { + stop(); + } + }, 5000); }); diff --git a/tests/indexer-analyzer-hints.test.ts b/tests/indexer-analyzer-hints.test.ts new file mode 100644 index 0000000..c7a1adf --- /dev/null +++ b/tests/indexer-analyzer-hints.test.ts @@ -0,0 +1,153 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { promises as fs } from 'fs'; +import os from 'os'; +import path from 'path'; +import { CodebaseIndexer } from '../src/core/indexer.js'; +import { analyzerRegistry } from '../src/core/analyzer-registry.js'; +import { AngularAnalyzer } from '../src/analyzers/angular/index.js'; +import { GenericAnalyzer } from '../src/analyzers/generic/index.js'; +import { + CODEBASE_CONTEXT_DIRNAME, + KEYWORD_INDEX_FILENAME +} from '../src/constants/codebase-context.js'; + +type IndexChunk = { + filePath: string; + componentType?: string; +}; + +async function readIndexedChunks(rootPath: string): Promise { + const indexPath = path.join(rootPath, CODEBASE_CONTEXT_DIRNAME, KEYWORD_INDEX_FILENAME); + const indexRaw = JSON.parse(await fs.readFile(indexPath, 'utf-8')) as Record; + + if (Array.isArray(indexRaw)) { + return indexRaw as IndexChunk[]; + } + + if (Array.isArray(indexRaw.chunks)) { + return indexRaw.chunks as IndexChunk[]; + } + + throw new Error(`Unexpected index format in ${indexPath}`); +} + +describe('Indexer analyzer hints', () => { + let tempDir: string; + + beforeAll(() => { + if (!analyzerRegistry.get('angular')) { + analyzerRegistry.register(new AngularAnalyzer()); + } + if (!analyzerRegistry.get('generic')) { + analyzerRegistry.register(new GenericAnalyzer()); + } + }); + + afterEach(async () => { + vi.restoreAllMocks(); + if (tempDir) { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it('indexes project-local extra extensions when a preferred analyzer is configured', async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'indexer-analyzer-hints-')); + await fs.writeFile( + path.join(tempDir, 'widget.sfc'), + 'export function renderWidget() { return "ok"; }\n' + ); + + const indexer = new CodebaseIndexer({ + rootPath: tempDir, + config: { skipEmbedding: true }, + projectOptions: { + preferredAnalyzer: 'generic', + extraFileExtensions: ['.sfc'] + } + }); + + const stats = await indexer.index(); + const chunks = await readIndexedChunks(tempDir); + + expect(stats.indexedFiles).toBe(1); + expect(chunks.some((chunk) => chunk.filePath.endsWith('widget.sfc'))).toBe(true); + }); + + it('honors extra extensions during incremental reindexing', async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'indexer-analyzer-hints-')); + const hintedFile = path.join(tempDir, 'widget.sfc'); + await fs.writeFile(hintedFile, 'export const value = 1;\n'); + + const projectOptions = { + preferredAnalyzer: 'generic', + extraFileExtensions: ['sfc'] + }; + + await new CodebaseIndexer({ + rootPath: tempDir, + config: { skipEmbedding: true }, + projectOptions + }).index(); + + await fs.writeFile(hintedFile, 'export const value = 2;\n'); + + const stats = await new CodebaseIndexer({ + rootPath: tempDir, + config: { skipEmbedding: true }, + projectOptions, + incrementalOnly: true + }).index(); + + expect(stats.incremental).toBeDefined(); + expect(stats.incremental?.changed).toBe(1); + }); + + it('warns once and falls back to default analyzer selection when the preferred analyzer is missing', async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'indexer-analyzer-hints-')); + await fs.writeFile( + path.join(tempDir, 'app.component.ts'), + [ + 'import { Component } from "@angular/core";', + '', + '@Component({', + ' selector: "app-root",', + ' template: "

Hello

"', + '})', + 'export class AppComponent {}' + ].join('\n') + ); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await new CodebaseIndexer({ + rootPath: tempDir, + config: { skipEmbedding: true }, + projectOptions: { + preferredAnalyzer: 'missing-analyzer' + } + }).index(); + + const chunks = await readIndexedChunks(tempDir); + + expect(warnSpy).toHaveBeenCalledOnce(); + expect(String(warnSpy.mock.calls[0]?.[0])).toContain('missing-analyzer'); + expect(chunks.some((chunk) => chunk.componentType === 'component')).toBe(true); + }); + + it('keeps default behavior unchanged when no analyzer hints are configured', async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'indexer-analyzer-hints-')); + await fs.writeFile( + path.join(tempDir, 'widget.sfc'), + 'export function renderWidget() { return "ignored"; }\n' + ); + + const stats = await new CodebaseIndexer({ + rootPath: tempDir, + config: { skipEmbedding: true } + }).index(); + const chunks = await readIndexedChunks(tempDir); + + expect(stats.indexedFiles).toBe(0); + expect(chunks).toEqual([]); + }); +}); diff --git a/tests/mcp-client-templates.test.ts b/tests/mcp-client-templates.test.ts new file mode 100644 index 0000000..1d40405 --- /dev/null +++ b/tests/mcp-client-templates.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const root = resolve(import.meta.dirname, '..'); + +function readJson(relPath: string): unknown { + const content = readFileSync(resolve(root, relPath), 'utf8'); + return JSON.parse(content); +} + +function readText(relPath: string): string { + return readFileSync(resolve(root, relPath), 'utf8'); +} + +// --------------------------------------------------------------------------- +// Template JSON validity +// --------------------------------------------------------------------------- + +describe('templates/mcp/stdio/.mcp.json', () => { + it('parses as valid JSON', () => { + expect(() => readJson('templates/mcp/stdio/.mcp.json')).not.toThrow(); + }); + + it('has an mcpServers key at the top level', () => { + const config = readJson('templates/mcp/stdio/.mcp.json') as Record; + expect(config).toHaveProperty('mcpServers'); + expect(typeof config.mcpServers).toBe('object'); + }); + + it('contains the codebase-context server entry', () => { + const config = readJson('templates/mcp/stdio/.mcp.json') as { + mcpServers: Record; + }; + expect(config.mcpServers).toHaveProperty('codebase-context'); + }); + + it('server entry uses npx command', () => { + const config = readJson('templates/mcp/stdio/.mcp.json') as { + mcpServers: Record; + }; + const entry = config.mcpServers['codebase-context']; + expect(entry.command).toBe('npx'); + expect(entry.args).toContain('codebase-context'); + }); +}); + +describe('templates/mcp/http/.mcp.json', () => { + it('parses as valid JSON', () => { + expect(() => readJson('templates/mcp/http/.mcp.json')).not.toThrow(); + }); + + it('has an mcpServers key at the top level', () => { + const config = readJson('templates/mcp/http/.mcp.json') as Record; + expect(config).toHaveProperty('mcpServers'); + }); + + it('contains the codebase-context server entry', () => { + const config = readJson('templates/mcp/http/.mcp.json') as { + mcpServers: Record; + }; + expect(config.mcpServers).toHaveProperty('codebase-context'); + }); + + it('server entry points to the local HTTP endpoint', () => { + const config = readJson('templates/mcp/http/.mcp.json') as { + mcpServers: Record; + }; + const entry = config.mcpServers['codebase-context']; + expect(entry.url).toBe('http://127.0.0.1:3100/mcp'); + expect(entry.type).toBe('http'); + }); +}); + +// --------------------------------------------------------------------------- +// README references templates and all four target clients +// --------------------------------------------------------------------------- + +describe('README.md client setup documentation', () => { + const readme = readText('README.md'); + + it('references the stdio template path', () => { + expect(readme).toContain('templates/mcp/stdio/.mcp.json'); + }); + + it('references the HTTP template path', () => { + expect(readme).toContain('templates/mcp/http/.mcp.json'); + }); + + it('mentions Claude Code', () => { + expect(readme).toContain('Claude Code'); + }); + + it('mentions Cursor', () => { + expect(readme).toContain('Cursor'); + }); + + it('mentions Codex', () => { + expect(readme).toContain('Codex'); + }); + + it('mentions Windsurf', () => { + expect(readme).toContain('Windsurf'); + }); + + it('includes the HTTP endpoint URL', () => { + expect(readme).toContain('127.0.0.1:3100/mcp'); + }); +}); + +// --------------------------------------------------------------------------- +// docs/capabilities.md transport notes +// --------------------------------------------------------------------------- + +describe('docs/capabilities.md transport documentation', () => { + const caps = readText('docs/capabilities.md'); + + it('references the stdio template path', () => { + expect(caps).toContain('templates/mcp/stdio/.mcp.json'); + }); + + it('references the HTTP template path', () => { + expect(caps).toContain('templates/mcp/http/.mcp.json'); + }); + + it('mentions the HTTP endpoint URL', () => { + expect(caps).toContain('127.0.0.1:3100/mcp'); + }); + + it('covers all four target clients', () => { + expect(caps).toContain('Claude Code'); + expect(caps).toContain('Cursor'); + expect(caps).toContain('Codex'); + expect(caps).toContain('Windsurf'); + }); +}); diff --git a/tests/search-decision-card.test.ts b/tests/search-decision-card.test.ts index 4b364be..eb4c8e1 100644 --- a/tests/search-decision-card.test.ts +++ b/tests/search-decision-card.test.ts @@ -3,12 +3,62 @@ import { promises as fs } from 'fs'; import os from 'os'; import path from 'path'; import { CodebaseIndexer } from '../src/core/indexer.js'; +import { rmWithRetries } from './test-helpers.js'; + +type ToolCallRequest = { + jsonrpc: '2.0'; + id: number; + method: 'tools/call'; + params: { name: string; arguments: Record }; +}; + +type SearchResultRow = { + snippet?: string; +}; + +type SearchToolPayload = { + results: SearchResultRow[]; + preflight?: { + ready: boolean; + nextAction?: string; + patterns?: Record; + warnings?: unknown[]; + bestExample?: string; + impact?: Record; + whatWouldHelp?: unknown[]; + }; +}; + +type ToolCallResponse = { + content: Array<{ type: 'text'; text: string }>; + isError?: boolean; +}; + +function getToolCallHandler( + server: unknown +): (request: ToolCallRequest) => Promise { + const handlers = (server as { _requestHandlers?: unknown })._requestHandlers; + if (!(handlers instanceof Map)) { + throw new Error('Expected server._requestHandlers to be a Map'); + } + const handler = handlers.get('tools/call'); + if (typeof handler !== 'function') { + throw new Error('Expected tools/call handler to be registered'); + } + return handler as (request: ToolCallRequest) => Promise; +} describe('Search Decision Card (Edit Intent)', () => { let tempRoot: string | null = null; + let originalArgv: string[] | null = null; + let originalEnvRoot: string | undefined; beforeEach(async () => { vi.resetModules(); + + originalArgv = [...process.argv]; + originalEnvRoot = process.env.CODEBASE_ROOT; + tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'search-decision-card-test-')); process.env.CODEBASE_ROOT = tempRoot; process.argv[2] = tempRoot; @@ -97,21 +147,30 @@ export class ProfileService { config: { skipEmbedding: true } }); await indexer.index(); - }); + }, 30000); afterEach(async () => { + if (originalArgv) { + process.argv = originalArgv; + } + + if (originalEnvRoot === undefined) { + delete process.env.CODEBASE_ROOT; + } else { + process.env.CODEBASE_ROOT = originalEnvRoot; + } + if (tempRoot) { - await fs.rm(tempRoot, { recursive: true, force: true }); + await rmWithRetries(tempRoot); tempRoot = null; } - delete process.env.CODEBASE_ROOT; - }); + }, 30000); it('intent="edit" with multiple results returns full decision card with ready field', async () => { if (!tempRoot) throw new Error('tempRoot not initialized'); const { server } = await import('../src/index.js'); - const handler = (server as any)._requestHandlers.get('tools/call'); + const handler = getToolCallHandler(server); const response = await handler({ jsonrpc: '2.0', @@ -131,12 +190,15 @@ export class ProfileService { const content = response.content[0]; expect(content.type).toBe('text'); - const parsed = JSON.parse(content.text); + const parsed = JSON.parse(content.text) as SearchToolPayload; expect(parsed.results).toBeDefined(); expect(parsed.results.length).toBeGreaterThan(0); const preflight = parsed.preflight; expect(preflight).toBeDefined(); + if (!preflight) { + throw new Error('Expected preflight payload for edit intent'); + } expect(preflight.ready).toBeDefined(); expect(typeof preflight.ready).toBe('boolean'); }); @@ -145,7 +207,7 @@ export class ProfileService { if (!tempRoot) throw new Error('tempRoot not initialized'); const { server } = await import('../src/index.js'); - const handler = (server as any)._requestHandlers.get('tools/call'); + const handler = getToolCallHandler(server); const response = await handler({ jsonrpc: '2.0', @@ -161,8 +223,12 @@ export class ProfileService { }); const content = response.content[0]; - const parsed = JSON.parse(content.text); + const parsed = JSON.parse(content.text) as SearchToolPayload; const preflight = parsed.preflight; + expect(preflight).toBeDefined(); + if (!preflight) { + throw new Error('Expected preflight payload for edit intent'); + } // preflight should have ready as minimum expect(preflight.ready).toBeDefined(); @@ -193,7 +259,7 @@ export class ProfileService { if (!tempRoot) throw new Error('tempRoot not initialized'); const { server } = await import('../src/index.js'); - const handler = (server as any)._requestHandlers.get('tools/call'); + const handler = getToolCallHandler(server); const response = await handler({ jsonrpc: '2.0', @@ -209,7 +275,7 @@ export class ProfileService { }); const content = response.content[0]; - const parsed = JSON.parse(content.text); + const parsed = JSON.parse(content.text) as SearchToolPayload; const preflight = parsed.preflight; // For explore intent, preflight should be lite: { ready, reason? } @@ -224,7 +290,7 @@ export class ProfileService { if (!tempRoot) throw new Error('tempRoot not initialized'); const { server } = await import('../src/index.js'); - const handler = (server as any)._requestHandlers.get('tools/call'); + const handler = getToolCallHandler(server); const response = await handler({ jsonrpc: '2.0', @@ -240,13 +306,13 @@ export class ProfileService { }); const content = response.content[0]; - const parsed = JSON.parse(content.text); + const parsed = JSON.parse(content.text) as SearchToolPayload; expect(parsed.results).toBeDefined(); expect(parsed.results.length).toBeGreaterThan(0); // At least some results should have a snippet - const withSnippets = parsed.results.filter((r: any) => r.snippet); + const withSnippets = parsed.results.filter((result) => result.snippet); expect(withSnippets.length).toBeGreaterThan(0); }); @@ -254,7 +320,7 @@ export class ProfileService { if (!tempRoot) throw new Error('tempRoot not initialized'); const { server } = await import('../src/index.js'); - const handler = (server as any)._requestHandlers.get('tools/call'); + const handler = getToolCallHandler(server); const response = await handler({ jsonrpc: '2.0', @@ -270,12 +336,12 @@ export class ProfileService { }); const content = response.content[0]; - const parsed = JSON.parse(content.text); + const parsed = JSON.parse(content.text) as SearchToolPayload; expect(parsed.results).toBeDefined(); // All results should not have snippet field - parsed.results.forEach((r: any) => { - expect(r.snippet).toBeUndefined(); + parsed.results.forEach((result) => { + expect(result.snippet).toBeUndefined(); }); }); @@ -283,7 +349,7 @@ export class ProfileService { if (!tempRoot) throw new Error('tempRoot not initialized'); const { server } = await import('../src/index.js'); - const handler = (server as any)._requestHandlers.get('tools/call'); + const handler = getToolCallHandler(server); const response = await handler({ jsonrpc: '2.0', @@ -299,9 +365,9 @@ export class ProfileService { }); const content = response.content[0]; - const parsed = JSON.parse(content.text); + const parsed = JSON.parse(content.text) as SearchToolPayload; - const withSnippet = parsed.results.find((r: any) => r.snippet); + const withSnippet = parsed.results.find((result) => result.snippet); if (withSnippet && withSnippet.snippet) { // Scope header should be a comment line const firstLine = withSnippet.snippet.split('\n')[0].trim(); diff --git a/tests/server-config.test.ts b/tests/server-config.test.ts index fc54534..de44feb 100644 --- a/tests/server-config.test.ts +++ b/tests/server-config.test.ts @@ -132,6 +132,55 @@ describe('loadServerConfig', () => { }); }); + it('parses analyzer hints with trimmed analyzer name and non-empty extensions', async () => { + const config = JSON.stringify({ + projects: [ + { + root: '~/hinted-repo', + analyzerHints: { + analyzer: ' generic ', + extensions: ['sfc', ' .astro ', '', 42, null] + } + } + ] + }); + + await withTempConfig(config, async (filePath) => { + process.env.CODEBASE_CONTEXT_CONFIG_PATH = filePath; + const result = await loadServerConfig(); + + expect(result).not.toBeNull(); + expect(result!.projects).toHaveLength(1); + expect(result!.projects![0].analyzerHints).toEqual({ + analyzer: 'generic', + extensions: ['sfc', '.astro'] + }); + }); + }); + + it('drops empty analyzerHints objects after parsing', async () => { + const config = JSON.stringify({ + projects: [ + { + root: '~/hinted-repo', + analyzerHints: { + analyzer: ' ', + extensions: ['', ' '] + } + } + ] + }); + + await withTempConfig(config, async (filePath) => { + process.env.CODEBASE_CONTEXT_CONFIG_PATH = filePath; + const result = await loadServerConfig(); + + expect(result).not.toBeNull(); + expect(result!.projects).toHaveLength(1); + expect(result!.projects![0].analyzerHints).toBeUndefined(); + }); + }); + it('drops server.port with a warning when value is 0', async () => { const errorSpy = vi.spyOn(console, 'error'); const config = JSON.stringify({ server: { port: 0 } });