From 146d7d660c28acecb02a70d4f1443829a2d3d8d4 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Mon, 20 Apr 2026 09:12:40 +0300 Subject: [PATCH] feat(code-nav): ship capability-gated search_symbols (CLI + MCP) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds GitHits' code-navigation surface behind the `code_navigation` capability gate: a CLI command `githits code search [query]` and the MCP tool `search_symbols`, both backed by the configured code-navigation endpoint. Finds functions, classes, modules, and doc sections inside any indexed dependency by exact-token search and returns source chunks with line ranges, symbol kind, and category. The gate remains — this is a feature- flagged rollout, not a public launch. ## CLI `githits code search` with `search-symbols` retained as a Commander alias. Package spec is `:[@]` (npm default; nine registries supported; scoped npm names preserved). Options: - `--category ` — broad filter, the preferred surface. - `--kind ` — precise-construct filter over the full 27-value taxonomy (function, method, trait, namespace, record, struct, enum, protocol, etc.). Prefer `--category` for most cases. - `--intent ` — file intent, defaults to `production` so top results are production source; `all` opts back into tests/benchmarks/examples. - `--wait ` — max indexing wait (0–60; default 20, above backend p50). Accepts `10` or `10s`; rejects `10ms`. - `--keywords "a,b,c"` + repeatable `--keyword ` — merged, deduplicated, capped at 20 client-side. - `--match-mode `, `--file `, `--limit ` (1–50, default 25), `--json`. Terminal output leads each entry with `path:startLine-endLine [kind]`, followed by the symbol name and a 3-line dedented snippet from the `code` field. Commit-SHA refs truncate to 7 chars; trivial `v`-prefix differences on the `(requested X)` annotation are suppressed; the literal `[fallback]` label on unclassified chunks is suppressed. Zero-result output surfaces the backend hint verbatim, falling back to filter-adaptive client suggestions. All enum values accepted case-insensitively. ## MCP tool `search_symbols`. Wire shape identical to the CLI `--json` envelope; deep-equal enforced by a shared parity test across five fixtures (success, zero-result, NOT_FOUND, INDEXING, INVALID_ARGUMENT). Error results always valid JSON in `content[0].text`. Tool description positions `category` as the preferred filter and documents the INDEXING retry pattern (retry with `wait_timeout_ms: 60000`). ## Shared source-of-truth helpers - `src/shared/code-navigation-defaults.ts` — DEFAULT_WAIT_TIMEOUT_MS (20s), MAX_WAIT_TIMEOUT_MS (60s ceiling), SEARCH_SYMBOLS_DEFAULT_FILE_INTENT ("PRODUCTION"), FILE_INTENT_ALL sentinel that translates to "omit the GraphQL variable" at the service layer. - `src/shared/search-symbols-request.ts` — buildSearchSymbolsParams with a `defaulted: [...]` echo so agents see which fields the client filled in silently. - `src/shared/search-symbols-response.ts` — success/error envelope builders. No underscore-prefixed keys; `retryable` top-level on errors for agents that want one boolean over a per-code table. - `src/shared/code-navigation-error-map.ts` — single classifier for the 13-code MappedError union (NOT_FOUND, VERSION_NOT_FOUND, INDEXING, UNRESOLVABLE, ACCESS_DENIED, AUTH_REQUIRED, NETWORK, INVALID_ARGUMENT, BACKEND_ERROR, TIMEOUT, RATE_LIMITED, PROTOCOL_ERROR, UNKNOWN). Table-tested; no UNKNOWN on any recognised error class. - `src/shared/package-spec.ts` — hardened parser: rejects unknown registry prefixes (`foobar:baz`), malformed specs, preserves scoped npm names. - `src/shared/normalise-keywords.ts` — comma + repeated-flag merger with cap enforcement. - `src/shared/debug-log.ts` — `GITHITS_DEBUG`-gated stderr diagnostics. PII policy: codes, detail keys, error class names; never message text, tokens, or response bodies. ## Service layer Always requests `mode: DETAILED`; always selects `code`, `resolution`, `kind`, and `category`. Nullable `data` and `searchSymbols` accepted in the Zod response schema. `fetch()` wrapped so connectivity failures surface as `CodeNavigationNetworkError`. Full dispatch on `extensions.code` covering the backend's 14 error codes, with `extensions.retryable` propagated end-to-end. The legacy `chunkType` field is no longer selected or surfaced client-side; `kind` is the single source of truth (the backend handles its own chunk-to-symbol fallback internally). Message-text heuristics retained as a rollover fallback for backends that predate the structured error codes. ## Docs - New `docs/implementation/mcp-cli-parity.md` — rules for dual-surface tools (PARITY-NAMING, PARITY-DEFAULTS, PARITY-REQUEST, PARITY-JSON-KEYS, PARITY-ERROR-ENVELOPE) with stable rule IDs cited by the parity test. - `docs/implementation/tools.md`, `docs/implementation/cli-commands.md`, `docs/implementation/config.md` updated with the new command, response shape, and troubleshooting notes (including `GITHITS_DEBUG=code-nav`). - `tools.md` carries a "Pending backend follow-ups" note for the single remaining backend-side task (distinguishing totalMatches from returnedCount and adding an offset arg for pagination). ## Tests 547 passing across 41 files. Highlights: - Classifier table covers every MappedErrorCode with fixtures for both `extensions.code` dispatch and legacy message-text fallback. - Parity test asserts CLI `--json` and MCP `content[0].text` produce deep-equal JSON for the same inputs across five fixture cases. - Service tests exercise the full GraphQL response surface: null data, VERSION_NOT_FOUND with structured details, TIMEOUT / RATE_LIMITED retryable dispatch, extensions.code-takes- precedence over message heuristics, legacy message fallback for pre-deploy backends. - CLI tests cover every option: `--category`, the expanded `--kind` taxonomy, `--wait` seconds + `s` suffix + rejection of `ms`, keyword merge + dedup + cap, case-insensitive enum values, zero-result adaptive suggestions, capability-gate registration. - Debug-log test asserts PII policy (no `message` field in payloads) and scope-syntax parsing. Supersedes the earlier gated code-navigation PoC that was scaffolded under a feature flag — the capability gate remains (commands only register when the startup token advertises `code_navigation`, when `GITHITS_CODE_NAVIGATION=1` is set locally, when an opaque `GITHITS_API_TOKEN` is present, or when stored auth has expired), but the implementation underneath is now the production surface. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/implementation/auth.md | 6 + docs/implementation/cli-commands.md | 34 +- docs/implementation/config.md | 14 +- docs/implementation/mcp-cli-parity.md | 149 +++ docs/implementation/tools.md | 31 +- src/cli.ts | 16 +- src/commands/auth-status.test.ts | 29 +- src/commands/auth-status.ts | 44 +- src/commands/code/index.test.ts | 80 ++ src/commands/code/index.ts | 63 ++ src/commands/code/search-symbols.test.ts | 727 +++++++++++++++ src/commands/code/search-symbols.ts | 519 +++++++++++ src/commands/index.ts | 1 + src/commands/mcp.test.ts | 43 +- src/commands/mcp.ts | 41 +- src/container.ts | 105 +++ .../code-navigation-capability.test.ts | 30 + src/services/code-navigation-capability.ts | 72 ++ src/services/code-navigation-service.test.ts | 728 +++++++++++++++ src/services/code-navigation-service.ts | 856 ++++++++++++++++++ src/services/config.test.ts | 74 ++ src/services/config.ts | 32 + src/services/execute-with-token-refresh.ts | 38 + src/services/index.ts | 41 +- src/services/refreshing-githits-service.ts | 34 +- src/services/test-helpers.ts | 52 ++ src/shared/code-navigation-defaults.ts | 52 ++ src/shared/code-navigation-error-map.test.ts | 357 ++++++++ src/shared/code-navigation-error-map.ts | 243 +++++ src/shared/code-navigation.ts | 134 +++ src/shared/debug-log.test.ts | 90 ++ src/shared/debug-log.ts | 59 ++ src/shared/index.ts | 51 ++ src/shared/normalise-keywords.test.ts | 65 ++ src/shared/normalise-keywords.ts | 59 ++ src/shared/package-spec.test.ts | 113 +++ src/shared/package-spec.ts | 142 +++ src/shared/search-symbols-request.test.ts | 86 ++ src/shared/search-symbols-request.ts | 117 +++ src/shared/search-symbols-response.test.ts | 115 +++ src/shared/search-symbols-response.ts | 150 +++ src/tools/code-navigation-shared.ts | 124 +++ src/tools/index.ts | 1 + src/tools/search-symbols-parity.test.ts | 239 +++++ src/tools/search-symbols.test.ts | 211 +++++ src/tools/search-symbols.ts | 236 +++++ src/tools/types.ts | 2 + 47 files changed, 6445 insertions(+), 60 deletions(-) create mode 100644 docs/implementation/mcp-cli-parity.md create mode 100644 src/commands/code/index.test.ts create mode 100644 src/commands/code/index.ts create mode 100644 src/commands/code/search-symbols.test.ts create mode 100644 src/commands/code/search-symbols.ts create mode 100644 src/services/code-navigation-capability.test.ts create mode 100644 src/services/code-navigation-capability.ts create mode 100644 src/services/code-navigation-service.test.ts create mode 100644 src/services/code-navigation-service.ts create mode 100644 src/services/config.test.ts create mode 100644 src/services/execute-with-token-refresh.ts create mode 100644 src/shared/code-navigation-defaults.ts create mode 100644 src/shared/code-navigation-error-map.test.ts create mode 100644 src/shared/code-navigation-error-map.ts create mode 100644 src/shared/code-navigation.ts create mode 100644 src/shared/debug-log.test.ts create mode 100644 src/shared/debug-log.ts create mode 100644 src/shared/normalise-keywords.test.ts create mode 100644 src/shared/normalise-keywords.ts create mode 100644 src/shared/package-spec.test.ts create mode 100644 src/shared/package-spec.ts create mode 100644 src/shared/search-symbols-request.test.ts create mode 100644 src/shared/search-symbols-request.ts create mode 100644 src/shared/search-symbols-response.test.ts create mode 100644 src/shared/search-symbols-response.ts create mode 100644 src/tools/code-navigation-shared.ts create mode 100644 src/tools/search-symbols-parity.test.ts create mode 100644 src/tools/search-symbols.test.ts create mode 100644 src/tools/search-symbols.ts diff --git a/docs/implementation/auth.md b/docs/implementation/auth.md index d5d29865..4a095f04 100644 --- a/docs/implementation/auth.md +++ b/docs/implementation/auth.md @@ -19,6 +19,8 @@ The two methods exist because OAuth provides full access but requires a browser, > **The container resolves auth at startup.** The `createContainer()` function checks for `GITHITS_API_TOKEN` first — if set, it takes precedence even when OAuth tokens are stored. If not set, it loads stored OAuth tokens and attempts auto-refresh if expired. See `src/container.ts` for the resolution logic. +For the code navigation PoC, the CLI also inspects the current bearer token for Supabase `feature_flags` claims. This is a local capability hint only: the backend still enforces access. The CLI decodes JWT payloads without signature verification, treating opaque or malformed tokens as `unknown` capability. + ## OAuth PKCE Flow The login command (`src/commands/login.ts`) orchestrates a 9-step OAuth flow (matching the `// Step N:` comments in the code): @@ -42,6 +44,7 @@ Tokens are JWTs with a configurable expiration (typically 1 hour). The CLI handl - **Proactive refresh** — When 90% of the token lifetime has elapsed (e.g., at ~54 minutes for a 1-hour token), the `TokenManager` refreshes before expiry. This avoids a stale-token window. - **Reactive refresh** — If the token is already expired, refresh is attempted immediately. - **401 retry** — The `RefreshingGitHitsService` decorator wraps `GitHitsServiceImpl` and retries once on `AuthenticationError`, calling `forceRefresh()` to handle clock skew or server-side revocation. +- **Shared retry helper** — GitHits REST calls and code navigation GraphQL calls both use the same token-refresh/retry flow, so auth drift is handled consistently across both service families. - **Concurrent coalescing** — Multiple concurrent refresh requests share a single in-flight Promise. - **At login** (`src/commands/login.ts`) — Checks if existing token is still valid before starting the OAuth flow. Respects `--force` flag to re-authenticate regardless. - **At auth status** (`src/commands/auth-status.ts`) — Attempts refresh before reporting "Token expired". @@ -158,6 +161,9 @@ The `hasValidToken` flag is checked by `requireAuth()` in `src/commands/mcp.ts` | `src/container.ts` | Dependency wiring, keychain probe with fallback | | `src/services/token-manager.ts` | `TokenProvider` interface, `TokenManager` (proactive refresh, coalescing) | | `src/services/refreshing-githits-service.ts` | `GitHitsService` decorator with token refresh and 401 retry | +| `src/services/execute-with-token-refresh.ts` | Shared helper for token-authenticated retry-on-refresh flows | +| `src/services/code-navigation-capability.ts` | Local JWT capability decoding for `code_navigation` | +| `src/services/code-navigation-service.ts` | GraphQL code navigation client using the shared refresh helper | | `src/services/auth-service.ts` | OAuth operations (DCR, PKCE, token exchange, callback server) | | `src/services/auth-storage.ts` | `AuthStorage` interface and file-based implementation | | `src/services/keyring-service.ts` | `KeyringService` interface wrapping `@napi-rs/keyring` | diff --git a/docs/implementation/cli-commands.md b/docs/implementation/cli-commands.md index b6f70f6e..c9f75583 100644 --- a/docs/implementation/cli-commands.md +++ b/docs/implementation/cli-commands.md @@ -2,7 +2,7 @@ ## Purpose -The CLI exposes three commands (`search`, `languages`, `feedback`) that mirror the MCP tools for direct human and agent use. These commands share business logic with the MCP tools through the same `GitHitsService` and shared utilities, but format output for terminal consumption instead of MCP tool results. +The CLI exposes three primary commands (`search`, `languages`, `feedback`) that mirror the public MCP tools for direct human and agent use. It also has a capability-gated `code search` command that searches indexed dependency source via the code-navigation backend. These commands share business logic with the MCP tools through the same service interfaces and shared utilities, but format output for terminal consumption instead of MCP tool results. ## Commands @@ -12,6 +12,7 @@ The CLI exposes three commands (`search`, `languages`, `feedback`) that mirror t | `search ` | `-l, --lang ` | `--license `, `--explain`, `--json` | Search for code examples | | `languages [query]` | — | `--json` | List or filter supported languages | | `feedback ` | `--accept` or `--reject` | `-m, --message `, `--json` | Submit feedback on a search result | +| `code search [query]` | package spec | `--keywords`, `--keyword`, `--match-mode`, `--category`, `--kind`, `--file`, `--intent`, `--limit`, `--wait`, `--json` | Search indexed dependency source code | ### `githits init` @@ -60,6 +61,31 @@ githits feedback abc123 --accept --message "Solved my problem" --json `--accept` and `--reject` are mutually exclusive (enforced by Commander's `.conflicts()` API). At least one must be provided (validated in the action function). JSON output is `{ "success": true, "message": "..." }`. +### `githits code search` + +``` +githits code search npm:express middleware +githits code search npm:express middleware --intent all +githits code search pypi:requests timeout --category callable --limit 10 +githits code search crates:serde Serialize --kind trait --limit 5 +githits code search npm:@types/node Buffer --file src/ --json +githits code search npm:express --keywords "router,handler" --match-mode and +``` + +Finds functions, classes, modules, and doc sections inside an indexed dependency by exact-token matches. Unlike `githits search`, which performs natural-language code example search, `code search` is symbol-oriented and returns source chunks with line ranges. + +**Package spec.** `:[@]`. Omit the registry to default to `npm`. Supported registries: `npm`, `pypi`, `hex`, `crates`, `nuget`, `maven`, `zig`, `vcpkg`, `packagist`. Scoped npm names are supported (`npm:@types/node`). + +**Filtering by symbol shape.** Prefer `--category` for broad filtering (`callable`, `type`, `module`, `data`, `documentation`) — it works across the full 27-value kind taxonomy without enumerating individual kinds. Reach for `--kind` when you want a specific construct, e.g. `--kind trait` (Rust) or `--kind namespace` (C#/C++/PHP). + +**Defaults.** `--intent production` filters to production source by default so top results are not dominated by tests, benchmarks, or examples. Use `--intent all` to include every file intent. `--wait` defaults to 20 seconds (above the p50 indexing time of ~11 s); first-time queries against an unindexed package may need `--wait 60` (the backend ceiling) to block until indexing completes. On an INDEXING error, the response message points out the retry options. + +**Output.** Default terminal output leads each entry with `path:startLine-endLine [kind]`, followed by the symbol name and a 3-line dedented snippet. `--json` emits the shared success/error envelope also produced by the MCP `search_symbols` tool — see [`mcp-cli-parity.md`](./mcp-cli-parity.md) for the wire contract. The command is registered as `code search` with `code search-symbols` as a Commander alias. + +**Capability gate.** The `code` group is registered only when the startup token advertises `code_navigation`, when `GITHITS_CODE_NAVIGATION=1` is set for local override, when `GITHITS_API_TOKEN` is present (opaque env token), or when stored auth is expired. + +**Troubleshooting.** Set `GITHITS_DEBUG=code-nav` to emit single-line JSON diagnostics to stderr on error paths. Include the output when filing an issue. Debug payloads never contain query text, tokens, or response bodies. + ## Architecture ``` @@ -80,7 +106,8 @@ Each command follows this pattern: | Shared Module | Used By | |---|---| -| `GitHitsService` (via container) | MCP tools + all CLI commands | +| `GitHitsService` (via container) | Public MCP tools + primary CLI commands | +| `CodeNavigationService` (via container) | Capability-gated `search_symbols` MCP tool + `githits code search` CLI command | | `filterLanguages()` from `src/shared/language-filter.ts` | `search_language` MCP tool + `languages` CLI command | | `requireAuth()` from `src/shared/require-auth.ts` | MCP server startup + all CLI commands | @@ -130,5 +157,6 @@ All commands support two output modes: ## Related Documentation -- `docs/implementation/tools.md` — MCP tools that share business logic with these commands +- [`tools.md`](./tools.md) — MCP tools that share business logic with these commands +- [`mcp-cli-parity.md`](./mcp-cli-parity.md) — rules for dual-surface tools (CLI ↔ MCP) - `docs/guidelines/ARCHITECTURAL_GUIDELINES.md` — DI and testing patterns diff --git a/docs/implementation/config.md b/docs/implementation/config.md index 5b1a0701..1a039f18 100644 --- a/docs/implementation/config.md +++ b/docs/implementation/config.md @@ -14,6 +14,7 @@ GitHits separates its MCP server (which handles OAuth discovery and the MCP prot |---|---|---|---| | **MCP URL** | `https://mcp.githits.com` | `GITHITS_MCP_URL` | OAuth discovery (`.well-known`), DCR registration, auth flow | | **API URL** | `https://api.githits.com` | `GITHITS_API_URL` | REST endpoints (`/search`, `/languages`, `/feedbacks`) | +| **Code navigation URL** | configured per environment | `GITHITS_CODE_NAV_URL` | GraphQL code-navigation endpoint used by `search_symbols` / `githits code search` | > **These are different services.** Setting only one won't work for custom environments. Both must be overridden together when pointing to a non-production backend. @@ -37,13 +38,22 @@ The container (`src/container.ts`) resolves authentication in priority order: | `/languages` | Full access | Full access | Blocked | | `/feedbacks` | Full access | Full access | Blocked | +Code navigation is different from the REST endpoints above: + +- the CLI resolves the code-navigation endpoint from `GITHITS_CODE_NAV_URL`; custom GitHits environments must set this explicitly (no default inference) +- MCP registration requires a startup token whose JWT advertises `code_navigation`; opaque env tokens are treated optimistically and left to backend enforcement +- the `githits code search` CLI command is shown when the startup token advertises `code_navigation`, when an opaque `GITHITS_API_TOKEN` is present, or when `GITHITS_CODE_NAVIGATION=1` is set locally +- the backend remains authoritative and can still deny access even if the CLI exposes the local command + ## Environment Variables | Variable | Purpose | Example | |---|---|---| | `GITHITS_MCP_URL` | Override MCP server URL | `http://localhost:7071/mcp` | | `GITHITS_API_URL` | Override REST API URL | `http://localhost:8000` | +| `GITHITS_CODE_NAV_URL` | Override code navigation GraphQL URL | `http://localhost:4000` | | `GITHITS_API_TOKEN` | API token for authentication | `ghi-abc123...` | +| `GITHITS_CODE_NAVIGATION` | Override capability gate and expose `code` CLI commands locally | `1` | ## Local Storage @@ -65,8 +75,10 @@ Environment variables └─ src/container.ts (createContainer) ├─ mcpUrl → passed to auth commands, used as storage key ├─ apiUrl → passed to GitHitsServiceImpl constructor + ├─ codeNavigationUrl → passed to CodeNavigationServiceImpl when configured ├─ apiToken → resolved from env var or OAuth storage - └─ hasValidToken → gates MCP server startup + ├─ hasValidToken → gates authenticated commands + └─ codeNavigationCapability / CLI override → gates code navigation exposure ``` Commands receive the full `Dependencies` object. Services receive only what they need (e.g., `GitHitsServiceImpl` gets `apiUrl` and `token`). diff --git a/docs/implementation/mcp-cli-parity.md b/docs/implementation/mcp-cli-parity.md new file mode 100644 index 00000000..e383912f --- /dev/null +++ b/docs/implementation/mcp-cli-parity.md @@ -0,0 +1,149 @@ +# MCP ↔ CLI Parity + +## Purpose + +Both the MCP tool (`search_symbols`) and the CLI command (`githits code +search`) expose the code-navigation feature. Users and agents should be +able to cross the surface boundary without learning a new shape — +parameter names differ per surface convention, but defaults, error +behaviour, and the serialised payload do not. + +This document is **the pattern and checklist derived from +`search_symbols`**, not a permanent contract for every future +code-navigation tool. When tool #2 lands with a good reason to break a +rule, extend the rule or add a new one here rather than bending tool +#1's shape to fit. + +## Rule IDs + +Rule IDs are cited from parity tests (e.g. +`src/tools/search-symbols-parity.test.ts`) in file header comments so +the test suite anchors the doc. + +### `PARITY-NAMING` + +- **MCP arguments** use `snake_case`. They are the wire contract agents + see; the JSON-schema description is the primary UX. +- **CLI flags** use `--kebab-case`. They are the user-facing surface. +- **Public enum values** are lowercase strings on both surfaces + (`production`, `test`, `summary`, `all`). +- **Backend coercion** from lowercase enum values to the uppercase + GraphQL enum variants lives in `src/shared/code-navigation.ts` + (`toSearchSymbolsFileIntent`, `toSearchSymbolsKind`, + `toSearchSymbolsMatchMode`). + +### `PARITY-DEFAULTS` + +- Both surfaces import defaults from + `src/shared/code-navigation-defaults.ts`. They never diverge + silently. +- Cross-tool defaults (e.g. `DEFAULT_WAIT_TIMEOUT_MS`) live without a + prefix. Tool-specific defaults carry a tool-name prefix + (`SEARCH_SYMBOLS_DEFAULT_FILE_INTENT`) so tool #2 declaring its own + defaults in the same file does not cause naming churn. +- When a surface fills in a default for the caller, that default value + is applied at the shared request builder + (`buildSearchSymbolsParams`) — not at the surface — so both + surfaces apply defaults at the same point and under the same + conditions. + +### `PARITY-REQUEST` + +- Request construction for a dual-surface tool routes through a single + shared helper (`buildSearchSymbolsParams`). The helper: + 1. Fills in defaults, + 2. Translates user-facing sentinel values (e.g. `FILE_INTENT_ALL`) + into their wire-level equivalents, + 3. Returns a `defaulted` array naming the fields that were + client-applied, which feeds the response `query.defaulted` echo. +- Cross-tool helpers (error classification, target resolution) live in + `src/shared/` without a tool-name prefix. Per-tool request builders + live in `src/shared/-request.ts`. + +### `PARITY-JSON-KEYS` + +- The CLI `--json` payload and the MCP tool text payload, parsed as + JSON, must `deepEqual` for equivalent inputs. +- String-equal is explicitly not the contract. Key ordering, + whitespace, and trailing newlines are free. +- **No leading-underscore keys.** `warning`, `hint`, and all other + status fields are plain. +- `query` echoes the resolved request parameters. `query.defaulted` is + a string array naming the fields whose values the client filled in. + Empty array when every field was caller-set. +- `fileIntent` is echoed as a lowercase enum value, or the literal + `"all"` when the caller chose the all-intents sentinel. +- `returnedCount` is an explicit echo of `results.length`. + `totalMatches` is the backend-provided total (equal to + `returnedCount` today; see backend request B2 for the future + upgrade). + +### `PARITY-ERROR-ENVELOPE` + +- Every error result, on both surfaces, carries + `{ error: string, code: MappedErrorCode, details?: object }`. +- `code` is mandatory. `UNKNOWN` is a last resort — every named error + class in the code-navigation stack maps to a specific code. The + classifier is tested by table in + `src/shared/code-navigation-error-map.test.ts`; that test is the + enforcement mechanism, not a convention. +- MCP error text is always valid JSON. A client that parses + `content[0].text` gets the same envelope whether the result is + success or error. + +### `PARITY-NO-SHARED-TERMINAL-FORMATTER` + +- Terminal rendering is CLI-local. +- MCP emits JSON text only; there is no equivalent pretty-print. +- Small semantic helpers that happen to be reused (e.g. a zero-result + message template) may move into `src/shared/` once two tools need + them. Default: keep formatting surface-local. + +## Checklist for adding a new dual-surface tool + +When a new tool lands with both MCP and CLI surfaces: + +- [ ] Tool-specific defaults added to + `src/shared/code-navigation-defaults.ts` with a `TOOLNAME_` prefix. +- [ ] Request builder at `src/shared/-request.ts`. Both surfaces + import it. +- [ ] Error classifier reused (`mapCodeNavigationError`). Add new + `MappedErrorCode` variants only when a genuinely new error class + exists; cover the new branch in the table test. +- [ ] Response builder at `src/shared/-response.ts` emitting the + shared success and error envelopes. JSON shape matches + `PARITY-JSON-KEYS` rules. +- [ ] Parity test at `src/tools/-parity.test.ts` that cites the + rule IDs it enforces (in a file header comment). Covers at minimum: + successful search, zero-result, two error codes. +- [ ] MCP tool description mirrored in the backend MCP server before + public release (coordination point — the CLI may lead; the backend + PR URL is recorded in the plan doc before the frontend PR merges). + +## Non-goals + +- **Shared terminal formatter.** CLI terminal output and MCP JSON are + different products. A shared prose-rendering layer is premature + until at least two tools prove the shape is common. +- **Shared MCP description copy.** Each tool's description targets a + different decision the agent is making. Copy is not reusable. + +## When to extend this document + +- A new rule ID is added when a future tool exposes a pattern that is + genuinely cross-tool (naming, shared helper location, JSON shape). +- An existing rule is revised when the tool that originally + established it (`search_symbols`) turns out to be the outlier. +- The checklist grows by exactly one bullet per rule. Keep it short. + +## Related files + +| File | Role | +|---|---| +| `src/shared/code-navigation-defaults.ts` | Canonical defaults and sentinels. | +| `src/shared/code-navigation-error-map.ts` | `mapCodeNavigationError` classifier and `MappedError` union. | +| `src/shared/search-symbols-request.ts` | Shared request builder for `search_symbols`. | +| `src/shared/search-symbols-response.ts` | Shared JSON envelope builders for `search_symbols`. | +| `src/tools/search-symbols.ts` | MCP tool definition. | +| `src/commands/code/search-symbols.ts` | CLI command. | +| `src/tools/search-symbols-parity.test.ts` | Parity tests (cite rule IDs). | diff --git a/docs/implementation/tools.md b/docs/implementation/tools.md index 018404a7..a8cfd2ff 100644 --- a/docs/implementation/tools.md +++ b/docs/implementation/tools.md @@ -22,6 +22,15 @@ Both expose the same tools with identical names, parameters, and descriptions. T | `search` | `query`, `language`, `license_mode?` | Search for code examples. Returns markdown-formatted results. | | `search_language` | `query` | Find supported programming language names before searching. | | `feedback` | `solution_id`, `accepted`, `feedback_text?` | Submit feedback on a search result to improve quality. | +| `search_symbols` | `target`, `query?`, `keywords?`, `match_mode?`, `category?`, `kind?`, `file_path?`, `limit?`, `file_intent?`, `wait_timeout_ms?` | Capability-gated code navigation search over indexed dependency source. | + +`search_symbols` is only registered when the startup token advertises `code_navigation` capability. The code-navigation endpoint can be overridden via `GITHITS_CODE_NAV_URL` for local development. Capability gating keeps the tool hidden from public/default flows while the feature is still rolling out. + +`search_symbols` shares request-construction, error classification, and JSON-payload shape with the CLI `githits code search` command via shared helpers under `src/shared/`. The parity rules are codified in [`mcp-cli-parity.md`](./mcp-cli-parity.md); the parity test (`src/tools/search-symbols-parity.test.ts`) asserts that both surfaces emit identical JSON for equivalent inputs. + +**Response shape.** `search_symbols` always requests `mode: DETAILED` and always selects the `code`, `resolution`, `kind`, and `category` fields. Responses include each match's full source code, precise symbol kind (from the unified symbol taxonomy), broad symbol category, and line range. The tool does not expose `mode` or `verbose` inputs — the service layer makes the choice once so both surfaces get the richest response without callers juggling the knobs. The legacy `chunkType` field is no longer selected or surfaced client-side; `kind` is the single source of truth for taxonomy. The text payload is always valid JSON (whether the result is success or error), so MCP clients can parse `content[0].text` without branching on `isError`. + +**Filter parameters.** `category` is the preferred surface for filtering (`callable`, `type`, `module`, `data`, `documentation`); `kind` is for the "I want this specific construct" case (27-value taxonomy). Both filters may be combined; both route through the shared `buildSearchSymbolsParams` helper. ## Entry Points @@ -37,16 +46,16 @@ Both paths call `requireAuth()` before starting the server. See `src/commands/mc ``` MCP SDK Server (src/commands/mcp.ts) └─ registers tools using deps.githitsService from container - └─ each tool: createXxxTool(githitsService) - └─ ToolDefinition { name, description, schema, handler } - └─ handler calls GitHitsService methods - └─ GitHitsServiceImpl makes REST API calls + └─ each tool: createXxxTool(service) + └─ ToolDefinition { name, description, schema, handler, annotations? } + └─ handler calls GitHitsService or CodeNavigationService methods + └─ service implementation makes REST or GraphQL calls ``` The layering is intentional: - **Tool definitions** (`src/tools/*.ts`) own the MCP contract: names, descriptions, schemas, and response formatting -- **GitHitsService** (`src/services/githits-service.ts`) owns the HTTP transport: endpoints, headers, error mapping +- **GitHitsService / CodeNavigationService** own the HTTP transport: endpoints, headers, error mapping - **MCP server setup** (`src/commands/mcp.ts`) owns wiring: creates the service, registers tools with the MCP SDK This separation means tool logic can be tested without HTTP calls, and service logic can be tested without MCP SDK dependencies. @@ -61,7 +70,7 @@ Each tool follows the same structure. See `src/tools/search.ts` for the canonica 4. Export a `createXxxTool(service)` factory function returning a `ToolDefinition` 5. The handler calls the service and wraps the result with `textResult()` or lets `withErrorHandling()` catch errors -> **Descriptions are copy-pasted from the backend.** This is deliberate. The description is what LLM clients see when deciding whether to use a tool. Even small wording differences could change tool selection behavior. +> **Descriptions are kept in sync with the backend MCP server.** Changes happen through coordinated PRs — the frontend may lead wording, but the backend mirrors before public release. The description is what LLM clients see when deciding whether to use a tool; even small wording differences could change tool selection behaviour. ## Adding a New Tool @@ -113,13 +122,19 @@ See `docs/guidelines/TESTING.md` for the full testing pattern. | `src/tools/feedback.ts` | Simplest tool (direct service delegation) | | `src/tools/types.ts` | `ToolDefinition` interface, `textResult`/`errorResult` helpers | | `src/tools/shared.ts` | `withErrorHandling()` wrapper | -| `src/services/test-helpers.ts` | `createMockGitHitsService()` factory (and all other service mocks) | +| `src/services/test-helpers.ts` | `createMockGitHitsService()` and `createMockCodeNavigationService()` factories | | `src/commands/mcp.ts` | Tool registration, MCP server setup, and TTY detection | | `src/services/githits-service.ts` | REST API client (what tools and CLI commands call) | +| `src/services/code-navigation-service.ts` | GraphQL code navigation client for `search_symbols` | | `src/shared/language-filter.ts` | Pure `filterLanguages()` function shared between MCP tool and CLI | +## Pending backend follow-ups + +- **`totalMatches` / pagination.** `searchSymbols.totalMatches` currently tracks `results.length` on the wire — equal to `returnedCount`, not the total before `limit`. Once the backend distinguishes them (and adds an `offset` arg), the CLI header flips from *"N match(es) (more available)"* to *"Showing N of M"* and a `--offset` flag can land for pagination. Tracked with the backend team; no frontend changes needed in the meantime. + ## Related Documentation - Backend tool definitions: `githits-backend/githits/api/mcp/server.py` -- `docs/implementation/cli-commands.md` — CLI commands that mirror these MCP tools +- [`mcp-cli-parity.md`](./mcp-cli-parity.md) — rules for dual-surface tools (CLI ↔ MCP) +- [`cli-commands.md`](./cli-commands.md) — CLI commands that mirror these MCP tools - `docs/guidelines/ARCHITECTURAL_GUIDELINES.md` — service isolation and testing patterns diff --git a/src/cli.ts b/src/cli.ts index f7d2ce47..2b5884bf 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,6 +3,7 @@ import { Command } from "commander"; import { version } from "../package.json"; import { registerAuthStatusCommand, + registerCodeCommandGroup, registerFeedbackCommand, registerInitCommand, registerLanguagesCommand, @@ -52,6 +53,9 @@ registerMcpCommand(program); registerSearchCommand(program); registerLanguagesCommand(program); registerFeedbackCommand(program); +if (shouldRegisterCodeCommands(process.argv.slice(2))) { + await registerCodeCommandGroup(program); +} // Auth status as subcommand of `auth` const authCommand = program @@ -60,4 +64,14 @@ const authCommand = program .description("Manage authentication with GitHits."); registerAuthStatusCommand(authCommand); -program.parse(); +await program.parseAsync(); + +function shouldRegisterCodeCommands(args: string[]): boolean { + const [firstArg] = args; + return ( + firstArg === "code" || + firstArg === "help" || + firstArg === "--help" || + firstArg === "-h" + ); +} diff --git a/src/commands/auth-status.test.ts b/src/commands/auth-status.test.ts index 5028afb9..0a26e37d 100644 --- a/src/commands/auth-status.test.ts +++ b/src/commands/auth-status.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, mock, spyOn } from "bun:test"; import { + createJwtToken, createMockAuthService, createMockAuthStorage, createValidTokenData, @@ -15,6 +16,7 @@ describe("authStatusAction", () => { authStorage?: ReturnType; authService?: ReturnType; envApiToken?: string; + codeNavigationCliOverrideEnabled?: boolean; } = {}, ) { return { @@ -22,6 +24,8 @@ describe("authStatusAction", () => { authService: overrides.authService ?? createMockAuthService(), mcpUrl, envApiToken: overrides.envApiToken ?? undefined, + codeNavigationCliOverrideEnabled: + overrides.codeNavigationCliOverrideEnabled ?? false, }; } @@ -42,6 +46,7 @@ describe("authStatusAction", () => { loadTokens: mock(() => Promise.resolve( createValidTokenData({ + accessToken: createJwtToken({ feature_flags: ["code_navigation"] }), expiresAt: new Date(Date.now() + 3600_000).toISOString(), }), ), @@ -56,6 +61,7 @@ describe("authStatusAction", () => { expect(output).toContain(mcpUrl); expect(output).toContain("Storage:"); expect(output).toContain("System keychain (githits)"); + expect(output).toContain("Code navigation: enabled"); consoleSpy.mockRestore(); }); @@ -87,6 +93,7 @@ describe("authStatusAction", () => { expiresAt: new Date(Date.now() - 3600_000).toISOString(), }); const refreshedToken = createValidTokenData({ + accessToken: createJwtToken({ feature_flags: ["code_navigation"] }), expiresAt: new Date(Date.now() + 3600_000).toISOString(), }); @@ -107,18 +114,36 @@ describe("authStatusAction", () => { expect(output).toContain("Authenticated (token refreshed)"); expect(output).not.toContain("Token expired"); expect(output).toContain("Storage:"); + expect(output).toContain("Code navigation: enabled"); consoleSpy.mockRestore(); }); it("shows env token info when envApiToken is provided", async () => { const consoleSpy = spyOn(console, "log").mockImplementation(() => {}); - await authStatusAction(createDeps({ envApiToken: "ghi-test-token-12345" })); + await authStatusAction( + createDeps({ + envApiToken: createJwtToken({ feature_flags: ["code_navigation"] }), + }), + ); const output = consoleSpy.mock.calls.map((c) => c[0]).join("\n"); expect(output).toContain("environment variable"); expect(output).toContain("GITHITS_API_TOKEN"); - expect(output).not.toContain("only work for search"); + expect(output).toContain("Code navigation: enabled"); + consoleSpy.mockRestore(); + }); + + it("shows unknown capability and active CLI override when unauthenticated", async () => { + const consoleSpy = spyOn(console, "log").mockImplementation(() => {}); + + await authStatusAction( + createDeps({ codeNavigationCliOverrideEnabled: true }), + ); + + const output = consoleSpy.mock.calls.map((c) => c[0]).join("\n"); + expect(output).toContain("Code navigation: unknown"); + expect(output).toContain("CLI override: enabled"); consoleSpy.mockRestore(); }); diff --git a/src/commands/auth-status.ts b/src/commands/auth-status.ts index f7f4b2f3..b9f6b533 100644 --- a/src/commands/auth-status.ts +++ b/src/commands/auth-status.ts @@ -1,13 +1,21 @@ import type { Command } from "commander"; import { createContainer } from "../container.js"; -import type { AuthService, AuthStorage } from "../services/index.js"; -import { refreshExpiredToken } from "../services/index.js"; +import type { + AuthService, + AuthStorage, + CodeNavigationCapability, +} from "../services/index.js"; +import { + getCodeNavigationCapability, + refreshExpiredToken, +} from "../services/index.js"; export interface AuthStatusDependencies { authStorage: AuthStorage; authService: AuthService; mcpUrl: string; envApiToken: string | undefined; + codeNavigationCliOverrideEnabled: boolean; } /** @@ -33,19 +41,37 @@ function displayExpiry(expiresAt: string | null): void { } } +function displayCodeNavigationStatus( + capability: CodeNavigationCapability, + overrideEnabled: boolean, +): void { + console.log(` Code navigation: ${capability}`); + console.log(` CLI override: ${overrideEnabled ? "enabled" : "disabled"}`); +} + /** * Core auth status logic, separated for testability. */ export async function authStatusAction( deps: AuthStatusDependencies, ): Promise { - const { authStorage, authService, mcpUrl, envApiToken } = deps; + const { + authStorage, + authService, + mcpUrl, + envApiToken, + codeNavigationCliOverrideEnabled, + } = deps; // Check for env API token if (envApiToken) { console.log("Authenticated via environment variable.\n"); console.log(` Source: GITHITS_API_TOKEN`); console.log(` Token: ${envApiToken.slice(0, 8)}...`); + displayCodeNavigationStatus( + getCodeNavigationCapability(envApiToken), + codeNavigationCliOverrideEnabled, + ); return; } @@ -54,6 +80,8 @@ export async function authStatusAction( if (!auth) { console.log("Not authenticated.\n"); console.log(` Environment: ${mcpUrl}\n`); + displayCodeNavigationStatus("unknown", codeNavigationCliOverrideEnabled); + console.log(""); console.log("To authenticate:"); console.log(" githits login"); return; @@ -69,9 +97,13 @@ export async function authStatusAction( if (refreshed) { // Reload tokens to get updated expiry const refreshedAuth = await authStorage.loadTokens(mcpUrl); + const capability = getCodeNavigationCapability( + refreshedAuth?.accessToken, + ); console.log("Authenticated (token refreshed).\n"); console.log(` Environment: ${mcpUrl}`); displayExpiry(refreshedAuth?.expiresAt ?? null); + displayCodeNavigationStatus(capability, codeNavigationCliOverrideEnabled); console.log(`\n Storage: ${authStorage.getStorageLocation()}`); return; } @@ -81,6 +113,8 @@ export async function authStatusAction( console.log( ` Expired: ${new Date(auth.expiresAt).toLocaleDateString()}\n`, ); + displayCodeNavigationStatus("unknown", codeNavigationCliOverrideEnabled); + console.log(""); console.log("Run `githits login` to re-authenticate."); return; } @@ -88,6 +122,10 @@ export async function authStatusAction( console.log("Authenticated.\n"); console.log(` Environment: ${mcpUrl}`); displayExpiry(auth.expiresAt); + displayCodeNavigationStatus( + getCodeNavigationCapability(auth.accessToken), + codeNavigationCliOverrideEnabled, + ); console.log(`\n Storage: ${authStorage.getStorageLocation()}`); } diff --git a/src/commands/code/index.test.ts b/src/commands/code/index.test.ts new file mode 100644 index 00000000..199be7b3 --- /dev/null +++ b/src/commands/code/index.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "bun:test"; +import { Command } from "commander"; +import { registerCodeCommandGroup } from "./index.js"; + +describe("registerCodeCommandGroup", () => { + it("does not register code commands without override or capability", async () => { + const program = new Command(); + await registerCodeCommandGroup(program, { + codeNavigationUrl: "https://nav.example.com", + overrideEnabled: false, + capability: "disabled", + }); + + expect(program.commands.some((command) => command.name() === "code")).toBe( + false, + ); + }); + + it("registers the code command group when capability is enabled", async () => { + const program = new Command(); + await registerCodeCommandGroup(program, { + codeNavigationUrl: "https://nav.example.com", + overrideEnabled: false, + capability: "enabled", + }); + + const codeCommand = program.commands.find( + (command) => command.name() === "code", + ); + expect(codeCommand).toBeDefined(); + expect( + codeCommand?.commands.some((command) => command.name() === "search"), + ).toBe(true); + }); + + it("registers the code command group when override and URL are set", async () => { + const program = new Command(); + await registerCodeCommandGroup(program, { + codeNavigationUrl: "https://nav.example.com", + overrideEnabled: true, + capability: "disabled", + }); + + const codeCommand = program.commands.find( + (command) => command.name() === "code", + ); + expect(codeCommand).toBeDefined(); + expect( + codeCommand?.commands.some((command) => command.name() === "search"), + ).toBe(true); + }); + + it("registers the code command group for opaque env tokens", async () => { + const program = new Command(); + await registerCodeCommandGroup(program, { + codeNavigationUrl: "https://nav.example.com", + overrideEnabled: false, + capability: "unknown", + envTokenPresent: true, + }); + + expect(program.commands.some((command) => command.name() === "code")).toBe( + true, + ); + }); + + it("registers the code command group for expired stored auth", async () => { + const program = new Command(); + await registerCodeCommandGroup(program, { + codeNavigationUrl: "https://nav.example.com", + overrideEnabled: false, + capability: "unknown", + expiredStoredAuth: true, + }); + + expect(program.commands.some((command) => command.name() === "code")).toBe( + true, + ); + }); +}); diff --git a/src/commands/code/index.ts b/src/commands/code/index.ts new file mode 100644 index 00000000..b773215d --- /dev/null +++ b/src/commands/code/index.ts @@ -0,0 +1,63 @@ +import type { Command } from "commander"; +import { resolveStartupCodeNavigationRegistrationState } from "../../container.js"; +import { + type CodeNavigationCapability, + getCodeNavigationUrl, + getEnvApiToken, + isCodeNavigationCliOverrideEnabled, +} from "../../services/index.js"; +import { registerCodeSearchSymbolsCommand } from "./search-symbols.js"; + +export interface CodeCommandGroupOptions { + codeNavigationUrl?: string; + overrideEnabled?: boolean; + capability?: CodeNavigationCapability; + envTokenPresent?: boolean; + expiredStoredAuth?: boolean; +} + +/** + * Registers the capability-gated code-navigation command group. + * Only exposed when the token advertises `code_navigation`, the + * user sets `GITHITS_CODE_NAVIGATION=1`, an opaque env token is + * present, or stored auth has expired. + */ +export async function registerCodeCommandGroup( + program: Command, + options: CodeCommandGroupOptions = {}, +): Promise { + const codeNavigationUrl = options.codeNavigationUrl ?? getCodeNavigationUrl(); + if (!codeNavigationUrl) { + return; + } + + const overrideEnabled = + options.overrideEnabled ?? isCodeNavigationCliOverrideEnabled(); + const registrationState = + options.capability !== undefined || options.expiredStoredAuth !== undefined + ? { + capability: options.capability ?? "unknown", + expiredStoredAuth: options.expiredStoredAuth ?? false, + } + : await resolveStartupCodeNavigationRegistrationState(); + const capability = registrationState.capability; + const envTokenPresent = options.envTokenPresent ?? Boolean(getEnvApiToken()); + + if ( + !overrideEnabled && + capability !== "enabled" && + !envTokenPresent && + !registrationState.expiredStoredAuth + ) { + return; + } + + const codeCommand = program + .command("code") + .summary("Search indexed dependency source code") + .description( + "Code-navigation commands for searching indexed dependency source. Requires the `code_navigation` capability on the active account.", + ); + + registerCodeSearchSymbolsCommand(codeCommand); +} diff --git a/src/commands/code/search-symbols.test.ts b/src/commands/code/search-symbols.test.ts new file mode 100644 index 00000000..de35ed2c --- /dev/null +++ b/src/commands/code/search-symbols.test.ts @@ -0,0 +1,727 @@ +import { describe, expect, it, mock, spyOn } from "bun:test"; +import { CodeNavigationIndexingError } from "../../services/index.js"; +import { + createMockCodeNavigationService, + defaultSearchSymbolsResult, +} from "../../services/test-helpers.js"; +import { AuthRequiredError } from "../../shared/require-auth.js"; +import { + type SearchSymbolsCommandDependencies, + searchSymbolsAction, +} from "./search-symbols.js"; + +describe("searchSymbolsAction", () => { + const mcpUrl = "https://mcp.githits.com"; + + function createDeps( + overrides: Partial = {}, + ): SearchSymbolsCommandDependencies { + return { + codeNavigationService: createMockCodeNavigationService(), + codeNavigationUrl: "https://nav.example.com", + hasValidToken: true, + mcpUrl, + ...overrides, + }; + } + + it("prints human-readable results by default", async () => { + const consoleSpy = spyOn(console, "log").mockImplementation(() => {}); + + await searchSymbolsAction("npm:express", "middleware", {}, createDeps()); + + const output = consoleSpy.mock.calls.map((call) => call[0]).join("\n"); + expect(output).toContain("1 match(es)"); + expect(output).toContain("useMiddleware"); + // Entry leads with `filePath:startLine-endLine [kind]`. + expect(output).toContain("src/app.js:42-48"); + expect(output).toContain("[function]"); + // Snippet is built from `code` and dedented, preserving structure. + expect(output).toContain("function useMiddleware(fn) {"); + consoleSpy.mockRestore(); + }); + + it("prints the shared JSON envelope when --json is provided", async () => { + const consoleSpy = spyOn(console, "log").mockImplementation(() => {}); + + await searchSymbolsAction( + "npm:express", + "middleware", + { json: true }, + createDeps(), + ); + + const output = consoleSpy.mock.calls[0]?.[0] as string; + const payload = JSON.parse(output); + expect(payload.results).toEqual(defaultSearchSymbolsResult.results); + expect(payload.returnedCount).toBe(1); + expect(payload.totalMatches).toBe(1); + expect(payload.hasMore).toBe(false); + expect(payload.version).toBe("4.18.0"); + expect(payload.query.target).toEqual({ + registry: "NPM", + packageName: "express", + version: undefined, + }); + expect(payload.query.fileIntent).toBe("production"); + expect(payload.query.defaulted).toContain("fileIntent"); + expect(payload.query.defaulted).toContain("waitTimeoutMs"); + expect(payload._warning).toBeUndefined(); + consoleSpy.mockRestore(); + }); + + it("prints the shared JSON error envelope on --json when the service fails", async () => { + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + try { + await searchSymbolsAction( + "npm:express", + "middleware", + { json: true }, + createDeps({ + codeNavigationService: createMockCodeNavigationService({ + searchSymbols: mock(() => + Promise.reject( + new CodeNavigationIndexingError( + "Target is being indexed.", + "idx-123", + ), + ), + ), + }), + }), + ); + } catch { + // expected process.exit throw + } + + const output = errorSpy.mock.calls[0]?.[0] as string; + expect(JSON.parse(output)).toEqual({ + error: "Target is being indexed.", + code: "INDEXING", + retryable: true, + details: { indexingRef: "idx-123" }, + }); + expect(exitSpy).toHaveBeenCalledWith(1); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it("surfaces the backend zero-result hint verbatim when it arrives", async () => { + const consoleSpy = spyOn(console, "log").mockImplementation(() => {}); + + await searchSymbolsAction( + "npm:express", + "nonexistentterm12345", + {}, + createDeps({ + codeNavigationService: createMockCodeNavigationService({ + searchSymbols: mock(() => + Promise.resolve({ + results: [], + totalMatches: 0, + hasMore: false, + version: "5.2.1", + hint: "120 chunks indexed across 45 files. Try broader search terms or use fetch_code_context to read specific files directly.", + }), + ), + }), + }), + ); + + const output = consoleSpy.mock.calls.map((call) => call[0]).join("\n"); + expect(output).toContain('No matches for "nonexistentterm12345"'); + expect(output).toContain("120 chunks indexed across 45 files"); + // Server hint replaces the client-side suggestion list. + expect(output).not.toContain("Try: drop --kind"); + consoleSpy.mockRestore(); + }); + + it("still filters the legacy '0 searchable chunks' phrasing when backend regresses", async () => { + const consoleSpy = spyOn(console, "log").mockImplementation(() => {}); + + await searchSymbolsAction( + "npm:express", + "nonexistentterm12345", + {}, + createDeps({ + codeNavigationService: createMockCodeNavigationService({ + searchSymbols: mock(() => + Promise.resolve({ + results: [], + totalMatches: 0, + hasMore: false, + version: "5.2.1", + hint: "Repository indexed but contains 0 searchable chunks.", + }), + ), + }), + }), + ); + + const output = consoleSpy.mock.calls.map((call) => call[0]).join("\n"); + expect(output).not.toContain("0 searchable chunks"); + // Falls back to the client-side suggestion list. + expect(output).toContain("Try:"); + consoleSpy.mockRestore(); + }); + + it("rejects unknown registry prefixes with a clean error", async () => { + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + try { + await searchSymbolsAction("foobar:baz", "middleware", {}, createDeps()); + } catch { + // expected process.exit throw + } + + expect(errorSpy.mock.calls.map((call) => call[0]).join("\n")).toContain( + 'Unsupported registry "foobar"', + ); + expect(exitSpy).toHaveBeenCalledWith(1); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it("applies --intent production by default and --intent all to opt out", async () => { + const logSpy = spyOn(console, "log").mockImplementation(() => {}); + const searchSymbols = mock< + ( + params: import("../../services/index.js").SearchSymbolsParams, + ) => Promise + >(() => Promise.resolve({ results: [], totalMatches: 0, hasMore: false })); + const deps = createDeps({ + codeNavigationService: createMockCodeNavigationService({ + searchSymbols, + }), + }); + + await searchSymbolsAction("npm:express", "middleware", {}, deps); + expect(searchSymbols.mock.calls[0]?.[0]?.fileIntent).toBe("PRODUCTION"); + + searchSymbols.mockClear(); + await searchSymbolsAction( + "npm:express", + "middleware", + { intent: "all" }, + deps, + ); + // `--intent all` resolves to "omit the GraphQL variable" — + // service call sees undefined fileIntent. + expect(searchSymbols.mock.calls[0]?.[0]?.fileIntent).toBeUndefined(); + + searchSymbols.mockClear(); + await searchSymbolsAction( + "npm:express", + "middleware", + { intent: "test" }, + deps, + ); + expect(searchSymbols.mock.calls[0]?.[0]?.fileIntent).toBe("TEST"); + + logSpy.mockRestore(); + }); + + it('echoes fileIntent: "all" in JSON output when --intent all is passed', async () => { + const logSpy = spyOn(console, "log").mockImplementation(() => {}); + + await searchSymbolsAction( + "npm:express", + "middleware", + { intent: "all", json: true }, + createDeps(), + ); + + const payload = JSON.parse(logSpy.mock.calls[0]?.[0] as string); + expect(payload.query.fileIntent).toBe("all"); + expect(payload.query.defaulted).not.toContain("fileIntent"); + logSpy.mockRestore(); + }); + + it("parses --wait in seconds and converts to milliseconds at the service boundary", async () => { + const searchSymbols = mock< + ( + params: import("../../services/index.js").SearchSymbolsParams, + ) => Promise + >(() => Promise.resolve({ results: [], totalMatches: 0, hasMore: false })); + const deps = createDeps({ + codeNavigationService: createMockCodeNavigationService({ + searchSymbols, + }), + }); + + const logSpy = spyOn(console, "log").mockImplementation(() => {}); + + await searchSymbolsAction( + "npm:express", + "middleware", + { wait: "15" }, + deps, + ); + expect(searchSymbols.mock.calls[0]?.[0]?.waitTimeoutMs).toBe(15_000); + + searchSymbols.mockClear(); + await searchSymbolsAction( + "npm:express", + "middleware", + { wait: "5s" }, + deps, + ); + expect(searchSymbols.mock.calls[0]?.[0]?.waitTimeoutMs).toBe(5_000); + + logSpy.mockRestore(); + }); + + it("rejects invalid --wait inputs", async () => { + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + const cases: Array<[string, string]> = [ + ["10ms", "seconds"], + ["abc", "between 0 and 60"], + ["-1", "between 0 and 60"], + ["61", "between 0 and 60"], + ]; + + for (const [value, substring] of cases) { + errorSpy.mockClear(); + try { + await searchSymbolsAction( + "npm:express", + "middleware", + { wait: value }, + createDeps(), + ); + } catch { + // expected process.exit throw + } + const output = errorSpy.mock.calls.map((call) => call[0]).join("\n"); + expect(output).toContain(substring); + } + + expect(exitSpy).toHaveBeenCalledWith(1); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it("CLI parser errors classify as INVALID_ARGUMENT in JSON output", async () => { + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + try { + await searchSymbolsAction( + "npm:express", + "middleware", + { wait: "61", json: true }, + createDeps(), + ); + } catch { + // expected process.exit throw + } + + const output = errorSpy.mock.calls[0]?.[0] as string; + const parsed = JSON.parse(output); + expect(parsed.code).toBe("INVALID_ARGUMENT"); + expect(parsed.error).toContain("--wait"); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it("suppresses the (requested X) annotation on trivial v-prefix differences", async () => { + const consoleSpy = spyOn(console, "log").mockImplementation(() => {}); + + await searchSymbolsAction( + "npm:express", + "middleware", + {}, + createDeps({ + codeNavigationService: createMockCodeNavigationService({ + searchSymbols: mock(() => + Promise.resolve({ + results: [{ filePath: "lib/x.js", startLine: 1 }], + totalMatches: 1, + hasMore: false, + version: "v2.32.3", + resolution: { + requestedVersion: "2.32.3", + resolvedRef: "v2.32.3", + }, + }), + ), + }), + }), + ); + + const output = consoleSpy.mock.calls.map((call) => call[0]).join("\n"); + expect(output).toContain("indexed v2.32.3"); + expect(output).not.toContain("(requested 2.32.3)"); + consoleSpy.mockRestore(); + }); + + it("truncates 40-char commit SHA refs to 7 characters in the header", async () => { + const consoleSpy = spyOn(console, "log").mockImplementation(() => {}); + + await searchSymbolsAction( + "npm:lodash", + "debounce", + {}, + createDeps({ + codeNavigationService: createMockCodeNavigationService({ + searchSymbols: mock(() => + Promise.resolve({ + results: [{ filePath: "lodash.js", startLine: 1 }], + totalMatches: 1, + hasMore: false, + version: "4f0b76e2eca13de1c1fe8b4305abc1f7d63f4b86", + }), + ), + }), + }), + ); + + const output = consoleSpy.mock.calls.map((call) => call[0]).join("\n"); + expect(output).toContain("indexed 4f0b76e"); + expect(output).not.toContain("4f0b76e2eca13de1c1fe8b4305abc1f7d63f4b86"); + consoleSpy.mockRestore(); + }); + + it("adapts zero-result suggestions to the filters the caller actually used", async () => { + const consoleSpy = spyOn(console, "log").mockImplementation(() => {}); + + await searchSymbolsAction( + "npm:express", + "Router", + { file: "nonexistent/", kind: "function" }, + createDeps({ + codeNavigationService: createMockCodeNavigationService({ + searchSymbols: mock(() => + Promise.resolve({ + results: [], + totalMatches: 0, + hasMore: false, + version: "v5.2.1", + }), + ), + }), + }), + ); + + const output = consoleSpy.mock.calls.map((call) => call[0]).join("\n"); + expect(output).toContain("drop --kind"); + expect(output).toContain("broaden or remove --file"); + expect(output).toContain("try --intent all"); + consoleSpy.mockRestore(); + }); + + it("omits `try --intent all` from the zero-result suggestion when the caller already chose all", async () => { + const consoleSpy = spyOn(console, "log").mockImplementation(() => {}); + + await searchSymbolsAction( + "npm:express", + "Router", + { intent: "all" }, + createDeps({ + codeNavigationService: createMockCodeNavigationService({ + searchSymbols: mock(() => + Promise.resolve({ + results: [], + totalMatches: 0, + hasMore: false, + version: "v5.2.1", + }), + ), + }), + }), + ); + + const output = consoleSpy.mock.calls.map((call) => call[0]).join("\n"); + expect(output).toContain("try broader keywords"); + expect(output).not.toContain("try --intent all"); + consoleSpy.mockRestore(); + }); + + it("accepts --category and passes through to the service and echo", async () => { + const searchSymbols = mock< + ( + params: import("../../services/index.js").SearchSymbolsParams, + ) => Promise + >(() => Promise.resolve({ results: [], totalMatches: 0, hasMore: false })); + const deps = createDeps({ + codeNavigationService: createMockCodeNavigationService({ + searchSymbols, + }), + }); + const logSpy = spyOn(console, "log").mockImplementation(() => {}); + + await searchSymbolsAction( + "npm:express", + "Router", + { category: "callable", json: true }, + deps, + ); + expect(searchSymbols.mock.calls[0]?.[0]?.category).toBe("CALLABLE"); + + const payload = JSON.parse(logSpy.mock.calls[0]?.[0] as string); + expect(payload.query.category).toBe("callable"); + logSpy.mockRestore(); + }); + + it("rejects unknown --category values with a clean error", async () => { + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + try { + await searchSymbolsAction( + "npm:express", + "Router", + { category: "xyzzy" }, + createDeps(), + ); + } catch { + // expected process.exit throw + } + + const output = errorSpy.mock.calls.map((call) => call[0]).join("\n"); + expect(output).toContain("--category must be one of"); + expect(output).toContain("callable"); + expect(exitSpy).toHaveBeenCalledWith(1); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it("accepts expanded --kind values from the unified taxonomy", async () => { + const searchSymbols = mock< + ( + params: import("../../services/index.js").SearchSymbolsParams, + ) => Promise + >(() => Promise.resolve({ results: [], totalMatches: 0, hasMore: false })); + const deps = createDeps({ + codeNavigationService: createMockCodeNavigationService({ + searchSymbols, + }), + }); + const logSpy = spyOn(console, "log").mockImplementation(() => {}); + + await searchSymbolsAction( + "crates:serde", + "Serialize", + { kind: "trait" }, + deps, + ); + expect(searchSymbols.mock.calls[0]?.[0]?.kind).toBe("TRAIT"); + + searchSymbols.mockClear(); + await searchSymbolsAction( + "npm:express", + "Router", + { kind: "namespace" }, + deps, + ); + expect(searchSymbols.mock.calls[0]?.[0]?.kind).toBe("NAMESPACE"); + + logSpy.mockRestore(); + }); + + it("adds `drop --category` to zero-result suggestions when a category was used", async () => { + const consoleSpy = spyOn(console, "log").mockImplementation(() => {}); + + await searchSymbolsAction( + "npm:express", + "Router", + { category: "type" }, + createDeps({ + codeNavigationService: createMockCodeNavigationService({ + searchSymbols: mock(() => + Promise.resolve({ + results: [], + totalMatches: 0, + hasMore: false, + version: "v5.2.1", + }), + ), + }), + }), + ); + + const output = consoleSpy.mock.calls.map((call) => call[0]).join("\n"); + expect(output).toContain("drop --category"); + consoleSpy.mockRestore(); + }); + + it("suppresses the `[fallback]` kind label in terminal output", async () => { + const consoleSpy = spyOn(console, "log").mockImplementation(() => {}); + + await searchSymbolsAction( + "npm:express", + "middleware", + {}, + createDeps({ + codeNavigationService: createMockCodeNavigationService({ + searchSymbols: mock(() => + Promise.resolve({ + results: [ + { + filePath: "lib/middleware/init.js", + startLine: 1, + endLine: 43, + kind: "fallback", + code: "module.exports = function () { return true; };", + }, + ], + totalMatches: 1, + hasMore: false, + version: "v5.2.1", + }), + ), + }), + }), + ); + + const output = consoleSpy.mock.calls.map((call) => call[0]).join("\n"); + expect(output).toContain("lib/middleware/init.js:1-43"); + expect(output).not.toContain("[fallback]"); + consoleSpy.mockRestore(); + }); + + it("accepts --kind, --intent, and --match-mode values case-insensitively", async () => { + const searchSymbols = mock< + ( + params: import("../../services/index.js").SearchSymbolsParams, + ) => Promise + >(() => Promise.resolve({ results: [], totalMatches: 0, hasMore: false })); + const deps = createDeps({ + codeNavigationService: createMockCodeNavigationService({ + searchSymbols, + }), + }); + const logSpy = spyOn(console, "log").mockImplementation(() => {}); + + await searchSymbolsAction( + "npm:express", + "middleware", + { kind: "FUNCTION", intent: "TEST", matchMode: "AND" }, + deps, + ); + + expect(searchSymbols.mock.calls[0]?.[0]).toMatchObject({ + kind: "FUNCTION", + fileIntent: "TEST", + matchMode: "AND", + }); + logSpy.mockRestore(); + }); + + it("merges repeatable --keyword with comma-separated --keywords", async () => { + const searchSymbols = mock< + ( + params: import("../../services/index.js").SearchSymbolsParams, + ) => Promise + >(() => Promise.resolve({ results: [], totalMatches: 0, hasMore: false })); + const deps = createDeps({ + codeNavigationService: createMockCodeNavigationService({ + searchSymbols, + }), + }); + + const logSpy = spyOn(console, "log").mockImplementation(() => {}); + + await searchSymbolsAction( + "npm:express", + undefined, + { keywords: "router,handler", keyword: ["middleware", "router"] }, + deps, + ); + + // De-duplicates "router" while preserving first-seen order. + expect(searchSymbols.mock.calls[0]?.[0]?.keywords).toEqual([ + "router", + "handler", + "middleware", + ]); + + logSpy.mockRestore(); + }); + + it("throws AuthRequiredError when no valid token is available", async () => { + const consoleSpy = spyOn(console, "log").mockImplementation(() => {}); + + await expect( + searchSymbolsAction( + "npm:express", + "middleware", + {}, + createDeps({ hasValidToken: false }), + ), + ).rejects.toThrow(AuthRequiredError); + + consoleSpy.mockRestore(); + }); + + it("exits when indexing is still in progress", async () => { + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + try { + await searchSymbolsAction( + "npm:express", + "middleware", + {}, + createDeps({ + codeNavigationService: createMockCodeNavigationService({ + searchSymbols: mock(() => + Promise.reject( + new CodeNavigationIndexingError( + "Target is being indexed.", + "idx-123", + ), + ), + ), + }), + }), + ); + } catch { + // expected process.exit throw + } + + expect(errorSpy.mock.calls.map((call) => call[0]).join("\n")).toContain( + "Target is being indexed", + ); + expect(exitSpy).toHaveBeenCalledWith(1); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it("exits when both query and keywords are missing", async () => { + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + try { + await searchSymbolsAction("npm:express", undefined, {}, createDeps()); + } catch { + // expected process.exit throw + } + + expect(errorSpy.mock.calls.map((call) => call[0]).join("\n")).toContain( + "Provide a query argument, or pass keywords via --keywords or repeated --keyword.", + ); + expect(exitSpy).toHaveBeenCalledWith(1); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }); +}); diff --git a/src/commands/code/search-symbols.ts b/src/commands/code/search-symbols.ts new file mode 100644 index 00000000..bbf08133 --- /dev/null +++ b/src/commands/code/search-symbols.ts @@ -0,0 +1,519 @@ +import type { Command } from "commander"; +import { createContainer } from "../../container.js"; +import type { + CodeNavigationService, + SearchSymbolsResult, + SearchSymbolsResultEntry, +} from "../../services/index.js"; +import { + AuthRequiredError, + buildSearchSymbolsErrorPayload, + buildSearchSymbolsParams, + buildSearchSymbolsSuccessPayload, + FILE_INTENT_ALL, + type FileIntentInput, + InvalidArgumentError, + knownSymbolCategoryList, + knownSymbolKindList, + mapCodeNavigationError, + normaliseKeywords, + parsePackageSpec, + requireAuth, + type SearchSymbolsSuccessPayload, + toCodeNavigationRegistry, + toSearchSymbolsFileIntent, + toSearchSymbolsKind, + toSearchSymbolsMatchMode, + toSymbolCategory, +} from "../../shared/index.js"; + +export interface SearchSymbolsCommandOptions { + kind?: string; + category?: string; + limit?: string; + keywords?: string; + keyword?: string[]; // repeatable + matchMode?: string; + file?: string; + intent?: string; + wait?: string; + json?: boolean; +} + +export interface SearchSymbolsCommandDependencies { + codeNavigationService: CodeNavigationService | undefined; + codeNavigationUrl: string | undefined; + hasValidToken: boolean; + mcpUrl: string; +} + +/** + * Core code navigation search command logic. + */ +export async function searchSymbolsAction( + packageArg: string, + query: string | undefined, + options: SearchSymbolsCommandOptions, + deps: SearchSymbolsCommandDependencies, +): Promise { + requireAuth(deps); + + try { + if (!deps.codeNavigationUrl || !deps.codeNavigationService) { + throw new InvalidArgumentError( + "Code navigation is not configured for this environment.", + ); + } + + const keywords = normaliseKeywords(options.keywords, options.keyword); + if (!query && keywords.length === 0) { + throw new InvalidArgumentError( + "Provide a query argument, or pass keywords via --keywords or repeated --keyword.", + ); + } + + const parsed = parsePackageSpec(packageArg); + + const { params, defaulted } = buildSearchSymbolsParams({ + target: { + registry: toCodeNavigationRegistry(parsed.registry), + packageName: parsed.name, + version: parsed.version, + }, + query, + keywords: keywords.length > 0 ? keywords : undefined, + matchMode: parseMatchMode(options.matchMode), + kind: parseKind(options.kind), + category: parseCategory(options.category), + filePath: options.file, + limit: parseOptionalInt(options.limit, "--limit", 1, 50), + fileIntent: parseIntent(options.intent), + waitTimeoutMs: parseWaitSeconds(options.wait), + }); + + const result = await deps.codeNavigationService.searchSymbols(params); + const payload = buildSearchSymbolsSuccessPayload(params, defaulted, result); + + if (options.json) { + console.log(JSON.stringify(payload)); + return; + } + + console.log( + formatSearchSymbolsTerminal( + payload, + parsed.registry, + parsed.name, + parsed.version, + query, + ), + ); + } catch (error) { + handleSearchSymbolsCommandError(error, options.json ?? false); + } +} + +const SEARCH_SYMBOLS_DESCRIPTION = `Find functions, classes, modules, and doc sections inside an indexed dependency by exact-token search. + +Package spec: :[@]. Omit the registry to default to +npm. Supported registries: npm, pypi, hex, crates, nuget, maven, zig, vcpkg, +packagist. + +Filter by --category (broad: callable, type, module, data, documentation) +or --kind (precise: function, method, class, trait, …). Prefer --category +for most use cases; reach for --kind when you need a specific construct. + +Examples: + githits code search npm:express middleware + githits code search npm:express middleware --intent all + githits code search pypi:requests timeout --category callable --limit 10 + githits code search crates:serde Serialize --kind trait --limit 5 + githits code search npm:@types/node Buffer --file src/ --json + githits code search npm:express --keywords "router,handler" --match-mode and`; + +/** + * Register the `code search` command. `search-symbols` is kept as an + * alias for continuity but is not the primary surface. + */ +export function registerCodeSearchSymbolsCommand(program: Command) { + program + .command("search [query]") + .alias("search-symbols") + .summary("Search indexed package code") + .description(SEARCH_SYMBOLS_DESCRIPTION) + .option( + "--category ", + "Filter by broad symbol category: callable, type, module, data, documentation. Preferred over --kind for most use cases.", + ) + .option( + "--kind ", + "Filter by precise symbol kind (function, method, constructor, getter, setter, class, interface, trait, struct, enum, record, module, namespace, property, event, etc.). Prefer --category for broad filtering.", + ) + .option("--limit ", "Max results (1-50; default: 25)") + .option( + "--keywords ", + "Comma-separated keywords (alternative to the query argument)", + ) + .option( + "--keyword ", + "Single keyword (repeatable; combines with --keywords)", + collectRepeatable, + [] as string[], + ) + .option( + "--match-mode ", + "How to combine keywords: or (any match) or and (all match)", + ) + .option("--file ", "Filter to files matching path prefix") + .option( + "--intent ", + "File intent filter (production, test, benchmark, example, generated, fixture, build, vendor, all). Default: production.", + ) + .option( + "--wait ", + "Max seconds to wait for indexing (0-60; default: 20). Accepts `10` or `10s`. Indexing usually completes within 30 seconds; pass `--wait 60` to block on a first-time request.", + ) + .option("--json", "Output as JSON") + .action( + async ( + packageArg: string, + query: string | undefined, + options: SearchSymbolsCommandOptions, + ) => { + try { + const deps = await createContainer(); + await searchSymbolsAction(packageArg, query, options, deps); + } catch (error) { + if (error instanceof AuthRequiredError) { + process.exit(1); + } + throw error; + } + }, + ); +} + +function collectRepeatable(value: string, previous: string[]): string[] { + return [...previous, value]; +} + +/** + * Terminal formatter. Uses the DETAILED-mode response fields: every + * entry leads with `filePath:startLine-endLine [kind]`, followed by + * the symbol name (when present) and a 3-line dedented snippet taken + * from `code`. Preserves indentation — does NOT collapse whitespace. + */ +function formatSearchSymbolsTerminal( + payload: SearchSymbolsSuccessPayload, + registry: string, + packageName: string, + version: string | undefined, + query: string | undefined, +): string { + const lines: string[] = []; + + if (payload.warning) { + lines.push(`Warning: ${payload.warning}`); + lines.push(""); + } + + lines.push(formatHeader(payload)); + lines.push(""); + + if (payload.results.length === 0) { + lines.push( + formatZeroResultMessage( + query, + registry, + packageName, + version, + payload.query, + payload.hint, + ), + ); + return lines.join("\n").trimEnd(); + } + + for (const entry of payload.results) { + lines.push(...formatEntry(entry)); + lines.push(""); + } + + if (payload.hint) { + lines.push(`Note: ${payload.hint}`); + } + + return lines.join("\n").trimEnd(); +} + +function formatHeader(payload: SearchSymbolsSuccessPayload): string { + // `totalMatches` currently tracks `results.length` on the backend + // (see backend request B2). Until a real total is available, say + // "N match(es) (more available)" rather than lying with "of N". + let summary = `${payload.returnedCount} match(es)`; + if (payload.hasMore) summary += " (more available)"; + + if (payload.version) { + summary += ` · indexed ${displayVersion(payload.version)}`; + const requested = + payload.resolution?.requestedVersion ?? payload.resolution?.requestedRef; + if ( + requested && + !isTrivialRefDifference(requested, payload.version) && + !isTrivialRefDifference(requested, payload.resolution?.resolvedRef) + ) { + summary += ` (requested ${requested})`; + } + } + + return summary; +} + +/** + * Shorten long refs (commit SHAs) to an abbreviated form for display, + * preserve tag-style versions unchanged. + */ +function displayVersion(version: string): string { + // 40-char hex is a full SHA; truncate to 7. + if (/^[0-9a-f]{40}$/i.test(version)) return version.slice(0, 7); + return version; +} + +/** + * Suppress the "(requested X)" annotation when the only difference + * between the caller's ref and the resolved/indexed ref is a leading + * `v` prefix (backend normalisation). Users asking for `2.32.3` who + * got `v2.32.3` back should not be told they got a different version. + */ +function isTrivialRefDifference( + requested: string, + resolved: string | undefined, +): boolean { + if (!resolved) return false; + if (requested === resolved) return true; + const stripV = (v: string) => (v.startsWith("v") ? v.slice(1) : v); + return stripV(requested) === stripV(resolved); +} + +function formatEntry(entry: SearchSymbolsResultEntry): string[] { + const out: string[] = []; + const locationParts: string[] = []; + + if (entry.filePath) { + if (entry.startLine) { + locationParts.push( + entry.endLine && entry.endLine !== entry.startLine + ? `${entry.filePath}:${entry.startLine}-${entry.endLine}` + : `${entry.filePath}:${entry.startLine}`, + ); + } else { + locationParts.push(entry.filePath); + } + } + + const kindLabel = resolveKindLabel(entry); + if (kindLabel) locationParts.push(`[${kindLabel}]`); + + out.push( + locationParts.length > 0 ? locationParts.join(" ") : "unnamed match", + ); + + if (entry.name) out.push(` ${entry.name}`); + + const snippet = buildSnippet(entry.code); + for (const snippetLine of snippet) out.push(snippetLine); + + return out; +} + +/** + * Resolve the bracketed kind label shown at the end of the first + * per-entry line. The backend populates `kind` for every chunk + * (handling its own fallback from chunk-level classification to + * symbol-enrichment kind internally), so the client reads a single + * source of truth. + * + * The literal `"fallback"` label is suppressed — backend emits it + * for unclassified chunks where no taxonomy hit, and showing it to + * the user adds noise without signal. + */ +function resolveKindLabel(entry: SearchSymbolsResultEntry): string | undefined { + if (!entry.kind) return undefined; + const normalised = entry.kind.toLowerCase(); + if (normalised === "fallback") return undefined; + return normalised; +} + +/** + * Produce a short, indented, dedented snippet from the `code` field. + * The backend exposes `preview` in DETAILED mode, but the formatter + * owns snippet rendering client-side so we can control the truncation + * length and preserve indentation consistently. + */ +function buildSnippet(code: string | undefined, maxLines = 3): string[] { + if (!code) return []; + + const raw = code.split("\n"); + // Trim surrounding blank lines to avoid an empty first/last line in + // the snippet. + while (raw.length > 0 && raw[0]?.trim() === "") raw.shift(); + while (raw.length > 0 && raw[raw.length - 1]?.trim() === "") raw.pop(); + if (raw.length === 0) return []; + + const indent = commonLeadingIndent(raw); + const dedented = raw.map((line) => (indent > 0 ? line.slice(indent) : line)); + const truncated = dedented.length > maxLines; + const selected = dedented.slice(0, maxLines); + const visible = truncated ? [...selected, "…"] : selected; + return visible.map((line) => ` ${line}`); +} + +function commonLeadingIndent(lines: string[]): number { + let min = Number.POSITIVE_INFINITY; + for (const line of lines) { + if (line.trim() === "") continue; + const match = line.match(/^(\s*)/); + const len = match?.[1]?.length ?? 0; + if (len < min) min = len; + } + return Number.isFinite(min) ? min : 0; +} + +function formatZeroResultMessage( + query: string | undefined, + registry: string, + packageName: string, + version: string | undefined, + echo: SearchSymbolsSuccessPayload["query"], + serverHint: string | undefined, +): string { + const target = version + ? `${registry}:${packageName}@${version}` + : `${registry}:${packageName}`; + const queryText = query ? `"${query}"` : "the given keywords"; + const header = `No matches for ${queryText} in ${target}.`; + + // Prefer the server hint when present — the April 2026 backend + // rewrite made zero-result hints accurate (chunk/file counts, + // docs-only guidance). Fall back to the client-side suggestion + // list built from the filters the caller actually applied. + if (serverHint) { + return [header, serverHint].join("\n"); + } + + const suggestions: string[] = []; + if (echo.kind) suggestions.push("drop --kind"); + if (echo.category) suggestions.push("drop --category"); + if (echo.filePath) suggestions.push("broaden or remove --file"); + if (echo.fileIntent !== "all") suggestions.push("try --intent all"); + if (echo.matchMode === "and") suggestions.push("try --match-mode or"); + suggestions.push("try broader keywords"); + + return [header, `Try: ${suggestions.join(", ")}.`].join("\n"); +} + +function handleSearchSymbolsCommandError(error: unknown, json: boolean): never { + if (json) { + console.error(JSON.stringify(buildSearchSymbolsErrorPayload(error))); + process.exit(1); + } + + const mapped = mapCodeNavigationError(error); + console.error(`Failed to search symbols: ${mapped.message}`); + process.exit(1); +} + +function parseOptionalInt( + value: string | undefined, + optionName: string, + min: number, + max: number, +): number | undefined { + if (value === undefined) return undefined; + + const parsed = Number.parseInt(value, 10); + if (Number.isNaN(parsed) || parsed < min || parsed > max) { + throw new InvalidArgumentError( + `${optionName} must be a number between ${min} and ${max}`, + ); + } + + return parsed; +} + +function parseMatchMode(value: string | undefined) { + if (!value) return undefined; + const parsed = toSearchSymbolsMatchMode(value.toLowerCase()); + if (!parsed) { + throw new InvalidArgumentError("--match-mode must be 'or' or 'and'"); + } + return parsed; +} + +function parseKind(value: string | undefined) { + if (!value) return undefined; + const parsed = toSearchSymbolsKind(value.toLowerCase()); + if (!parsed) { + throw new InvalidArgumentError( + `--kind must be one of ${knownSymbolKindList().join(", ")}`, + ); + } + return parsed; +} + +function parseCategory(value: string | undefined) { + if (!value) return undefined; + const parsed = toSymbolCategory(value.toLowerCase()); + if (!parsed) { + throw new InvalidArgumentError( + `--category must be one of ${knownSymbolCategoryList().join(", ")}`, + ); + } + return parsed; +} + +function parseIntent(value: string | undefined): FileIntentInput { + if (value === undefined) return undefined; + const lower = value.toLowerCase(); + if (lower === "all") return FILE_INTENT_ALL; + const parsed = toSearchSymbolsFileIntent(lower); + if (!parsed) { + throw new InvalidArgumentError( + "--intent must be one of production, test, benchmark, example, generated, fixture, build, vendor, or all", + ); + } + return parsed; +} + +/** + * Parse the `--wait` flag as a seconds value, internally converting to + * milliseconds for the service layer. Accepts `10` or `10s`. Rejects + * negative values, non-numeric input, and values beyond the 60-second + * cap. + */ +function parseWaitSeconds(value: string | undefined): number | undefined { + if (value === undefined) return undefined; + const trimmed = value.trim(); + const withoutSuffix = trimmed.endsWith("s") ? trimmed.slice(0, -1) : trimmed; + if (trimmed.endsWith("ms")) { + throw new InvalidArgumentError( + "--wait is specified in seconds (e.g. `10` or `10s`), not milliseconds.", + ); + } + const parsed = Number.parseInt(withoutSuffix, 10); + if ( + Number.isNaN(parsed) || + parsed < 0 || + parsed > 60 || + withoutSuffix !== String(parsed) + ) { + throw new InvalidArgumentError( + "--wait must be a number of seconds between 0 and 60 (e.g. `10` or `10s`).", + ); + } + return parsed * 1000; +} + +// The `SearchSymbolsResult` type is imported above; retained so +// ambient TS modules that reference it through this file still work. +export type { SearchSymbolsResult }; diff --git a/src/commands/index.ts b/src/commands/index.ts index 61457c2c..9b7e1ff5 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -3,6 +3,7 @@ export { authStatusAction, registerAuthStatusCommand, } from "./auth-status.js"; +export { registerCodeCommandGroup } from "./code/index.js"; export { type FeedbackDependencies, type FeedbackOptions, diff --git a/src/commands/mcp.test.ts b/src/commands/mcp.test.ts index 5abb0047..f0126f29 100644 --- a/src/commands/mcp.test.ts +++ b/src/commands/mcp.test.ts @@ -4,10 +4,15 @@ import { createMockAuthService, createMockAuthStorage, createMockBrowserService, + createMockCodeNavigationService, createMockFileSystemService, createMockGitHitsService, } from "../services/test-helpers.js"; -import { createMcpServer, startMcpServer } from "./mcp.js"; +import { + createMcpServer, + getMcpToolDefinitions, + startMcpServer, +} from "./mcp.js"; function createTestDeps(overrides: Partial = {}): Dependencies { return { @@ -20,13 +25,17 @@ function createTestDeps(overrides: Partial = {}): Dependencies { apiToken: "test-token", hasValidToken: true, envApiToken: undefined, + codeNavigationCapability: "disabled", + codeNavigationCliOverrideEnabled: false, + codeNavigationUrl: undefined, + codeNavigationService: undefined, githitsService: createMockGitHitsService(), ...overrides, }; } describe("createMcpServer", () => { - it("creates server with all three tools registered", () => { + it("creates server with default tools registered", () => { const deps = createTestDeps(); const server = createMcpServer(deps); @@ -34,12 +43,32 @@ describe("createMcpServer", () => { expect(server).toBeDefined(); }); - it("uses apiToken from dependencies", () => { - const deps = createTestDeps({ apiToken: "custom-token" }); - const server = createMcpServer(deps); + it("adds search_symbols when capability is enabled", () => { + const deps = createTestDeps({ + codeNavigationCapability: "enabled", + codeNavigationUrl: "https://nav.example.com", + codeNavigationService: createMockCodeNavigationService(), + }); - // Server should be created successfully - expect(server).toBeDefined(); + const tools = getMcpToolDefinitions(deps); + expect(tools.map((tool) => tool.name)).toEqual([ + "search", + "search_language", + "feedback", + "search_symbols", + ]); + }); + + it("adds search_symbols for opaque env tokens", () => { + const deps = createTestDeps({ + envApiToken: "ghi-opaque-token", + codeNavigationCapability: "unknown", + codeNavigationUrl: "https://nav.example.com", + codeNavigationService: createMockCodeNavigationService(), + }); + + const tools = getMcpToolDefinitions(deps); + expect(tools.some((tool) => tool.name === "search_symbols")).toBe(true); }); }); diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index 387c879a..f588eb95 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -7,10 +7,35 @@ import { dim, highlight, shouldUseColors } from "../shared/colors.js"; import { createFeedbackTool, createSearchLanguageTool, + createSearchSymbolsTool, createSearchTool, type ToolDefinition, } from "../tools/index.js"; +/** + * Returns the MCP tools enabled for the current startup state. + */ +export function getMcpToolDefinitions( + deps: Dependencies, +): ToolDefinition[] { + const tools: ToolDefinition[] = [ + createSearchTool(deps.githitsService), + createSearchLanguageTool(deps.githitsService), + createFeedbackTool(deps.githitsService), + ]; + + if ( + (deps.codeNavigationCapability === "enabled" || + (deps.codeNavigationCapability === "unknown" && + deps.envApiToken !== undefined)) && + deps.codeNavigationService + ) { + tools.push(createSearchSymbolsTool(deps.codeNavigationService)); + } + + return tools; +} + /** * Creates the MCP server with injected dependencies. */ @@ -20,18 +45,16 @@ export function createMcpServer(deps: Dependencies): McpServer { version, }); - const { githitsService } = deps; - // biome-ignore lint/suspicious/noExplicitAny: Generic tool definitions - const tools: ToolDefinition[] = [ - createSearchTool(githitsService), - createSearchLanguageTool(githitsService), - createFeedbackTool(githitsService), - ]; + const tools = getMcpToolDefinitions(deps); for (const tool of tools) { server.registerTool( tool.name, - { description: tool.description, inputSchema: tool.schema }, + { + description: tool.description, + inputSchema: tool.schema, + annotations: tool.annotations, + }, tool.handler, ); } @@ -91,7 +114,7 @@ export function registerMcpCommand(program: Command) { When run interactively (TTY), shows setup instructions. When run via stdio (non-TTY), starts the MCP server. -Available tools: search, search_language, feedback`, +Available tools depend on the current authentication state and enabled features.`, ) .action(async () => { if (process.stdout.isTTY && process.stdin.isTTY) { diff --git a/src/container.ts b/src/container.ts index 7f807f0b..f6760c02 100644 --- a/src/container.ts +++ b/src/container.ts @@ -7,18 +7,26 @@ import { type BrowserService, BrowserServiceImpl, ChunkingKeyringService, + type CodeNavigationCapability, + type CodeNavigationService, + CodeNavigationServiceImpl, type FileSystemService, FileSystemServiceImpl, GitHitsServiceImpl, getApiUrl, + getCodeNavigationCapability, + getCodeNavigationUrl, getEnvApiToken, getMcpUrl, + isCodeNavigationCliOverrideEnabled, KeychainAuthStorage, KeychainUnavailableError, KeyringServiceImpl, MigratingAuthStorage, RefreshingGitHitsService, + type TokenData, TokenManager, + type TokenProvider, WINDOWS_MAX_ENTRY_SIZE, } from "./services/index.js"; @@ -77,10 +85,25 @@ export interface Dependencies { hasValidToken: boolean; /** Raw GITHITS_API_TOKEN env var value (for auth status display) */ envApiToken: string | undefined; + /** Code navigation capability derived from the startup token snapshot */ + codeNavigationCapability: CodeNavigationCapability; + /** Whether GITHITS_CODE_NAVIGATION is set to force-expose the code CLI commands locally */ + codeNavigationCliOverrideEnabled: boolean; + /** Code navigation backend URL when configured */ + codeNavigationUrl: string | undefined; + /** Optional code navigation service used by gated CLI/MCP paths */ + codeNavigationService: CodeNavigationService | undefined; /** GitHits REST API service */ githitsService: GitHitsService; } +function createStaticTokenProvider(token: string): TokenProvider { + return { + getToken: async () => token, + forceRefresh: async () => undefined, + }; +} + /** * Creates the production dependency container. * Async because token resolution requires reading stored auth. @@ -88,6 +111,8 @@ export interface Dependencies { export async function createContainer(): Promise { const mcpUrl = getMcpUrl(); const apiUrl = getApiUrl(); + const codeNavigationUrl = getCodeNavigationUrl(); + const codeNavigationCliOverrideEnabled = isCodeNavigationCliOverrideEnabled(); const fileSystemService = new FileSystemServiceImpl(); const authStorage = createAuthStorage(fileSystemService); const authService = new AuthServiceImpl(); @@ -96,6 +121,13 @@ export async function createContainer(): Promise { // Check for env API token first const envToken = getEnvApiToken(); if (envToken) { + const codeNavigationService = codeNavigationUrl + ? new CodeNavigationServiceImpl( + codeNavigationUrl, + createStaticTokenProvider(envToken), + ) + : undefined; + return { authStorage, authService, @@ -106,6 +138,10 @@ export async function createContainer(): Promise { apiToken: envToken, hasValidToken: true, envApiToken: envToken, + codeNavigationCapability: getCodeNavigationCapability(envToken), + codeNavigationCliOverrideEnabled, + codeNavigationUrl, + codeNavigationService, githitsService: new GitHitsServiceImpl(apiUrl, envToken), }; } @@ -113,6 +149,9 @@ export async function createContainer(): Promise { // Create token manager for stored auth with auto-refresh const tokenManager = new TokenManager({ authService, authStorage, mcpUrl }); const apiToken = await tokenManager.getToken(); + const codeNavigationService = codeNavigationUrl + ? new CodeNavigationServiceImpl(codeNavigationUrl, tokenManager) + : undefined; return { authStorage, @@ -124,6 +163,72 @@ export async function createContainer(): Promise { apiToken, hasValidToken: apiToken !== undefined, envApiToken: undefined, + codeNavigationCapability: getCodeNavigationCapability(apiToken), + codeNavigationCliOverrideEnabled, + codeNavigationUrl, + codeNavigationService, githitsService: new RefreshingGitHitsService(apiUrl, tokenManager), }; } + +/** + * Resolves the startup capability snapshot without triggering token refresh. + */ +export async function resolveStartupCodeNavigationCapability(): Promise { + const state = await resolveStartupCodeNavigationRegistrationState(); + return state.capability; +} + +export interface StartupCodeNavigationRegistrationState { + capability: CodeNavigationCapability; + expiredStoredAuth: boolean; +} + +/** + * Resolves CLI registration state without triggering token refresh. + */ +export async function resolveStartupCodeNavigationRegistrationState(): Promise { + const envToken = getEnvApiToken(); + if (envToken) { + return { + capability: getCodeNavigationCapability(envToken), + expiredStoredAuth: false, + }; + } + + const tokens = await loadStartupTokens(getMcpUrl()); + if (tokens?.expiresAt && new Date(tokens.expiresAt) < new Date()) { + return { capability: "unknown", expiredStoredAuth: true }; + } + + return { + capability: getCodeNavigationCapability(tokens?.accessToken), + expiredStoredAuth: false, + }; +} + +async function loadStartupTokens(mcpUrl: string): Promise { + const fileSystemService = new FileSystemServiceImpl(); + const fileStorage = new AuthStorageImpl(fileSystemService); + + try { + // Avoid createAuthStorage() here: command registration/help should stay read-only + // and must not trigger the keychain probe write+delete cycle or migration writes. + const rawKeyring = new KeyringServiceImpl(); + const keyring = + process.platform === "win32" + ? new ChunkingKeyringService(rawKeyring, WINDOWS_MAX_ENTRY_SIZE) + : rawKeyring; + const keychainStorage = new KeychainAuthStorage(keyring); + const keychainTokens = await keychainStorage.loadTokens(mcpUrl); + if (keychainTokens) { + return keychainTokens; + } + } catch (error) { + if (!(error instanceof KeychainUnavailableError)) { + throw error; + } + } + + return fileStorage.loadTokens(mcpUrl); +} diff --git a/src/services/code-navigation-capability.test.ts b/src/services/code-navigation-capability.test.ts new file mode 100644 index 00000000..c1482df5 --- /dev/null +++ b/src/services/code-navigation-capability.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "bun:test"; +import { getCodeNavigationCapability } from "./code-navigation-capability.js"; +import { createJwtToken } from "./test-helpers.js"; + +describe("getCodeNavigationCapability", () => { + it("returns enabled for top-level feature_flags claim", () => { + const token = createJwtToken({ feature_flags: ["code_navigation"] }); + expect(getCodeNavigationCapability(token)).toBe("enabled"); + }); + + it("returns enabled for nested claims.feature_flags", () => { + const token = createJwtToken({ + claims: { feature_flags: ["code_navigation"] }, + }); + expect(getCodeNavigationCapability(token)).toBe("enabled"); + }); + + it("returns disabled when feature flag array exists without the flag", () => { + const token = createJwtToken({ feature_flags: ["other_feature"] }); + expect(getCodeNavigationCapability(token)).toBe("disabled"); + }); + + it("returns unknown for opaque tokens", () => { + expect(getCodeNavigationCapability("ghi-opaque-token")).toBe("unknown"); + }); + + it("returns unknown for malformed jwt payload", () => { + expect(getCodeNavigationCapability("a.broken.c")).toBe("unknown"); + }); +}); diff --git a/src/services/code-navigation-capability.ts b/src/services/code-navigation-capability.ts new file mode 100644 index 00000000..6151c8cb --- /dev/null +++ b/src/services/code-navigation-capability.ts @@ -0,0 +1,72 @@ +/** + * Capability state derived from the current bearer token. + */ +export type CodeNavigationCapability = "enabled" | "disabled" | "unknown"; + +interface JwtPayloadShape { + feature_flags?: unknown; + claims?: { feature_flags?: unknown }; +} + +/** + * Derives code navigation capability from a Supabase JWT. + * + * The CLI does not have the signing secret, so this decodes the JWT payload + * without verifying the signature. Opaque or malformed tokens return unknown. + */ +export function getCodeNavigationCapability( + token: string | undefined, +): CodeNavigationCapability { + if (!token) return "unknown"; + + const payload = parseJwtPayload(token); + if (!payload) return "unknown"; + + const featureFlags = extractFeatureFlags(payload); + if (!featureFlags) return "unknown"; + + return featureFlags.includes("code_navigation") ? "enabled" : "disabled"; +} + +function extractFeatureFlags(payload: JwtPayloadShape): string[] | null { + if (isStringArray(payload.feature_flags)) { + return payload.feature_flags; + } + + if (payload.claims && isStringArray(payload.claims.feature_flags)) { + return payload.claims.feature_flags; + } + + return null; +} + +function isStringArray(value: unknown): value is string[] { + return ( + Array.isArray(value) && value.every((entry) => typeof entry === "string") + ); +} + +function parseJwtPayload(token: string): JwtPayloadShape | null { + const parts = token.split("."); + if (parts.length < 2) return null; + + try { + const decoded = Buffer.from(toBase64(parts[1] ?? ""), "base64").toString( + "utf8", + ); + const parsed: unknown = JSON.parse(decoded); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return null; + } + return parsed as JwtPayloadShape; + } catch { + return null; + } +} + +function toBase64(base64Url: string): string { + const normalized = base64Url.replace(/-/g, "+").replace(/_/g, "/"); + const padding = normalized.length % 4; + if (padding === 0) return normalized; + return normalized.padEnd(normalized.length + (4 - padding), "="); +} diff --git a/src/services/code-navigation-service.test.ts b/src/services/code-navigation-service.test.ts new file mode 100644 index 00000000..c0e3d103 --- /dev/null +++ b/src/services/code-navigation-service.test.ts @@ -0,0 +1,728 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; +import { + CodeNavigationBackendError, + CodeNavigationIndexingError, + CodeNavigationNetworkError, + CodeNavigationServiceImpl, + CodeNavigationTargetNotFoundError, + CodeNavigationUnresolvableError, + CodeNavigationValidationError, + CodeNavigationVersionNotFoundError, + MalformedCodeNavigationResponseError, +} from "./code-navigation-service.js"; +import { AuthenticationError } from "./githits-service.js"; +import { createMockTokenProvider } from "./test-helpers.js"; + +function mockFetch(impl: () => Promise) { + const fn = mock(impl); + globalThis.fetch = fn as unknown as typeof fetch; + return fn; +} + +describe("CodeNavigationServiceImpl", () => { + const BASE_URL = "https://nav.example.com"; + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("sends GraphQL request and normalizes search results", async () => { + const fn = mockFetch(() => + Promise.resolve( + new Response( + JSON.stringify({ + data: { + searchSymbols: { + results: [ + { + name: "useMiddleware", + kind: "function", + category: "callable", + filePath: "src/app.js", + startLine: 42, + preview: "function useMiddleware(fn) {", + language: "javascript", + }, + ], + totalMatches: 1, + hasMore: false, + indexedVersion: "4.18.0", + diagnostics: { hint: null }, + warning: null, + indexingStatus: "INDEXED", + }, + }, + }), + { headers: { "Content-Type": "application/json" } }, + ), + ), + ); + + const service = new CodeNavigationServiceImpl( + BASE_URL, + createMockTokenProvider(), + globalThis.fetch, + ); + + const result = await service.searchSymbols({ + target: { registry: "NPM", packageName: "express", version: "4.18.0" }, + query: "middleware", + }); + + expect(result.version).toBe("4.18.0"); + expect(result.totalMatches).toBe(1); + expect(result.results[0]?.name).toBe("useMiddleware"); + + const [url, init] = fn.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe(`${BASE_URL}/api/graphql`); + expect(init.method).toBe("POST"); + expect((init.headers as Record).Authorization).toBe( + "Bearer mock-access-token", + ); + + const body = JSON.parse(init.body as string); + expect(body.variables.registry).toBe("NPM"); + expect(body.variables.packageName).toBe("express"); + expect(body.variables.query).toBe("middleware"); + }); + + it("retries once on 401 with refreshed token", async () => { + const responses = [ + Promise.resolve(new Response("", { status: 401 })), + Promise.resolve( + new Response( + JSON.stringify({ + data: { + searchSymbols: { + results: [], + totalMatches: 0, + hasMore: false, + indexedVersion: null, + diagnostics: { hint: null }, + warning: null, + indexingStatus: "INDEXED", + }, + }, + }), + ), + ), + ]; + const fn = mock( + () => responses.shift() ?? Promise.reject(new Error("no response")), + ); + globalThis.fetch = fn as unknown as typeof fetch; + + const tokenProvider = createMockTokenProvider({ + forceRefresh: mock(() => Promise.resolve("mock-refreshed-token")), + }); + const service = new CodeNavigationServiceImpl( + BASE_URL, + tokenProvider, + globalThis.fetch, + ); + + await service.searchSymbols({ + target: { registry: "NPM", packageName: "express" }, + query: "middleware", + }); + + expect(tokenProvider.forceRefresh).toHaveBeenCalledTimes(1); + const secondCall = fn.mock.calls[1] as unknown as [string, RequestInit]; + expect( + (secondCall[1].headers as Record).Authorization, + ).toBe("Bearer mock-refreshed-token"); + }); + + it("throws indexing error when backend reports indexing in progress", async () => { + mockFetch(() => + Promise.resolve( + new Response( + JSON.stringify({ + data: { + searchSymbols: { + results: [], + totalMatches: 0, + hasMore: false, + indexedVersion: null, + diagnostics: { hint: null }, + warning: null, + indexingStatus: "INDEXING", + indexingRef: "idx-123", + availableVersions: [{ version: "4.18.0", ref: "v4.18.0" }], + }, + }, + }), + ), + ), + ); + + const service = new CodeNavigationServiceImpl( + BASE_URL, + createMockTokenProvider(), + globalThis.fetch, + ); + + await expect( + service.searchSymbols({ + target: { registry: "NPM", packageName: "express" }, + query: "middleware", + }), + ).rejects.toBeInstanceOf(CodeNavigationIndexingError); + }); + + it("throws malformed response error when payload does not match schema", async () => { + mockFetch(() => + Promise.resolve( + new Response(JSON.stringify({ data: { wrongField: {} } }), { + headers: { "Content-Type": "application/json" }, + }), + ), + ); + + const service = new CodeNavigationServiceImpl( + BASE_URL, + createMockTokenProvider(), + globalThis.fetch, + ); + + await expect( + service.searchSymbols({ + target: { registry: "NPM", packageName: "express" }, + query: "middleware", + }), + ).rejects.toBeInstanceOf(MalformedCodeNavigationResponseError); + }); + + it("retries once when GraphQL returns UNAUTHORIZED", async () => { + const responses = [ + Promise.resolve( + new Response( + JSON.stringify({ + errors: [ + { + message: "Unauthorized", + extensions: { code: "UNAUTHORIZED" }, + }, + ], + }), + { headers: { "Content-Type": "application/json" } }, + ), + ), + Promise.resolve( + new Response( + JSON.stringify({ + data: { + searchSymbols: { + results: [], + totalMatches: 0, + hasMore: false, + indexedVersion: null, + diagnostics: { hint: null }, + warning: null, + indexingStatus: "INDEXED", + }, + }, + }), + { headers: { "Content-Type": "application/json" } }, + ), + ), + ]; + const fn = mock( + () => responses.shift() ?? Promise.reject(new Error("no response")), + ); + globalThis.fetch = fn as unknown as typeof fetch; + + const tokenProvider = createMockTokenProvider({ + forceRefresh: mock(() => Promise.resolve("mock-refreshed-token")), + }); + const service = new CodeNavigationServiceImpl( + BASE_URL, + tokenProvider, + globalThis.fetch, + ); + + await service.searchSymbols({ + target: { registry: "NPM", packageName: "express" }, + query: "middleware", + }); + + expect(tokenProvider.forceRefresh).toHaveBeenCalledTimes(1); + }); + + it("throws authentication error when refresh does not produce a new token", async () => { + mockFetch(() => Promise.resolve(new Response("", { status: 401 }))); + + const service = new CodeNavigationServiceImpl( + BASE_URL, + createMockTokenProvider({ + forceRefresh: mock(() => Promise.resolve(undefined)), + }), + globalThis.fetch, + ); + + await expect( + service.searchSymbols({ + target: { registry: "NPM", packageName: "express" }, + query: "middleware", + }), + ).rejects.toBeInstanceOf(AuthenticationError); + }); + + it("maps null data with GraphQL not-found error to CodeNavigationTargetNotFoundError", async () => { + // Observed live response for unknown packages: { data: null, errors: [...] } + // without `extensions.code`. Schema accepts null data; createGraphQLError + // then matches the message heuristically. + mockFetch(() => + Promise.resolve( + new Response( + JSON.stringify({ + data: null, + errors: [ + { + message: + "Package not found in registry: npm/nosuchpackage-zzzzz. Verify the package name and registry are correct.", + path: ["searchSymbols"], + }, + ], + }), + { headers: { "Content-Type": "application/json" } }, + ), + ), + ); + + const service = new CodeNavigationServiceImpl( + BASE_URL, + createMockTokenProvider(), + globalThis.fetch, + ); + + await expect( + service.searchSymbols({ + target: { registry: "NPM", packageName: "nosuchpackage-zzzzz" }, + query: "middleware", + }), + ).rejects.toBeInstanceOf(CodeNavigationTargetNotFoundError); + }); + + it("maps NOT_FOUND extensions.code to CodeNavigationTargetNotFoundError", async () => { + // Forward-compat: once backend starts populating extensions.code + // (backend request B8), the structured path is preferred over the + // message heuristic. + mockFetch(() => + Promise.resolve( + new Response( + JSON.stringify({ + errors: [ + { + message: "No such target", + extensions: { code: "NOT_FOUND" }, + }, + ], + }), + { headers: { "Content-Type": "application/json" } }, + ), + ), + ); + + const service = new CodeNavigationServiceImpl( + BASE_URL, + createMockTokenProvider(), + globalThis.fetch, + ); + + await expect( + service.searchSymbols({ + target: { registry: "NPM", packageName: "unknown" }, + query: "middleware", + }), + ).rejects.toBeInstanceOf(CodeNavigationTargetNotFoundError); + }); + + it("wraps fetch failures in CodeNavigationNetworkError", async () => { + mockFetch(() => + Promise.reject( + Object.assign(new Error("connect ECONNREFUSED"), { + code: "ECONNREFUSED", + }), + ), + ); + + const service = new CodeNavigationServiceImpl( + BASE_URL, + createMockTokenProvider(), + globalThis.fetch, + ); + + await expect( + service.searchSymbols({ + target: { registry: "NPM", packageName: "express" }, + query: "middleware", + }), + ).rejects.toBeInstanceOf(CodeNavigationNetworkError); + }); + + it("maps 5xx responses to CodeNavigationBackendError with status", async () => { + mockFetch(() => + Promise.resolve( + new Response("Internal Server Error", { + status: 502, + headers: { "Content-Type": "text/plain" }, + }), + ), + ); + + const service = new CodeNavigationServiceImpl( + BASE_URL, + createMockTokenProvider(), + globalThis.fetch, + ); + + try { + await service.searchSymbols({ + target: { registry: "NPM", packageName: "express" }, + query: "middleware", + }); + expect.unreachable(); + } catch (err) { + expect(err).toBeInstanceOf(CodeNavigationBackendError); + expect((err as CodeNavigationBackendError).status).toBe(502); + } + }); + + it("dispatches extensions.code VERSION_NOT_FOUND into a typed error with structured fields", async () => { + mockFetch(() => + Promise.resolve( + new Response( + JSON.stringify({ + data: null, + errors: [ + { + message: + 'No version of npm/express matches "4". Available versions: 5.2.1, 5.1.0. Try: express@5.2.1 (exact) or express@^5.0.0 (latest 5.x).', + extensions: { + code: "VERSION_NOT_FOUND", + retryable: false, + package: "npm/express", + requested_version: "4", + latest_indexed: "5.2.1", + available_versions: [ + { version: "5.2.1", ref: "v5.2.1" }, + { version: "5.1.0", ref: "v5.1.0" }, + ], + }, + }, + ], + }), + ), + ), + ); + + const service = new CodeNavigationServiceImpl( + BASE_URL, + createMockTokenProvider(), + globalThis.fetch, + ); + + try { + await service.searchSymbols({ + target: { registry: "NPM", packageName: "express", version: "4" }, + query: "middleware", + }); + expect.unreachable(); + } catch (err) { + expect(err).toBeInstanceOf(CodeNavigationVersionNotFoundError); + const typed = err as CodeNavigationVersionNotFoundError; + expect(typed.packageName).toBe("npm/express"); + expect(typed.requestedVersion).toBe("4"); + expect(typed.latestIndexed).toBe("5.2.1"); + expect(typed.availableVersions).toEqual([ + { version: "5.2.1", ref: "v5.2.1" }, + { version: "5.1.0", ref: "v5.1.0" }, + ]); + } + }); + + it("dispatches extensions.code VALIDATION_ERROR to CodeNavigationValidationError", async () => { + mockFetch(() => + Promise.resolve( + new Response( + JSON.stringify({ + data: null, + errors: [ + { + message: "Query too long.", + extensions: { code: "VALIDATION_ERROR", retryable: false }, + }, + ], + }), + ), + ), + ); + + const service = new CodeNavigationServiceImpl( + BASE_URL, + createMockTokenProvider(), + globalThis.fetch, + ); + + await expect( + service.searchSymbols({ + target: { registry: "NPM", packageName: "express" }, + query: "x".repeat(1000), + }), + ).rejects.toBeInstanceOf(CodeNavigationValidationError); + }); + + it("dispatches extensions.code TIMEOUT to CodeNavigationBackendError with graphqlCode=TIMEOUT and retryable=true", async () => { + mockFetch(() => + Promise.resolve( + new Response( + JSON.stringify({ + data: null, + errors: [ + { + message: "Backend timed out.", + extensions: { code: "TIMEOUT", retryable: true }, + }, + ], + }), + ), + ), + ); + + const service = new CodeNavigationServiceImpl( + BASE_URL, + createMockTokenProvider(), + globalThis.fetch, + ); + + try { + await service.searchSymbols({ + target: { registry: "NPM", packageName: "express" }, + query: "middleware", + }); + expect.unreachable(); + } catch (err) { + expect(err).toBeInstanceOf(CodeNavigationBackendError); + const typed = err as CodeNavigationBackendError; + expect(typed.graphqlCode).toBe("TIMEOUT"); + expect(typed.retryable).toBe(true); + } + }); + + it("ignores legacy message heuristics when extensions.code is present (extensions take precedence)", async () => { + // Backend emits UPSTREAM_ERROR; the message happens to include + // "not found" because the upstream complained. The new dispatch + // must NOT misclassify this as NOT_FOUND. + mockFetch(() => + Promise.resolve( + new Response( + JSON.stringify({ + data: null, + errors: [ + { + message: "Upstream registry said: package not found.", + extensions: { code: "UPSTREAM_ERROR", retryable: true }, + }, + ], + }), + ), + ), + ); + + const service = new CodeNavigationServiceImpl( + BASE_URL, + createMockTokenProvider(), + globalThis.fetch, + ); + + try { + await service.searchSymbols({ + target: { registry: "NPM", packageName: "express" }, + query: "middleware", + }); + expect.unreachable(); + } catch (err) { + expect(err).not.toBeInstanceOf(CodeNavigationTargetNotFoundError); + expect(err).toBeInstanceOf(CodeNavigationBackendError); + expect((err as CodeNavigationBackendError).graphqlCode).toBe( + "UPSTREAM_ERROR", + ); + } + }); + + it("falls back to message heuristics when extensions.code is absent (legacy backend)", async () => { + // Pre-deployment backend response shape — still must work + // during the rollover window. + mockFetch(() => + Promise.resolve( + new Response( + JSON.stringify({ + data: null, + errors: [ + { + message: + "Package not found in registry: npm/nosuchpackage-zzzzz.", + path: ["searchSymbols"], + }, + ], + }), + ), + ), + ); + + const service = new CodeNavigationServiceImpl( + BASE_URL, + createMockTokenProvider(), + globalThis.fetch, + ); + + await expect( + service.searchSymbols({ + target: { registry: "NPM", packageName: "nosuchpackage-zzzzz" }, + query: "x", + }), + ).rejects.toBeInstanceOf(CodeNavigationTargetNotFoundError); + }); + + it("indexing error message guides callers to retry with a longer wait timeout", async () => { + mockFetch(() => + Promise.resolve( + new Response( + JSON.stringify({ + data: { + searchSymbols: { + results: [], + totalMatches: 0, + hasMore: false, + indexedVersion: null, + diagnostics: { hint: null }, + warning: null, + indexingStatus: "INDEXING", + indexingRef: "idx-123", + }, + }, + }), + ), + ), + ); + + const service = new CodeNavigationServiceImpl( + BASE_URL, + createMockTokenProvider(), + globalThis.fetch, + ); + + try { + await service.searchSymbols({ + target: { registry: "NPM", packageName: "express" }, + query: "middleware", + }); + expect.unreachable(); + } catch (err) { + expect(err).toBeInstanceOf(CodeNavigationIndexingError); + const message = (err as Error).message; + expect(message).toContain("Target is still indexing"); + expect(message).toContain("usually completes within 30 seconds"); + expect(message).toContain("--wait 60"); + expect(message).toContain("wait_timeout_ms: 60000"); + expect(message).toContain("idx-123"); + } + }); + + it("classifies 'could not resolve' messages as UNRESOLVABLE, not NOT_FOUND", async () => { + // Regression guard on two heuristics: the NOT_FOUND heuristic + // must not swallow resolution-failure phrasing, and the new + // UNRESOLVABLE heuristic must catch it so callers can + // distinguish "target does not exist" from "version/ref cannot + // be resolved". + mockFetch(() => + Promise.resolve( + new Response( + JSON.stringify({ + data: null, + errors: [ + { + message: + "Could not resolve version 25.6.0 to a Git ref for npm/@types/node.", + path: ["searchSymbols"], + }, + ], + }), + { headers: { "Content-Type": "application/json" } }, + ), + ), + ); + + const service = new CodeNavigationServiceImpl( + BASE_URL, + createMockTokenProvider(), + globalThis.fetch, + ); + + await expect( + service.searchSymbols({ + target: { + registry: "NPM", + packageName: "@types/node", + version: "25.6.0", + }, + query: "Buffer", + }), + ).rejects.toBeInstanceOf( + // Import it via the module's export so any rename is caught here. + (await import("./code-navigation-service.js")) + .CodeNavigationUnresolvableError, + ); + }); + + it("always sends mode: DETAILED and omits the $verbose variable from the GraphQL request", async () => { + // The service makes this choice once per request so both CLI and + // MCP consumers get the richest response. Confirm the request + // body inlines `mode: DETAILED` and carries no `verbose` variable. + const fn = mockFetch(() => + Promise.resolve( + new Response( + JSON.stringify({ + data: { + searchSymbols: { + results: [], + totalMatches: 0, + hasMore: false, + indexedVersion: null, + diagnostics: { hint: null }, + warning: null, + indexingStatus: "INDEXED", + }, + }, + }), + { headers: { "Content-Type": "application/json" } }, + ), + ), + ); + + const service = new CodeNavigationServiceImpl( + BASE_URL, + createMockTokenProvider(), + globalThis.fetch, + ); + + await service.searchSymbols({ + target: { registry: "NPM", packageName: "express" }, + query: "middleware", + }); + + const [, init] = fn.mock.calls[0] as unknown as [string, RequestInit]; + const body = JSON.parse(init.body as string); + expect(Object.keys(body.variables)).not.toContain("verbose"); + expect(Object.keys(body.variables)).not.toContain("mode"); + // mode is inlined as a literal in the query body + expect(body.query).toContain("mode: DETAILED"); + expect(body.query).not.toContain("@include(if: $verbose)"); + }); +}); diff --git a/src/services/code-navigation-service.ts b/src/services/code-navigation-service.ts new file mode 100644 index 00000000..332f7256 --- /dev/null +++ b/src/services/code-navigation-service.ts @@ -0,0 +1,856 @@ +import { z } from "zod"; +import { version } from "../../package.json"; +import { executeWithTokenRefresh } from "./execute-with-token-refresh.js"; +import { AuthenticationError } from "./githits-service.js"; +import type { TokenProvider } from "./token-manager.js"; + +export type CodeNavigationRegistry = + | "NPM" + | "PYPI" + | "HEX" + | "CRATES" + | "NUGET" + | "MAVEN" + | "ZIG" + | "VCPKG" + | "PACKAGIST"; + +export type SearchSymbolsMatchMode = "OR" | "AND"; + +/** + * Precise symbol kind from the backend's unified symbol taxonomy. + * Prefer `SymbolCategory` for broad filtering; use `SymbolKind` + * only when the caller knows the exact construct. + * + * Taxonomy notes: + * - `method` excludes constructor/getter/setter/operator + * - `field` excludes property/event + * - `module` excludes namespace/package/object + * - `class` excludes record/mixin/actor + * - `interface` excludes protocol/annotation + */ +export type SearchSymbolsKind = + | "FUNCTION" + | "METHOD" + | "CONSTRUCTOR" + | "GETTER" + | "SETTER" + | "OPERATOR" + | "CLASS" + | "INTERFACE" + | "TRAIT" + | "STRUCT" + | "ENUM" + | "RECORD" + | "PROTOCOL" + | "EXTENSION" + | "DELEGATE" + | "MIXIN" + | "ACTOR" + | "ANNOTATION" + | "TYPE" + | "MODULE" + | "NAMESPACE" + | "PACKAGE" + | "OBJECT" + | "FIELD" + | "PROPERTY" + | "EVENT" + | "CONSTANT" + | "DOC_SECTION"; + +/** + * Broad symbol category — the preferred filtering surface. Computed + * by the backend from the precise `kind`, so it works across the + * full kind taxonomy without enumerating individual kinds. + */ +export type SymbolCategory = + | "CALLABLE" + | "TYPE" + | "MODULE" + | "DATA" + | "DOCUMENTATION"; + +export type SearchSymbolsFileIntent = + | "PRODUCTION" + | "TEST" + | "BENCHMARK" + | "EXAMPLE" + | "GENERATED" + | "FIXTURE" + | "BUILD" + | "VENDOR"; + +export interface CodeNavigationTarget { + registry?: CodeNavigationRegistry; + packageName?: string; + version?: string; + repoUrl?: string; + gitRef?: string; +} + +export interface SearchSymbolsParams { + target: CodeNavigationTarget; + query?: string; + keywords?: string[]; + matchMode?: SearchSymbolsMatchMode; + /** + * Precise symbol kind. Prefer `category` for broad filtering; + * use `kind` only when the caller wants a specific construct. + */ + kind?: SearchSymbolsKind; + /** + * Broad symbol category filter. Preferred surface for filtering + * navigation queries — works across the 27-value kind taxonomy + * without enumerating individual kinds. + */ + category?: SymbolCategory; + filePath?: string; + limit?: number; + fileIntent?: SearchSymbolsFileIntent; + waitTimeoutMs?: number; +} + +export interface SearchSymbolsResultEntry { + name?: string; + filePath?: string; + startLine?: number; + endLine?: number; + preview?: string; + code?: string; + language?: string; + symbolRef?: string; + qualifiedPath?: string; + /** + * Precise symbol kind (lowercase string) from the backend's + * unified symbol taxonomy. Populated for every chunk — the + * backend handles the fallback from chunk-level classification + * to enrichment-level kind internally, so callers can treat + * this as the single source of truth for taxonomy. + */ + kind?: string; + /** + * Broad category of the primary symbol — one of `callable`, + * `type`, `module`, `data`, `documentation`. Computed by the + * backend from `kind`. Null for kinds with no category + * (e.g. CSS rules). + */ + category?: string; + arity?: number; + isPublic?: boolean; + /** + * Number of symbols contained in this chunk (DETAILED mode only, + * populated when > 1). Schema coerced to `Int` in the April 2026 + * backend update; earlier versions returned a string array. + */ + containedSymbols?: number; +} + +export interface SearchSymbolsResolution { + requestedVersion?: string; + requestedRef?: string; + resolvedRef?: string; + commitSha?: string; +} + +export interface SearchSymbolsResult { + results: SearchSymbolsResultEntry[]; + totalMatches: number; + hasMore: boolean; + version?: string; + resolution?: SearchSymbolsResolution; + hint?: string; + warning?: string; +} + +export interface AvailableVersion { + version?: string; + ref: string; +} + +export interface CodeNavigationService { + searchSymbols(params: SearchSymbolsParams): Promise; +} + +export class CodeNavigationAccessError extends Error { + constructor(message: string) { + super(message); + this.name = "CodeNavigationAccessError"; + } +} + +export class CodeNavigationGraphQLError extends Error { + constructor( + message: string, + public readonly code?: string, + ) { + super(message); + this.name = "CodeNavigationGraphQLError"; + } +} + +export class CodeNavigationIndexingError extends Error { + constructor( + message: string, + public readonly indexingRef?: string, + public readonly availableVersions?: AvailableVersion[], + ) { + super(message); + this.name = "CodeNavigationIndexingError"; + } +} + +export class CodeNavigationUnresolvableError extends Error { + constructor(message: string) { + super(message); + this.name = "CodeNavigationUnresolvableError"; + } +} + +export class MalformedCodeNavigationResponseError extends Error { + constructor(message: string) { + super(message); + this.name = "MalformedCodeNavigationResponseError"; + } +} + +export class CodeNavigationTargetNotFoundError extends Error { + constructor( + message: string, + public readonly availableVersions?: AvailableVersion[], + ) { + super(message); + this.name = "CodeNavigationTargetNotFoundError"; + } +} + +/** + * Raised when the target package exists in the index but the + * requested version has no matching indexed ref. Distinct from + * `CodeNavigationTargetNotFoundError` because the recovery path + * differs (switch to a supported version rather than check the + * package name). Carries the structured fields backend populates + * on `VERSION_NOT_FOUND`. + */ +export class CodeNavigationVersionNotFoundError extends Error { + constructor( + message: string, + public readonly packageName: string | undefined, + public readonly requestedVersion: string | undefined, + public readonly latestIndexed: string | undefined, + public readonly availableVersions: AvailableVersion[] | undefined, + ) { + super(message); + this.name = "CodeNavigationVersionNotFoundError"; + } +} + +/** + * Raised when the caller submitted invalid input that the backend + * rejected with `VALIDATION_ERROR` (e.g. query too long). Treated as + * a client error (non-retryable, INVALID_ARGUMENT on the mapped + * envelope). + */ +export class CodeNavigationValidationError extends Error { + constructor(message: string) { + super(message); + this.name = "CodeNavigationValidationError"; + } +} + +/** + * Raised when the caller hit a backend feature flag they don't have + * access to. Same user-facing handling as ACCESS_DENIED. + */ +export class CodeNavigationFeatureFlagRequiredError extends Error { + constructor(message: string) { + super(message); + this.name = "CodeNavigationFeatureFlagRequiredError"; + } +} + +export class CodeNavigationNetworkError extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = "CodeNavigationNetworkError"; + } +} + +export class CodeNavigationBackendError extends Error { + constructor( + message: string, + public readonly status?: number, + public readonly graphqlCode?: string, + /** + * Backend-provided retryability hint. When present, callers + * should trust it over per-code defaults. Populated by the + * April 2026 `extensions.retryable` contract on GraphQL errors. + */ + public readonly retryable?: boolean, + ) { + super(message); + this.name = "CodeNavigationBackendError"; + } +} + +/** + * Raised by the service when the caller supplied neither a query + * nor any keywords. Name starts with `Invalid` so + * `mapCodeNavigationError` classifies it as `INVALID_ARGUMENT`. + */ +export class InvalidSearchSymbolsRequestError extends Error { + constructor(message: string) { + super(message); + this.name = "InvalidSearchSymbolsRequestError"; + } +} + +// Always requests `mode: DETAILED` — the service makes this choice +// once so both CLI and MCP get the richest response (kind, category, +// endLine, language). Consumers derive a snippet from `code` when +// needed; `preview` is available in both modes but the formatter +// owns snippet rendering client-side for consistent truncation. +const SEARCH_SYMBOLS_QUERY = ` +query SearchSymbols( + $registry: Registry + $packageName: String + $repoUrl: String + $gitRef: String + $query: String + $keywords: [String!] + $matchMode: MatchMode + $kind: SymbolKind + $category: SymbolCategory + $filePath: String + $version: String + $limit: Int + $fileIntent: FileIntent + $waitTimeoutMs: Int +) { + searchSymbols( + registry: $registry + packageName: $packageName + repoUrl: $repoUrl + gitRef: $gitRef + query: $query + keywords: $keywords + matchMode: $matchMode + kind: $kind + category: $category + filePath: $filePath + version: $version + limit: $limit + fileIntent: $fileIntent + mode: DETAILED + waitTimeoutMs: $waitTimeoutMs + ) { + results { + name + filePath + startLine + endLine + preview + code + language + symbolRef + qualifiedPath + kind + category + arity + isPublic + containedSymbols + } + totalMatches + hasMore + indexedVersion + resolution { + requestedVersion + requestedRef + resolvedRef + commitSha + } + diagnostics { + hint + } + warning + indexingStatus + indexingRef + availableVersions { + version + ref + } + } +}`; + +const availableVersionSchema = z.object({ + version: z.string().nullable().optional(), + ref: z.string(), +}); + +const searchSymbolsResultEntrySchema = z.object({ + name: z.string().nullable().optional(), + filePath: z.string().nullable().optional(), + startLine: z.number().int().nullable().optional(), + endLine: z.number().int().nullable().optional(), + preview: z.string().nullable().optional(), + code: z.string().nullable().optional(), + language: z.string().nullable().optional(), + symbolRef: z.string().nullable().optional(), + qualifiedPath: z.string().nullable().optional(), + kind: z.string().nullable().optional(), + category: z.string().nullable().optional(), + arity: z.number().int().nullable().optional(), + isPublic: z.boolean().nullable().optional(), + containedSymbols: z.number().int().nullable().optional(), +}); + +const searchSymbolsResponseSchema = z.object({ + results: z.array(searchSymbolsResultEntrySchema), + totalMatches: z.number().int(), + hasMore: z.boolean(), + indexedVersion: z.string().nullable().optional(), + resolution: z + .object({ + requestedVersion: z.string().nullable().optional(), + requestedRef: z.string().nullable().optional(), + resolvedRef: z.string().nullable().optional(), + commitSha: z.string().nullable().optional(), + }) + .nullable() + .optional(), + diagnostics: z + .object({ + hint: z.string().nullable().optional(), + }) + .nullable() + .optional(), + warning: z.string().nullable().optional(), + indexingStatus: z.string(), + indexingRef: z.string().nullable().optional(), + availableVersions: z.array(availableVersionSchema).nullable().optional(), +}); + +const graphQLErrorSchema = z.object({ + message: z.string(), + extensions: z.record(z.string(), z.unknown()).optional(), +}); + +// `data` may be null (seen live for unknown packages that also carry +// `errors`), and `searchSymbols` may be null even when `data` is present. +// Both must parse successfully so the error-handling layer can classify. +const graphQLResponseSchema = z.object({ + data: z + .object({ + searchSymbols: searchSymbolsResponseSchema.nullable().optional(), + }) + .nullable() + .optional(), + errors: z.array(graphQLErrorSchema).optional(), +}); + +export class CodeNavigationServiceImpl implements CodeNavigationService { + constructor( + private readonly codeNavigationUrl: string, + private readonly tokenProvider: TokenProvider, + private readonly fetchFn: typeof fetch = globalThis.fetch, + ) {} + + async searchSymbols( + params: SearchSymbolsParams, + ): Promise { + return executeWithTokenRefresh({ + getToken: () => this.tokenProvider.getToken(), + forceRefresh: () => this.tokenProvider.forceRefresh(), + shouldRefresh: (error) => error instanceof AuthenticationError, + executeWithToken: (token) => this.executeSearchSymbols(token, params), + }); + } + + private async executeSearchSymbols( + token: string, + params: SearchSymbolsParams, + ): Promise { + if (!params.query && (!params.keywords || params.keywords.length === 0)) { + throw new InvalidSearchSymbolsRequestError( + "Either query or keywords must be provided.", + ); + } + + let response: Response; + try { + response = await this.fetchFn(`${this.baseUrl()}/api/graphql`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "User-Agent": `githits-cli/${version}`, + }, + body: JSON.stringify({ + query: SEARCH_SYMBOLS_QUERY, + variables: { + registry: params.target.registry, + packageName: params.target.packageName, + repoUrl: params.target.repoUrl, + gitRef: params.target.gitRef, + query: params.query, + keywords: params.keywords, + matchMode: params.matchMode, + kind: params.kind, + category: params.category, + filePath: params.filePath, + version: params.target.version, + limit: params.limit, + fileIntent: params.fileIntent, + waitTimeoutMs: params.waitTimeoutMs, + }, + }), + }); + } catch (cause) { + throw new CodeNavigationNetworkError( + "Could not reach the code navigation service. Check your connection or set GITHITS_CODE_NAV_URL.", + { cause }, + ); + } + + if (!response.ok) { + throw await this.createHttpError(response); + } + + const parsed = graphQLResponseSchema.safeParse( + await response.json().catch(() => null), + ); + if (!parsed.success) { + throw new MalformedCodeNavigationResponseError( + "Malformed response from code navigation service.", + ); + } + + if (parsed.data.errors && parsed.data.errors.length > 0) { + throw this.createGraphQLError(parsed.data.errors); + } + + const data = parsed.data.data?.searchSymbols; + if (!data) { + // `data: null` or `data.searchSymbols: null` with no `errors` entry. + // Rare — the backend normally couples null payloads with errors. + throw new MalformedCodeNavigationResponseError( + "Malformed response from code navigation service.", + ); + } + + if (data.indexingStatus === "INDEXING") { + throw new CodeNavigationIndexingError( + this.createIndexingMessage(data.indexingRef ?? undefined), + data.indexingRef ?? undefined, + data.availableVersions?.map((entry) => ({ + version: entry.version ?? undefined, + ref: entry.ref, + })), + ); + } + + if (data.indexingStatus === "UNRESOLVABLE") { + throw new CodeNavigationUnresolvableError( + "The requested target or version could not be resolved.", + ); + } + + return { + results: data.results.map((entry) => ({ + name: entry.name ?? undefined, + filePath: entry.filePath ?? undefined, + startLine: entry.startLine ?? undefined, + endLine: entry.endLine ?? undefined, + preview: entry.preview ?? undefined, + code: entry.code ?? undefined, + language: entry.language ?? undefined, + symbolRef: entry.symbolRef ?? undefined, + qualifiedPath: entry.qualifiedPath ?? undefined, + kind: entry.kind ?? undefined, + category: entry.category ?? undefined, + arity: entry.arity ?? undefined, + isPublic: entry.isPublic ?? undefined, + containedSymbols: entry.containedSymbols ?? undefined, + })), + totalMatches: data.totalMatches, + hasMore: data.hasMore, + version: data.indexedVersion ?? undefined, + resolution: data.resolution + ? { + requestedVersion: data.resolution.requestedVersion ?? undefined, + requestedRef: data.resolution.requestedRef ?? undefined, + resolvedRef: data.resolution.resolvedRef ?? undefined, + commitSha: data.resolution.commitSha ?? undefined, + } + : undefined, + hint: data.diagnostics?.hint ?? undefined, + warning: data.warning ?? undefined, + }; + } + + private baseUrl(): string { + return this.codeNavigationUrl.replace(/\/+$/, ""); + } + + private async createHttpError(response: Response): Promise { + const status = response.status; + const body = await response.text().catch(() => ""); + const detail = parseDetail(body); + + if (status === 401) { + return new AuthenticationError( + "Authentication required. Run `githits login` to authenticate.", + ); + } + + if (status === 403) { + return new CodeNavigationAccessError( + detail ?? "Code navigation access denied.", + ); + } + + if (status >= 500) { + return new CodeNavigationBackendError( + detail + ? `Server error (${status}): ${detail}` + : `Server error (${status})`, + status, + ); + } + + return new CodeNavigationBackendError( + detail ?? `Request failed with status ${status}`, + status, + ); + } + + private createGraphQLError( + errors: Array>, + ): Error { + const message = errors.map((error) => error.message).join(", "); + const extensions = getPrimaryExtensions(errors); + const code = + typeof extensions?.code === "string" ? extensions.code : undefined; + const retryable = + typeof extensions?.retryable === "boolean" + ? extensions.retryable + : undefined; + const indexingRef = getGraphQLIndexingRef(errors); + + // Direct dispatch on extensions.code — the April 2026 backend + // contract populates this on every error. Fall back to message + // heuristics below for older backend builds that haven't + // deployed yet (safe to remove once rollout completes). + switch (code) { + case "PACKAGE_INDEXING": + return new CodeNavigationIndexingError( + this.createIndexingMessage(indexingRef), + indexingRef, + parseAvailableVersions(extensions), + ); + + case "VERSION_NOT_FOUND": + return new CodeNavigationVersionNotFoundError( + message, + typeof extensions?.package === "string" + ? extensions.package + : undefined, + typeof extensions?.requested_version === "string" + ? extensions.requested_version + : undefined, + typeof extensions?.latest_indexed === "string" + ? extensions.latest_indexed + : undefined, + parseAvailableVersions(extensions), + ); + + case "NOT_FOUND": + case "PACKAGE_NOT_FOUND": + case "NO_REPOSITORY_URL": + return new CodeNavigationTargetNotFoundError(message); + + case "UNSUPPORTED_REGISTRY": + case "VALIDATION_ERROR": + return new CodeNavigationValidationError(message); + + case "FEATURE_FLAG_REQUIRED": + return new CodeNavigationFeatureFlagRequiredError(message); + + case "UNAUTHORIZED": + return new AuthenticationError( + "Authentication required. Run `githits login` to authenticate.", + ); + + case "FORBIDDEN": + return new CodeNavigationAccessError( + "Code navigation access denied. This feature may not be enabled for your account.", + ); + + case "UPSTREAM_ERROR": + case "TIMEOUT": + case "RATE_LIMITED": + case "INTERNAL_ERROR": + case "UNKNOWN_ERROR": + return new CodeNavigationBackendError( + message, + undefined, + code, + retryable, + ); + + // `code` was present but not one of the recognised values — + // forward it onward as BACKEND_ERROR, preserving the code so + // the classifier can still surface it to callers. + default: + break; + } + + // Legacy fallback: backend didn't populate `extensions.code`. + // Retain the message-text heuristics so rollover works smoothly; + // remove once all backend deploys are confirmed. + if (code === undefined) { + if (isAuthMessage(message)) { + return new CodeNavigationAccessError( + "Code navigation access denied. This feature may not be enabled for your account.", + ); + } + if (isUnresolvableMessage(message)) { + return new CodeNavigationUnresolvableError(message); + } + if (isTargetNotFoundMessage(message)) { + return new CodeNavigationTargetNotFoundError(message); + } + } + + return new CodeNavigationBackendError(message, undefined, code, retryable); + } + + private createIndexingMessage(indexingRef?: string): string { + // Backend p50 indexing time ~11 s, mean ~17 s, backend ceiling 60 + // s. Give callers both a concrete "retry shortly" expectation and + // the option to block until ready via a longer wait timeout. + const base = + "Target is still indexing. Indexing usually completes within 30 seconds. Retry this request, or pass a longer wait timeout (CLI: `--wait 60`, MCP: `wait_timeout_ms: 60000`) to block until ready."; + if (indexingRef) { + return `${base} Indexing reference: ${indexingRef}.`; + } + return base; + } +} + +function parseDetail(body: string): string | undefined { + if (!body) return undefined; + + try { + const parsed = JSON.parse(body) as Record; + if (typeof parsed.detail === "string") return parsed.detail; + if (typeof parsed.error === "string") return parsed.error; + } catch { + return body; + } + + return undefined; +} + +/** + * Pick the first GraphQL error entry that carries an `extensions` + * block. Backends typically emit a single error per failure; when + * multiple are present the primary one wins. Separate from the + * joined message string so callers can reach structured fields + * (package, latest_indexed, available_versions, retryable). + */ +function getPrimaryExtensions( + errors: Array>, +): Record | undefined { + for (const error of errors) { + if (error.extensions && Object.keys(error.extensions).length > 0) { + return error.extensions; + } + } + return undefined; +} + +function getGraphQLIndexingRef( + errors: Array>, +): string | undefined { + for (const error of errors) { + const indexingRef = + error.extensions?.indexing_ref ?? error.extensions?.indexingRef; + if (typeof indexingRef === "string") return indexingRef; + } + + return undefined; +} + +/** + * Parse `extensions.available_versions` (snake_case on the wire) into + * the internal `AvailableVersion` shape. Silently drops malformed + * entries so a partially-broken extensions block does not prevent + * the user from seeing whatever good suggestions are available. + */ +function parseAvailableVersions( + extensions: Record | undefined, +): AvailableVersion[] | undefined { + const raw = extensions?.available_versions ?? extensions?.availableVersions; + if (!Array.isArray(raw)) return undefined; + const parsed: AvailableVersion[] = []; + for (const item of raw) { + if (item && typeof item === "object" && "ref" in item) { + const entry = item as { ref?: unknown; version?: unknown }; + if (typeof entry.ref === "string") { + parsed.push({ + ref: entry.ref, + version: + typeof entry.version === "string" ? entry.version : undefined, + }); + } + } + } + return parsed.length > 0 ? parsed : undefined; +} + +function isAuthMessage(message: string): boolean { + const lower = message.toLowerCase(); + return ( + lower.includes("unauthorized") || + lower.includes("forbidden") || + lower.includes("permission") || + lower.includes("authentication") + ); +} + +/** + * Heuristic fallback for NOT_FOUND detection when the backend does not + * populate `extensions.code` on GraphQL errors. See backend request B8 + * — once backend adds structured codes, this heuristic becomes a + * secondary fallback rather than the primary signal. + * + * Scope deliberately narrow: "not found" / "unknown package" are + * lookup-failure signals. Phrases like "could not resolve" are avoided + * here because they also appear in `UNRESOLVABLE` messages, which + * have different semantics for the caller (version/ref resolution + * failure rather than the target not existing). + */ +function isTargetNotFoundMessage(message: string): boolean { + const lower = message.toLowerCase(); + return ( + lower.includes("not found") || + lower.includes("unknown package") || + lower.includes("no such package") || + lower.includes("does not exist") + ); +} + +/** + * Heuristic for UNRESOLVABLE — used when the backend returns a + * version/ref resolution failure (e.g. "Could not resolve version + * X to a Git ref") without populating `extensions.code`. Semantically + * distinct from NOT_FOUND: the target is known, but the requested + * version/ref cannot be mapped to indexable source. + */ +function isUnresolvableMessage(message: string): boolean { + const lower = message.toLowerCase(); + return ( + lower.includes("could not resolve") || lower.includes("cannot resolve") + ); +} diff --git a/src/services/config.test.ts b/src/services/config.test.ts new file mode 100644 index 00000000..8c15dc98 --- /dev/null +++ b/src/services/config.test.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { + getCodeNavigationUrl, + isCodeNavigationCliOverrideEnabled, +} from "./config.js"; + +describe("code navigation config", () => { + const originalCodeNavUrl = process.env.GITHITS_CODE_NAV_URL; + const originalPkgseerUrl = process.env.PKGSEER_URL; + const originalCodeNavigation = process.env.GITHITS_CODE_NAVIGATION; + const originalMcpUrl = process.env.GITHITS_MCP_URL; + const originalApiUrl = process.env.GITHITS_API_URL; + + afterEach(() => { + restoreEnv("GITHITS_CODE_NAV_URL", originalCodeNavUrl); + restoreEnv("PKGSEER_URL", originalPkgseerUrl); + restoreEnv("GITHITS_CODE_NAVIGATION", originalCodeNavigation); + restoreEnv("GITHITS_MCP_URL", originalMcpUrl); + restoreEnv("GITHITS_API_URL", originalApiUrl); + }); + + it("prefers GITHITS_CODE_NAV_URL over PKGSEER_URL", () => { + process.env.GITHITS_CODE_NAV_URL = "https://nav.githits.test"; + process.env.PKGSEER_URL = "https://pkgseer.test"; + + expect(getCodeNavigationUrl()).toBe("https://nav.githits.test"); + }); + + it("falls back to PKGSEER_URL when GitHits URL is unset", () => { + delete process.env.GITHITS_CODE_NAV_URL; + process.env.PKGSEER_URL = "https://pkgseer.test"; + + expect(getCodeNavigationUrl()).toBe("https://pkgseer.test"); + }); + + it("uses pkgseer.dev by default when no overrides are set", () => { + delete process.env.GITHITS_CODE_NAV_URL; + delete process.env.PKGSEER_URL; + + expect(getCodeNavigationUrl()).toBe("https://pkgseer.dev"); + }); + + it("does not default to pkgseer.dev for custom GitHits environments", () => { + delete process.env.GITHITS_CODE_NAV_URL; + delete process.env.PKGSEER_URL; + process.env.GITHITS_MCP_URL = "https://mcp.staging.githits.test"; + + expect(getCodeNavigationUrl()).toBeUndefined(); + }); + + it("returns false when CLI override is unset", () => { + delete process.env.GITHITS_CODE_NAVIGATION; + expect(isCodeNavigationCliOverrideEnabled()).toBe(false); + }); + + it("returns true when CLI override is set to 1", () => { + process.env.GITHITS_CODE_NAVIGATION = "1"; + expect(isCodeNavigationCliOverrideEnabled()).toBe(true); + }); + + it("returns false when CLI override is set to false", () => { + process.env.GITHITS_CODE_NAVIGATION = "false"; + expect(isCodeNavigationCliOverrideEnabled()).toBe(false); + }); +}); + +function restoreEnv(name: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[name]; + return; + } + + process.env[name] = value; +} diff --git a/src/services/config.ts b/src/services/config.ts index 0bc542c3..a4eeeb17 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -8,6 +8,7 @@ const DEFAULT_MCP_URL = "https://mcp.githits.com"; const DEFAULT_API_URL = "https://api.githits.com"; +const DEFAULT_CODE_NAV_URL = "https://pkgseer.dev"; /** * Get the MCP server base URL (for OAuth discovery). @@ -25,9 +26,40 @@ export function getApiUrl(): string { return process.env.GITHITS_API_URL ?? DEFAULT_API_URL; } +/** + * Get the code-navigation backend URL. `GITHITS_CODE_NAV_URL` is the + * supported override; a legacy env var is also accepted for local + * development parity with older environments but is not publicly + * documented. + */ +export function getCodeNavigationUrl(): string | undefined { + const explicitUrl = + process.env.GITHITS_CODE_NAV_URL ?? process.env.PKGSEER_URL; + if (explicitUrl) { + return explicitUrl; + } + + const usingDefaultEnvironment = + getMcpUrl() === DEFAULT_MCP_URL && getApiUrl() === DEFAULT_API_URL; + return usingDefaultEnvironment ? DEFAULT_CODE_NAV_URL : undefined; +} + /** * Get API token from environment variable (for CI/automation). */ export function getEnvApiToken(): string | undefined { return process.env.GITHITS_API_TOKEN; } + +/** + * Whether `GITHITS_CODE_NAVIGATION` forces the capability-gated + * `code` CLI commands to be exposed locally regardless of the + * startup token's `code_navigation` claim. + */ +export function isCodeNavigationCliOverrideEnabled(): boolean { + const raw = process.env.GITHITS_CODE_NAVIGATION; + if (!raw) return false; + + const normalized = raw.toLowerCase(); + return normalized !== "0" && normalized !== "false" && normalized !== ""; +} diff --git a/src/services/execute-with-token-refresh.ts b/src/services/execute-with-token-refresh.ts new file mode 100644 index 00000000..cd66778e --- /dev/null +++ b/src/services/execute-with-token-refresh.ts @@ -0,0 +1,38 @@ +import { AuthenticationError } from "./githits-service.js"; + +export interface ExecuteWithTokenRefreshOptions { + getToken: () => Promise; + forceRefresh: () => Promise; + executeWithToken: (token: string) => Promise; + shouldRefresh: (error: unknown) => boolean; +} + +/** + * Executes a token-authenticated operation and retries once after refresh when + * the caller marks the failure as refreshable. + */ +export async function executeWithTokenRefresh( + options: ExecuteWithTokenRefreshOptions, +): Promise { + const token = await options.getToken(); + if (!token) { + throw new AuthenticationError( + "Authentication required. Run `githits login` to authenticate.", + ); + } + + try { + return await options.executeWithToken(token); + } catch (error) { + if (!options.shouldRefresh(error)) { + throw error; + } + + const refreshedToken = await options.forceRefresh(); + if (!refreshedToken) { + throw error; + } + + return options.executeWithToken(refreshedToken); + } +} diff --git a/src/services/index.ts b/src/services/index.ts index 0bbb3e8d..2ccde9fc 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -10,7 +10,6 @@ export type { TokenResponse, } from "./auth-service.js"; export { AuthServiceImpl } from "./auth-service.js"; - export type { AuthStorage, ClientRegistration, @@ -23,9 +22,47 @@ export { ChunkingKeyringService, WINDOWS_MAX_ENTRY_SIZE, } from "./chunking-keyring-service.js"; -export { getApiUrl, getEnvApiToken, getMcpUrl } from "./config.js"; +export type { CodeNavigationCapability } from "./code-navigation-capability.js"; +export { getCodeNavigationCapability } from "./code-navigation-capability.js"; +export type { + AvailableVersion, + CodeNavigationRegistry, + CodeNavigationService, + CodeNavigationTarget, + SearchSymbolsFileIntent, + SearchSymbolsKind, + SearchSymbolsMatchMode, + SearchSymbolsParams, + SearchSymbolsResolution, + SearchSymbolsResult, + SearchSymbolsResultEntry, + SymbolCategory, +} from "./code-navigation-service.js"; +export { + CodeNavigationAccessError, + CodeNavigationBackendError, + CodeNavigationFeatureFlagRequiredError, + CodeNavigationGraphQLError, + CodeNavigationIndexingError, + CodeNavigationNetworkError, + CodeNavigationServiceImpl, + CodeNavigationTargetNotFoundError, + CodeNavigationUnresolvableError, + CodeNavigationValidationError, + CodeNavigationVersionNotFoundError, + InvalidSearchSymbolsRequestError, + MalformedCodeNavigationResponseError, +} from "./code-navigation-service.js"; +export { + getApiUrl, + getCodeNavigationUrl, + getEnvApiToken, + getMcpUrl, + isCodeNavigationCliOverrideEnabled, +} from "./config.js"; export type { ExecResult, ExecService } from "./exec-service.js"; export { ExecServiceImpl } from "./exec-service.js"; +export { executeWithTokenRefresh } from "./execute-with-token-refresh.js"; export type { FileSystemService } from "./filesystem-service.js"; export { FileSystemServiceImpl } from "./filesystem-service.js"; export type { diff --git a/src/services/refreshing-githits-service.ts b/src/services/refreshing-githits-service.ts index 41b5b21e..c9389d1f 100644 --- a/src/services/refreshing-githits-service.ts +++ b/src/services/refreshing-githits-service.ts @@ -1,3 +1,4 @@ +import { executeWithTokenRefresh } from "./execute-with-token-refresh.js"; import { AuthenticationError, type FeedbackParams, @@ -46,29 +47,14 @@ export class RefreshingGitHitsService implements GitHitsService { private async withTokenRefresh( operation: (service: GitHitsService) => Promise, ): Promise { - const token = await this.tokenProvider.getToken(); - if (!token) { - throw new AuthenticationError( - "Authentication required. Run `githits login` to authenticate.", - ); - } - - const service = this.serviceFactory(this.apiUrl, token); - try { - return await operation(service); - } catch (error) { - if (error instanceof AuthenticationError) { - const refreshedToken = await this.tokenProvider.forceRefresh(); - if (!refreshedToken) { - throw error; - } - const refreshedService = this.serviceFactory( - this.apiUrl, - refreshedToken, - ); - return operation(refreshedService); - } - throw error; - } + return executeWithTokenRefresh({ + getToken: () => this.tokenProvider.getToken(), + forceRefresh: () => this.tokenProvider.forceRefresh(), + shouldRefresh: (error) => error instanceof AuthenticationError, + executeWithToken: async (token) => { + const service = this.serviceFactory(this.apiUrl, token); + return operation(service); + }, + }); } } diff --git a/src/services/test-helpers.ts b/src/services/test-helpers.ts index 55075ee8..1686d9a7 100644 --- a/src/services/test-helpers.ts +++ b/src/services/test-helpers.ts @@ -12,6 +12,10 @@ import type { TokenData, } from "./auth-storage.js"; import type { BrowserService } from "./browser-service.js"; +import type { + CodeNavigationService, + SearchSymbolsResult, +} from "./code-navigation-service.js"; import type { ExecResult, ExecService } from "./exec-service.js"; import type { FileSystemService } from "./filesystem-service.js"; import type { GitHitsService } from "./githits-service.js"; @@ -88,6 +92,30 @@ export function createMockAuthService( }; } +/** + * Default code navigation search result for testing. Matches the + * DETAILED-mode response shape the service now always requests: + * `kind`, `category`, `endLine`, `language`, and `code` are + * populated; `preview` is null (callers build snippets from `code`). + */ +export const defaultSearchSymbolsResult: SearchSymbolsResult = { + results: [ + { + name: "useMiddleware", + kind: "function", + category: "callable", + filePath: "src/app.js", + startLine: 42, + endLine: 48, + code: "function useMiddleware(fn) {\n fn();\n return null;\n}", + language: "javascript", + }, + ], + totalMatches: 1, + hasMore: false, + version: "4.18.0", +}; + /** * Creates a mock AuthStorage with default implementations. */ @@ -185,6 +213,18 @@ export function createMockGitHitsService( }; } +/** + * Creates a mock CodeNavigationService with default implementations. + */ +export function createMockCodeNavigationService( + impl: Partial = {}, +): CodeNavigationService { + return { + searchSymbols: mock(() => Promise.resolve(defaultSearchSymbolsResult)), + ...impl, + }; +} + /** * Creates a mock KeyringService with default implementations. */ @@ -227,6 +267,18 @@ export function createValidTokenData( }; } +/** + * Creates an unsigned JWT-like token string for payload decoding tests. + */ +export function createJwtToken(payload: Record): string { + const encode = (value: unknown) => + Buffer.from(JSON.stringify(value), "utf8") + .toString("base64url") + .replace(/=/g, ""); + + return `${encode({ alg: "none", typ: "JWT" })}.${encode(payload)}.signature`; +} + /** * Creates a mock PromptService with default implementations. */ diff --git a/src/shared/code-navigation-defaults.ts b/src/shared/code-navigation-defaults.ts new file mode 100644 index 00000000..303e8cc0 --- /dev/null +++ b/src/shared/code-navigation-defaults.ts @@ -0,0 +1,52 @@ +import type { SearchSymbolsFileIntent } from "../services/index.js"; + +/** + * Default indexing wait time for any code-navigation request issued + * by the CLI or MCP surfaces. Both surfaces import this so defaults + * never diverge silently. + * + * 20 seconds sits above the p50 (~11 s) and close to the mean (~17 + * s) observed backend indexing time — most first-time requests + * complete within this window. Callers who hit an INDEXING response + * can retry with up to `MAX_WAIT_TIMEOUT_MS` to block until ready. + */ +export const DEFAULT_WAIT_TIMEOUT_MS = 20_000; + +/** + * Backend ceiling on how long a single request may wait for + * indexing. Clamp callers to this ceiling so the backend never + * rejects a request for an oversized wait. + */ +export const MAX_WAIT_TIMEOUT_MS = 60_000; + +/** + * Tool-scoped default: when a caller omits `file_intent` on the + * `search_symbols` surface, apply this filter so top results are + * production source rather than tests/benchmarks/examples. Named with + * a `SEARCH_SYMBOLS_` prefix so tool #2 can declare its own defaults + * alongside without naming churn. + */ +export const SEARCH_SYMBOLS_DEFAULT_FILE_INTENT: SearchSymbolsFileIntent = + "PRODUCTION"; + +/** + * Sentinel: the caller explicitly asked for "all intents" (CLI + * `--intent all`, MCP `file_intent: "all"`). Distinct from `undefined` + * (which means "caller did not set the field") so the service + * translation layer can make an intentional choice rather than guess. + * + * Translates to "omit the GraphQL variable" at the service layer, + * where omission returns results from every file intent (confirmed + * against the live backend across both package and repo scopes). + */ +export const FILE_INTENT_ALL = Symbol("FILE_INTENT_ALL"); + +/** + * User-facing file_intent input: either a specific intent, the + * `FILE_INTENT_ALL` sentinel, or `undefined` (no intent set — callers + * should default this at the entry point). + */ +export type FileIntentInput = + | SearchSymbolsFileIntent + | typeof FILE_INTENT_ALL + | undefined; diff --git a/src/shared/code-navigation-error-map.test.ts b/src/shared/code-navigation-error-map.test.ts new file mode 100644 index 00000000..77a45bc7 --- /dev/null +++ b/src/shared/code-navigation-error-map.test.ts @@ -0,0 +1,357 @@ +import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; +import { + CodeNavigationAccessError, + CodeNavigationBackendError, + CodeNavigationFeatureFlagRequiredError, + CodeNavigationGraphQLError, + CodeNavigationIndexingError, + CodeNavigationNetworkError, + CodeNavigationTargetNotFoundError, + CodeNavigationUnresolvableError, + CodeNavigationValidationError, + CodeNavigationVersionNotFoundError, + InvalidSearchSymbolsRequestError, + MalformedCodeNavigationResponseError, +} from "../services/code-navigation-service.js"; +import { AuthenticationError } from "../services/githits-service.js"; +import { mapCodeNavigationError } from "./code-navigation-error-map.js"; + +class InvalidPackageSpecError extends Error { + constructor(message: string) { + super(message); + this.name = "InvalidPackageSpecError"; + } +} + +class UnsupportedRegistryError extends Error { + constructor(message: string) { + super(message); + this.name = "UnsupportedRegistryError"; + } +} + +describe("mapCodeNavigationError", () => { + it("classifies CodeNavigationTargetNotFoundError as NOT_FOUND", () => { + const err = new CodeNavigationTargetNotFoundError("Package not found", [ + { version: "5.2.1", ref: "v5.2.1" }, + ]); + expect(mapCodeNavigationError(err)).toEqual({ + code: "NOT_FOUND", + message: "Package not found", + retryable: false, + details: { availableVersions: [{ version: "5.2.1", ref: "v5.2.1" }] }, + }); + }); + + it("classifies CodeNavigationTargetNotFoundError without availableVersions", () => { + const err = new CodeNavigationTargetNotFoundError("Package not found"); + expect(mapCodeNavigationError(err)).toEqual({ + code: "NOT_FOUND", + message: "Package not found", + retryable: false, + }); + }); + + it("classifies CodeNavigationVersionNotFoundError as VERSION_NOT_FOUND with structured details", () => { + const err = new CodeNavigationVersionNotFoundError( + 'No version of npm/express matches "4". Available versions: 5.2.1, 5.1.0. Try: express@5.2.1.', + "npm/express", + "4", + "5.2.1", + [ + { version: "5.2.1", ref: "v5.2.1" }, + { version: "5.1.0", ref: "v5.1.0" }, + ], + ); + expect(mapCodeNavigationError(err)).toEqual({ + code: "VERSION_NOT_FOUND", + message: + 'No version of npm/express matches "4". Available versions: 5.2.1, 5.1.0. Try: express@5.2.1.', + retryable: false, + details: { + package: "npm/express", + requestedVersion: "4", + latestIndexed: "5.2.1", + availableVersions: [ + { version: "5.2.1", ref: "v5.2.1" }, + { version: "5.1.0", ref: "v5.1.0" }, + ], + }, + }); + }); + + it("classifies CodeNavigationIndexingError as INDEXING with details", () => { + const err = new CodeNavigationIndexingError( + "Indexing in progress", + "idx-42", + [{ version: "5.2.1", ref: "v5.2.1" }], + ); + expect(mapCodeNavigationError(err)).toEqual({ + code: "INDEXING", + message: "Indexing in progress", + retryable: true, + details: { + indexingRef: "idx-42", + availableVersions: [{ version: "5.2.1", ref: "v5.2.1" }], + }, + }); + }); + + it("classifies CodeNavigationUnresolvableError as UNRESOLVABLE", () => { + const err = new CodeNavigationUnresolvableError("Cannot resolve"); + expect(mapCodeNavigationError(err)).toEqual({ + code: "UNRESOLVABLE", + message: "Cannot resolve", + retryable: false, + }); + }); + + it("classifies CodeNavigationAccessError as ACCESS_DENIED", () => { + const err = new CodeNavigationAccessError("Denied"); + expect(mapCodeNavigationError(err)).toEqual({ + code: "ACCESS_DENIED", + message: "Denied", + retryable: false, + }); + }); + + it("classifies CodeNavigationFeatureFlagRequiredError as ACCESS_DENIED", () => { + const err = new CodeNavigationFeatureFlagRequiredError( + "Feature flag required", + ); + expect(mapCodeNavigationError(err)).toEqual({ + code: "ACCESS_DENIED", + message: "Feature flag required", + retryable: false, + }); + }); + + it("classifies AuthenticationError as AUTH_REQUIRED", () => { + const err = new AuthenticationError("Login required"); + expect(mapCodeNavigationError(err)).toEqual({ + code: "AUTH_REQUIRED", + message: "Login required", + retryable: false, + }); + }); + + it("classifies CodeNavigationNetworkError as NETWORK", () => { + const err = new CodeNavigationNetworkError("Cannot connect"); + expect(mapCodeNavigationError(err)).toEqual({ + code: "NETWORK", + message: "Cannot connect", + retryable: true, + }); + }); + + it("classifies CodeNavigationValidationError as INVALID_ARGUMENT", () => { + const err = new CodeNavigationValidationError("Query too long"); + expect(mapCodeNavigationError(err)).toEqual({ + code: "INVALID_ARGUMENT", + message: "Query too long", + retryable: false, + }); + }); + + it("classifies CodeNavigationBackendError with INTERNAL_ERROR as BACKEND_ERROR (non-retryable default)", () => { + const err = new CodeNavigationBackendError( + "Server error (502)", + 502, + "INTERNAL_ERROR", + ); + expect(mapCodeNavigationError(err)).toEqual({ + code: "BACKEND_ERROR", + message: "Server error (502)", + retryable: false, + details: { status: 502, graphqlCode: "INTERNAL_ERROR" }, + }); + }); + + it("classifies CodeNavigationBackendError with TIMEOUT as TIMEOUT (retryable)", () => { + const err = new CodeNavigationBackendError( + "Backend timed out", + undefined, + "TIMEOUT", + ); + expect(mapCodeNavigationError(err)).toEqual({ + code: "TIMEOUT", + message: "Backend timed out", + retryable: true, + details: { graphqlCode: "TIMEOUT" }, + }); + }); + + it("classifies CodeNavigationBackendError with RATE_LIMITED as RATE_LIMITED (retryable)", () => { + const err = new CodeNavigationBackendError( + "Too many requests", + undefined, + "RATE_LIMITED", + ); + expect(mapCodeNavigationError(err)).toEqual({ + code: "RATE_LIMITED", + message: "Too many requests", + retryable: true, + details: { graphqlCode: "RATE_LIMITED" }, + }); + }); + + it("classifies CodeNavigationBackendError with UPSTREAM_ERROR as BACKEND_ERROR (retryable default)", () => { + const err = new CodeNavigationBackendError( + "Upstream failed", + undefined, + "UPSTREAM_ERROR", + ); + expect(mapCodeNavigationError(err)).toEqual({ + code: "BACKEND_ERROR", + message: "Upstream failed", + retryable: true, + details: { graphqlCode: "UPSTREAM_ERROR" }, + }); + }); + + it("honours backend-supplied retryable override on CodeNavigationBackendError", () => { + // Backend ships `extensions.retryable: true` on a code that + // would otherwise default to non-retryable. + const err = new CodeNavigationBackendError( + "Backend hiccup", + 500, + "INTERNAL_ERROR", + true, + ); + expect(mapCodeNavigationError(err)).toEqual({ + code: "BACKEND_ERROR", + message: "Backend hiccup", + retryable: true, + details: { status: 500, graphqlCode: "INTERNAL_ERROR" }, + }); + }); + + it("classifies legacy CodeNavigationGraphQLError as BACKEND_ERROR", () => { + const err = new CodeNavigationGraphQLError("Legacy graphql failure", "X"); + expect(mapCodeNavigationError(err)).toEqual({ + code: "BACKEND_ERROR", + message: "Legacy graphql failure", + retryable: false, + details: { graphqlCode: "X" }, + }); + }); + + it("classifies MalformedCodeNavigationResponseError as PROTOCOL_ERROR", () => { + const err = new MalformedCodeNavigationResponseError("Bad payload"); + expect(mapCodeNavigationError(err)).toEqual({ + code: "PROTOCOL_ERROR", + message: "Bad payload", + retryable: false, + }); + }); + + it("classifies InvalidPackageSpecError as INVALID_ARGUMENT", () => { + const err = new InvalidPackageSpecError("bad spec"); + expect(mapCodeNavigationError(err)).toEqual({ + code: "INVALID_ARGUMENT", + message: "bad spec", + retryable: false, + }); + }); + + it("classifies UnsupportedRegistryError as INVALID_ARGUMENT", () => { + const err = new UnsupportedRegistryError("no such registry"); + expect(mapCodeNavigationError(err)).toEqual({ + code: "INVALID_ARGUMENT", + message: "no such registry", + retryable: false, + }); + }); + + it("classifies InvalidSearchSymbolsRequestError as INVALID_ARGUMENT", () => { + const err = new InvalidSearchSymbolsRequestError( + "Either query or keywords must be provided.", + ); + expect(mapCodeNavigationError(err)).toEqual({ + code: "INVALID_ARGUMENT", + message: "Either query or keywords must be provided.", + retryable: false, + }); + }); + + it("falls through to UNKNOWN for plain Error", () => { + const err = new Error("mystery"); + expect(mapCodeNavigationError(err)).toEqual({ + code: "UNKNOWN", + message: "mystery", + retryable: false, + }); + }); + + it("falls through to UNKNOWN for non-Error thrown values", () => { + expect(mapCodeNavigationError("oops")).toEqual({ + code: "UNKNOWN", + message: "Unknown error", + retryable: false, + }); + expect(mapCodeNavigationError(undefined)).toEqual({ + code: "UNKNOWN", + message: "Unknown error", + retryable: false, + }); + }); +}); + +describe("mapCodeNavigationError debug instrumentation", () => { + const originalEnv = process.env.GITHITS_DEBUG; + let stderrSpy: ReturnType; + + beforeEach(() => { + stderrSpy = spyOn(process.stderr, "write").mockImplementation( + () => true as never, + ); + }); + + afterEach(() => { + stderrSpy.mockRestore(); + if (originalEnv === undefined) delete process.env.GITHITS_DEBUG; + else process.env.GITHITS_DEBUG = originalEnv; + }); + + it("emits exactly one stderr line per classification when GITHITS_DEBUG=code-nav", () => { + process.env.GITHITS_DEBUG = "code-nav"; + mapCodeNavigationError( + new CodeNavigationTargetNotFoundError("Package not found"), + ); + expect(stderrSpy).toHaveBeenCalledTimes(1); + const line = stderrSpy.mock.calls[0]?.[0] as string; + const parsed = JSON.parse(line); + expect(parsed.area).toBe("code-nav"); + expect(parsed.code).toBe("NOT_FOUND"); + expect(parsed.errorName).toBe("CodeNavigationTargetNotFoundError"); + expect(parsed.event).toBe("error-classified"); + // PII policy: no message text in debug payload. + expect(parsed.message).toBeUndefined(); + }); + + it("emits zero stderr lines when GITHITS_DEBUG is unset", () => { + delete process.env.GITHITS_DEBUG; + mapCodeNavigationError(new AuthenticationError("Login required")); + mapCodeNavigationError(new CodeNavigationAccessError("Denied")); + expect(stderrSpy).not.toHaveBeenCalled(); + }); + + it("emits one line per error classification across every branch", () => { + process.env.GITHITS_DEBUG = "*"; + const errors: unknown[] = [ + new CodeNavigationTargetNotFoundError("x"), + new CodeNavigationIndexingError("x"), + new CodeNavigationUnresolvableError("x"), + new CodeNavigationAccessError("x"), + new AuthenticationError("x"), + new CodeNavigationNetworkError("x"), + new CodeNavigationBackendError("x", 500), + new CodeNavigationGraphQLError("x"), + new MalformedCodeNavigationResponseError("x"), + new InvalidPackageSpecError("x"), + new Error("x"), + ]; + for (const err of errors) mapCodeNavigationError(err); + expect(stderrSpy).toHaveBeenCalledTimes(errors.length); + }); +}); diff --git a/src/shared/code-navigation-error-map.ts b/src/shared/code-navigation-error-map.ts new file mode 100644 index 00000000..bbc1b904 --- /dev/null +++ b/src/shared/code-navigation-error-map.ts @@ -0,0 +1,243 @@ +import { + type AvailableVersion, + CodeNavigationAccessError, + CodeNavigationBackendError, + CodeNavigationFeatureFlagRequiredError, + CodeNavigationGraphQLError, + CodeNavigationIndexingError, + CodeNavigationNetworkError, + CodeNavigationTargetNotFoundError, + CodeNavigationUnresolvableError, + CodeNavigationValidationError, + CodeNavigationVersionNotFoundError, + MalformedCodeNavigationResponseError, +} from "../services/code-navigation-service.js"; +import { AuthenticationError } from "../services/githits-service.js"; +import { debugLog } from "./debug-log.js"; + +export type MappedErrorCode = + | "NOT_FOUND" + | "VERSION_NOT_FOUND" + | "INDEXING" + | "UNRESOLVABLE" + | "ACCESS_DENIED" + | "AUTH_REQUIRED" + | "NETWORK" + | "INVALID_ARGUMENT" + | "BACKEND_ERROR" + | "TIMEOUT" + | "RATE_LIMITED" + | "PROTOCOL_ERROR" + | "UNKNOWN"; + +export interface MappedErrorDetails { + availableVersions?: AvailableVersion[]; + indexingRef?: string; + status?: number; + graphqlCode?: string; + /** + * Populated on `VERSION_NOT_FOUND` from the backend's + * `extensions.latest_indexed` — the newest version that is + * actually indexed, suitable as the first recovery suggestion. + */ + latestIndexed?: string; + /** The version the caller asked for (for `VERSION_NOT_FOUND`). */ + requestedVersion?: string; + /** Fully-qualified package identifier (for `VERSION_NOT_FOUND`). */ + package?: string; +} + +export interface MappedError { + code: MappedErrorCode; + message: string; + /** + * Whether the caller can retry the same request successfully. + * Sourced from the backend's `extensions.retryable` when present + * (April 2026 contract); otherwise a per-code default. Agents and + * automation use this directly without maintaining their own + * retryability tables. + */ + retryable?: boolean; + details?: MappedErrorDetails; +} + +/** + * Classify a thrown error from the code navigation stack into the + * shared `MappedError` shape used by the CLI JSON envelope and the + * MCP error payload. + * + * Every named error class in `code-navigation-service.ts` and every + * expected adjacent error (auth, invalid argument) maps to a specific + * code. Only genuinely unrecognised Errors reach `UNKNOWN` — the + * table test in `code-navigation-error-map.test.ts` guards that. + */ +export function mapCodeNavigationError(error: unknown): MappedError { + const mapped = classify(error); + // Emit a single debug line per classification when GITHITS_DEBUG is + // scoped to "code-nav" (or "*"). PII policy: carry the `code`, + // error constructor name, and detail *keys* — never the message + // text (which can echo user-supplied query content on some + // backend error paths) and never response bodies. + debugLog("code-nav", { + event: "error-classified", + code: mapped.code, + errorName: error instanceof Error ? error.name : typeof error, + detailKeys: mapped.details ? Object.keys(mapped.details) : [], + }); + return mapped; +} + +function classify(error: unknown): MappedError { + if (error instanceof CodeNavigationVersionNotFoundError) { + const details: MappedErrorDetails = {}; + if (error.packageName) details.package = error.packageName; + if (error.requestedVersion) { + details.requestedVersion = error.requestedVersion; + } + if (error.latestIndexed) details.latestIndexed = error.latestIndexed; + if (error.availableVersions && error.availableVersions.length > 0) { + details.availableVersions = error.availableVersions; + } + return { + code: "VERSION_NOT_FOUND", + message: error.message, + retryable: false, + details: Object.keys(details).length > 0 ? details : undefined, + }; + } + if (error instanceof CodeNavigationTargetNotFoundError) { + return { + code: "NOT_FOUND", + message: error.message, + retryable: false, + details: error.availableVersions + ? { availableVersions: error.availableVersions } + : undefined, + }; + } + if (error instanceof CodeNavigationIndexingError) { + const details: MappedErrorDetails = {}; + if (error.indexingRef) details.indexingRef = error.indexingRef; + if (error.availableVersions && error.availableVersions.length > 0) { + details.availableVersions = error.availableVersions; + } + return { + code: "INDEXING", + message: error.message, + retryable: true, + details: Object.keys(details).length > 0 ? details : undefined, + }; + } + if (error instanceof CodeNavigationUnresolvableError) { + return { + code: "UNRESOLVABLE", + message: error.message, + retryable: false, + }; + } + if ( + error instanceof CodeNavigationAccessError || + error instanceof CodeNavigationFeatureFlagRequiredError + ) { + return { + code: "ACCESS_DENIED", + message: error.message, + retryable: false, + }; + } + if (error instanceof AuthenticationError) { + return { + code: "AUTH_REQUIRED", + message: error.message, + retryable: false, + }; + } + if (error instanceof CodeNavigationNetworkError) { + return { code: "NETWORK", message: error.message, retryable: true }; + } + if (error instanceof CodeNavigationValidationError) { + return { + code: "INVALID_ARGUMENT", + message: error.message, + retryable: false, + }; + } + if (error instanceof CodeNavigationBackendError) { + return classifyBackendError(error); + } + if (error instanceof CodeNavigationGraphQLError) { + // Legacy GraphQL errors — service now prefers CodeNavigationBackendError, + // but anything still throwing this type is a backend-side failure. + return { + code: "BACKEND_ERROR", + message: error.message, + retryable: false, + details: error.code ? { graphqlCode: error.code } : undefined, + }; + } + if (error instanceof MalformedCodeNavigationResponseError) { + return { + code: "PROTOCOL_ERROR", + message: error.message, + retryable: false, + }; + } + if (isInvalidArgumentError(error)) { + return { + code: "INVALID_ARGUMENT", + message: error.message, + retryable: false, + }; + } + if (error instanceof Error) { + return { code: "UNKNOWN", message: error.message, retryable: false }; + } + return { code: "UNKNOWN", message: "Unknown error", retryable: false }; +} + +/** + * Dispatch on `CodeNavigationBackendError.graphqlCode` to produce + * TIMEOUT / RATE_LIMITED / BACKEND_ERROR with the correct retryable + * default. When the backend provided its own `retryable` hint on the + * original extensions block, it takes precedence over the default. + */ +function classifyBackendError(error: CodeNavigationBackendError): MappedError { + const details: MappedErrorDetails = {}; + if (typeof error.status === "number") details.status = error.status; + if (error.graphqlCode) details.graphqlCode = error.graphqlCode; + + const build = (code: MappedErrorCode, defaultRetryable: boolean) => ({ + code, + message: error.message, + retryable: error.retryable ?? defaultRetryable, + details: Object.keys(details).length > 0 ? details : undefined, + }); + + switch (error.graphqlCode) { + case "TIMEOUT": + return build("TIMEOUT", true); + case "RATE_LIMITED": + return build("RATE_LIMITED", true); + case "UPSTREAM_ERROR": + return build("BACKEND_ERROR", true); + case "INTERNAL_ERROR": + case "UNKNOWN_ERROR": + default: + return build("BACKEND_ERROR", false); + } +} + +/** + * Any Error whose `name` starts with `Invalid` or `Unsupported` is + * treated as an invalid-argument case. Covers parser errors from + * `src/shared/package-spec.ts` (`InvalidPackageSpecError`, + * `UnsupportedRegistryError`, `InvalidArgumentError`) without + * requiring this module to import them directly, which would + * introduce a circular dependency. + */ +function isInvalidArgumentError(error: unknown): error is Error { + if (!(error instanceof Error)) return false; + return ( + error.name.startsWith("Invalid") || error.name.startsWith("Unsupported") + ); +} diff --git a/src/shared/code-navigation.ts b/src/shared/code-navigation.ts new file mode 100644 index 00000000..5a4ad38a --- /dev/null +++ b/src/shared/code-navigation.ts @@ -0,0 +1,134 @@ +import type { + CodeNavigationRegistry, + SearchSymbolsFileIntent, + SearchSymbolsKind, + SearchSymbolsMatchMode, + SymbolCategory, +} from "../services/index.js"; + +const registryMap = { + npm: "NPM", + pypi: "PYPI", + hex: "HEX", + crates: "CRATES", + nuget: "NUGET", + maven: "MAVEN", + zig: "ZIG", + vcpkg: "VCPKG", + packagist: "PACKAGIST", +} as const satisfies Record; + +/** + * Lowercase user-facing kind values → the backend's uppercase + * symbol-kind enum. Callers should prefer the broader + * `symbolCategoryMap`; this enumeration exists for the "I know + * the precise construct" use case. + */ +const symbolKindMap = { + function: "FUNCTION", + method: "METHOD", + constructor: "CONSTRUCTOR", + getter: "GETTER", + setter: "SETTER", + operator: "OPERATOR", + class: "CLASS", + interface: "INTERFACE", + trait: "TRAIT", + struct: "STRUCT", + enum: "ENUM", + record: "RECORD", + protocol: "PROTOCOL", + extension: "EXTENSION", + delegate: "DELEGATE", + mixin: "MIXIN", + actor: "ACTOR", + annotation: "ANNOTATION", + type: "TYPE", + module: "MODULE", + namespace: "NAMESPACE", + package: "PACKAGE", + object: "OBJECT", + field: "FIELD", + property: "PROPERTY", + event: "EVENT", + constant: "CONSTANT", + doc_section: "DOC_SECTION", +} as const satisfies Record; + +/** + * Lowercase user-facing category values → the backend's uppercase + * category enum. Preferred over enumerating individual kinds. + */ +const symbolCategoryMap = { + callable: "CALLABLE", + type: "TYPE", + module: "MODULE", + data: "DATA", + documentation: "DOCUMENTATION", +} as const satisfies Record; + +const fileIntentMap = { + production: "PRODUCTION", + test: "TEST", + benchmark: "BENCHMARK", + example: "EXAMPLE", + generated: "GENERATED", + fixture: "FIXTURE", + build: "BUILD", + vendor: "VENDOR", +} as const satisfies Record; + +const matchModeMap = { + or: "OR", + and: "AND", +} as const satisfies Record; + +export type CodeNavigationRegistryArg = keyof typeof registryMap; + +export function toCodeNavigationRegistry( + registry: CodeNavigationRegistryArg, +): CodeNavigationRegistry { + return registryMap[registry]; +} + +export function toSearchSymbolsMatchMode( + mode: string | undefined, +): SearchSymbolsMatchMode | undefined { + return mode ? matchModeMap[mode as keyof typeof matchModeMap] : undefined; +} + +export function toSearchSymbolsKind( + kind: string | undefined, +): SearchSymbolsKind | undefined { + return kind ? symbolKindMap[kind as keyof typeof symbolKindMap] : undefined; +} + +export type SymbolCategoryArg = keyof typeof symbolCategoryMap; + +export function toSymbolCategory( + category: string | undefined, +): SymbolCategory | undefined { + return category + ? symbolCategoryMap[category as keyof typeof symbolCategoryMap] + : undefined; +} + +export function isKnownSymbolKind(value: string): value is SymbolCategoryArg { + return value in symbolKindMap; +} + +export function knownSymbolKindList(): ReadonlyArray { + return Object.keys(symbolKindMap); +} + +export function knownSymbolCategoryList(): ReadonlyArray { + return Object.keys(symbolCategoryMap); +} + +export function toSearchSymbolsFileIntent( + intent: string | undefined, +): SearchSymbolsFileIntent | undefined { + return intent + ? fileIntentMap[intent as keyof typeof fileIntentMap] + : undefined; +} diff --git a/src/shared/debug-log.test.ts b/src/shared/debug-log.test.ts new file mode 100644 index 00000000..2c6ff095 --- /dev/null +++ b/src/shared/debug-log.test.ts @@ -0,0 +1,90 @@ +import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; +import { debugLog } from "./debug-log.js"; + +describe("debugLog", () => { + const originalEnv = process.env.GITHITS_DEBUG; + let stderrSpy: ReturnType; + + beforeEach(() => { + stderrSpy = spyOn(process.stderr, "write").mockImplementation( + () => true as never, + ); + }); + + afterEach(() => { + stderrSpy.mockRestore(); + if (originalEnv === undefined) delete process.env.GITHITS_DEBUG; + else process.env.GITHITS_DEBUG = originalEnv; + }); + + it("emits nothing when GITHITS_DEBUG is unset", () => { + delete process.env.GITHITS_DEBUG; + debugLog("code-nav", { foo: "bar" }); + expect(stderrSpy).not.toHaveBeenCalled(); + }); + + it("emits nothing when GITHITS_DEBUG is empty", () => { + process.env.GITHITS_DEBUG = ""; + debugLog("code-nav", { foo: "bar" }); + expect(stderrSpy).not.toHaveBeenCalled(); + }); + + it("emits for the requested area when GITHITS_DEBUG names it exactly", () => { + process.env.GITHITS_DEBUG = "code-nav"; + debugLog("code-nav", { foo: "bar" }); + expect(stderrSpy).toHaveBeenCalledTimes(1); + const call = stderrSpy.mock.calls[0]?.[0] as string; + const parsed = JSON.parse(call.trimEnd()); + expect(parsed.area).toBe("code-nav"); + expect(parsed.foo).toBe("bar"); + expect(parsed.ts).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + it("emits when area is in a comma-separated GITHITS_DEBUG scope list", () => { + process.env.GITHITS_DEBUG = "auth,code-nav,telemetry"; + debugLog("code-nav", {}); + expect(stderrSpy).toHaveBeenCalledTimes(1); + }); + + it("skips when area is not in the scope list", () => { + process.env.GITHITS_DEBUG = "auth,telemetry"; + debugLog("code-nav", {}); + expect(stderrSpy).not.toHaveBeenCalled(); + }); + + it("emits for any area when GITHITS_DEBUG=*", () => { + process.env.GITHITS_DEBUG = "*"; + debugLog("code-nav", {}); + debugLog("auth", {}); + debugLog("anything", {}); + expect(stderrSpy).toHaveBeenCalledTimes(3); + }); + + it("serialises payload contents as JSON on a single line", () => { + process.env.GITHITS_DEBUG = "code-nav"; + debugLog("code-nav", { + code: "NOT_FOUND", + detailKeys: ["availableVersions"], + count: 3, + }); + const call = stderrSpy.mock.calls[0]?.[0] as string; + expect(call.endsWith("\n")).toBe(true); + // One newline total — single-line emission. + expect(call.split("\n").filter(Boolean)).toHaveLength(1); + const parsed = JSON.parse(call); + expect(parsed.code).toBe("NOT_FOUND"); + expect(parsed.detailKeys).toEqual(["availableVersions"]); + expect(parsed.count).toBe(3); + }); + + it("falls back to a marker line when the payload is not serialisable", () => { + process.env.GITHITS_DEBUG = "code-nav"; + const circular: Record = {}; + circular.self = circular; + debugLog("code-nav", circular); + const call = stderrSpy.mock.calls[0]?.[0] as string; + const parsed = JSON.parse(call); + expect(parsed.area).toBe("code-nav"); + expect(parsed.error).toContain("not serialisable"); + }); +}); diff --git a/src/shared/debug-log.ts b/src/shared/debug-log.ts new file mode 100644 index 00000000..9c6703b0 --- /dev/null +++ b/src/shared/debug-log.ts @@ -0,0 +1,59 @@ +/** + * Diagnostic logging gated by `GITHITS_DEBUG`. + * + * Emits a single line of JSON to stderr when the caller's `area` + * matches the env-var scope. Intended for error-path instrumentation + * so real-world failures of the new typed-error mapping are + * diagnosable without asking users to run with a flag we added after + * the fact. + * + * **PII policy.** Debug payloads carry error codes, parsed spec + * shape, and request parameter *names* — never the user's query + * text, bearer tokens, response bodies, or any other caller-owned + * content. Callers are responsible for filtering before passing + * payloads here; this module does not inspect payload contents. + * + * **Scope syntax.** + * - `GITHITS_DEBUG=*` enables every area. + * - `GITHITS_DEBUG=code-nav` scopes to the `code-nav` area. + * - `GITHITS_DEBUG=code-nav,auth` comma-separated list. + * - Unset or empty: no output. + */ +export function debugLog(area: string, payload: Record): void { + if (!isAreaEnabled(area)) return; + + const line = { + ts: new Date().toISOString(), + area, + ...payload, + }; + + // Serialisation is best-effort: if the payload contains something + // non-serialisable (e.g. a BigInt, a circular ref), fall back to a + // stringified marker rather than throwing from the instrumentation + // itself. + let text: string; + try { + text = JSON.stringify(line); + } catch { + text = JSON.stringify({ + ts: line.ts, + area, + error: "debug-log payload not serialisable", + }); + } + + process.stderr.write(`${text}\n`); +} + +function isAreaEnabled(area: string): boolean { + const raw = process.env.GITHITS_DEBUG; + if (!raw || raw === "") return false; + if (raw === "*") return true; + + const scopes = raw + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + return scopes.includes(area) || scopes.includes("*"); +} diff --git a/src/shared/index.ts b/src/shared/index.ts index 5106482f..20cb5fcc 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -1,3 +1,27 @@ +export { + type CodeNavigationRegistryArg, + isKnownSymbolKind, + knownSymbolCategoryList, + knownSymbolKindList, + type SymbolCategoryArg, + toCodeNavigationRegistry, + toSearchSymbolsFileIntent, + toSearchSymbolsKind, + toSearchSymbolsMatchMode, + toSymbolCategory, +} from "./code-navigation.js"; +export { + DEFAULT_WAIT_TIMEOUT_MS, + FILE_INTENT_ALL, + type FileIntentInput, + MAX_WAIT_TIMEOUT_MS, + SEARCH_SYMBOLS_DEFAULT_FILE_INTENT, +} from "./code-navigation-defaults.js"; +export { + type MappedError, + type MappedErrorCode, + mapCodeNavigationError, +} from "./code-navigation-error-map.js"; export { colorize, colors, @@ -8,8 +32,35 @@ export { success, warning, } from "./colors.js"; +export { debugLog } from "./debug-log.js"; export { filterLanguages, type LanguageMatch, } from "./language-filter.js"; +export { + InvalidKeywordsError, + normaliseKeywords, +} from "./normalise-keywords.js"; +export { + InvalidArgumentError, + InvalidPackageSpecError, + isKnownRegistry, + KNOWN_REGISTRIES, + type KnownRegistry, + type ParsedPackageSpec, + parsePackageSpec, + UnsupportedRegistryError, +} from "./package-spec.js"; export { AuthRequiredError, requireAuth } from "./require-auth.js"; +export { + buildSearchSymbolsParams, + type SearchSymbolsRequestBuildResult, + type SearchSymbolsRequestInput, +} from "./search-symbols-request.js"; +export { + buildSearchSymbolsErrorPayload, + buildSearchSymbolsSuccessPayload, + type SearchSymbolsErrorPayload, + type SearchSymbolsQueryEcho, + type SearchSymbolsSuccessPayload, +} from "./search-symbols-response.js"; diff --git a/src/shared/normalise-keywords.test.ts b/src/shared/normalise-keywords.test.ts new file mode 100644 index 00000000..fdad5214 --- /dev/null +++ b/src/shared/normalise-keywords.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "bun:test"; +import { + InvalidKeywordsError, + normaliseKeywords, +} from "./normalise-keywords.js"; + +describe("normaliseKeywords", () => { + it("returns an empty array when both inputs are undefined", () => { + expect(normaliseKeywords(undefined, undefined)).toEqual([]); + }); + + it("splits a comma-separated list and trims entries", () => { + expect(normaliseKeywords("router, handler , middleware")).toEqual([ + "router", + "handler", + "middleware", + ]); + }); + + it("accepts a repeated-flag array unchanged (after trim/dedup)", () => { + expect(normaliseKeywords(undefined, ["router", "handler"])).toEqual([ + "router", + "handler", + ]); + }); + + it("merges comma + repeated inputs and de-duplicates preserving first-seen order", () => { + expect( + normaliseKeywords("router,handler", ["middleware", "router", "errors"]), + ).toEqual(["router", "handler", "middleware", "errors"]); + }); + + it("drops empty entries", () => { + expect(normaliseKeywords(",router,,handler,,,")).toEqual([ + "router", + "handler", + ]); + }); + + it("accepts empty string as no input", () => { + expect(normaliseKeywords("")).toEqual([]); + }); + + it("throws InvalidKeywordsError when the merged list exceeds 20 entries", () => { + const many = Array.from({ length: 21 }, (_, i) => `kw${i}`).join(","); + expect(() => normaliseKeywords(many)).toThrow(InvalidKeywordsError); + }); + + it("counts AFTER dedup when enforcing the cap", () => { + // 20 unique keywords + one duplicate should stay at 20 and pass. + const twenty = Array.from({ length: 20 }, (_, i) => `kw${i}`).join(","); + expect(() => normaliseKeywords(`${twenty},kw0`)).not.toThrow(); + }); + + it("classifier contract: error name is stable", () => { + try { + normaliseKeywords( + Array.from({ length: 30 }, (_, i) => `kw${i}`).join(","), + ); + expect.unreachable(); + } catch (err) { + expect((err as Error).name).toBe("InvalidKeywordsError"); + } + }); +}); diff --git a/src/shared/normalise-keywords.ts b/src/shared/normalise-keywords.ts new file mode 100644 index 00000000..ed9713c5 --- /dev/null +++ b/src/shared/normalise-keywords.ts @@ -0,0 +1,59 @@ +const MAX_KEYWORDS = 20; + +/** + * Raised when caller input cannot be normalised into a valid keyword + * list. Classifier maps it to `INVALID_ARGUMENT`. + */ +export class InvalidKeywordsError extends Error { + constructor(message: string) { + super(message); + this.name = "InvalidKeywordsError"; + } +} + +/** + * Combine comma-separated and repeated keyword inputs into a single, + * de-duplicated, capped array. + * + * - `rawComma`: the value of a `--keywords "a,b,c"` option (CLI) or a + * single string passed via MCP (rare but accepted). + * - `rawRepeated`: the collected values from a repeatable + * `--keyword ` CLI flag, or the array form of the MCP + * `keywords` argument. + * + * Both are trimmed, empty entries dropped, then merged with first-seen + * order preserved. `InvalidKeywordsError` is thrown when the final + * list exceeds 20 entries — the same cap the MCP schema enforces + * (applied here *before* the service call so the error surfaces + * client-side with a clear message). + */ +export function normaliseKeywords( + rawComma?: string, + rawRepeated?: ReadonlyArray, +): string[] { + const seen = new Set(); + const out: string[] = []; + + const add = (value: string) => { + const trimmed = value.trim(); + if (trimmed === "") return; + if (seen.has(trimmed)) return; + seen.add(trimmed); + out.push(trimmed); + }; + + if (rawComma !== undefined && rawComma !== "") { + for (const piece of rawComma.split(",")) add(piece); + } + if (rawRepeated !== undefined) { + for (const piece of rawRepeated) add(piece); + } + + if (out.length > MAX_KEYWORDS) { + throw new InvalidKeywordsError( + `Too many keywords: got ${out.length}, maximum is ${MAX_KEYWORDS}.`, + ); + } + + return out; +} diff --git a/src/shared/package-spec.test.ts b/src/shared/package-spec.test.ts new file mode 100644 index 00000000..9c79cfac --- /dev/null +++ b/src/shared/package-spec.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "bun:test"; +import { + InvalidPackageSpecError, + parsePackageSpec, + UnsupportedRegistryError, +} from "./package-spec.js"; + +describe("parsePackageSpec", () => { + it("parses plain package name as npm registry (implicit)", () => { + expect(parsePackageSpec("express")).toEqual({ + registry: "npm", + registryExplicit: false, + name: "express", + }); + }); + + it("parses explicit registry prefix", () => { + expect(parsePackageSpec("pypi:requests")).toEqual({ + registry: "pypi", + registryExplicit: true, + name: "requests", + }); + }); + + it("parses version suffix with @", () => { + expect(parsePackageSpec("npm:express@4.18.0")).toEqual({ + registry: "npm", + registryExplicit: true, + name: "express", + version: "4.18.0", + }); + }); + + it("parses pypi package with version", () => { + expect(parsePackageSpec("pypi:requests@2.33.1")).toEqual({ + registry: "pypi", + registryExplicit: true, + name: "requests", + version: "2.33.1", + }); + }); + + it("preserves scoped npm names (leading @ is not a version delimiter)", () => { + expect(parsePackageSpec("npm:@types/node")).toEqual({ + registry: "npm", + registryExplicit: true, + name: "@types/node", + }); + }); + + it("preserves scoped npm name without explicit prefix", () => { + expect(parsePackageSpec("@types/node")).toEqual({ + registry: "npm", + registryExplicit: false, + name: "@types/node", + }); + }); + + it("parses packagist coordinate with slash-separated name", () => { + expect(parsePackageSpec("packagist:psr/log@1.0.0")).toEqual({ + registry: "packagist", + registryExplicit: true, + name: "psr/log", + version: "1.0.0", + }); + }); + + it("throws UnsupportedRegistryError when the prefix is unknown", () => { + expect(() => parsePackageSpec("foobar:baz")).toThrow( + UnsupportedRegistryError, + ); + try { + parsePackageSpec("foobar:baz"); + } catch (err) { + expect((err as Error).message).toContain("foobar"); + expect((err as Error).message).toContain("npm"); + expect((err as Error).message).toContain("pypi"); + } + }); + + it("throws InvalidPackageSpecError on empty input", () => { + expect(() => parsePackageSpec("")).toThrow(InvalidPackageSpecError); + expect(() => parsePackageSpec(" ")).toThrow(InvalidPackageSpecError); + }); + + it("throws InvalidPackageSpecError on trailing @ with no version", () => { + expect(() => parsePackageSpec("npm:express@")).toThrow( + InvalidPackageSpecError, + ); + }); + + it("throws InvalidPackageSpecError when registry prefix has no name", () => { + expect(() => parsePackageSpec("npm:")).toThrow(InvalidPackageSpecError); + }); + + it("classifier can see InvalidPackageSpecError as INVALID_ARGUMENT (name contract)", () => { + try { + parsePackageSpec(""); + expect.unreachable(); + } catch (err) { + expect((err as Error).name).toBe("InvalidPackageSpecError"); + } + }); + + it("classifier can see UnsupportedRegistryError as INVALID_ARGUMENT (name contract)", () => { + try { + parsePackageSpec("foobar:baz"); + expect.unreachable(); + } catch (err) { + expect((err as Error).name).toBe("UnsupportedRegistryError"); + } + }); +}); diff --git a/src/shared/package-spec.ts b/src/shared/package-spec.ts new file mode 100644 index 00000000..25e7aefc --- /dev/null +++ b/src/shared/package-spec.ts @@ -0,0 +1,142 @@ +/** + * Known package registries supported by code navigation targets. + */ +export const KNOWN_REGISTRIES = [ + "npm", + "pypi", + "hex", + "crates", + "nuget", + "maven", + "zig", + "vcpkg", + "packagist", +] as const; + +export type KnownRegistry = (typeof KNOWN_REGISTRIES)[number]; + +/** + * Parsed package specification with optional version suffix. + */ +export interface ParsedPackageSpec { + registry: KnownRegistry; + name: string; + version?: string; + registryExplicit: boolean; +} + +/** + * Raised when the caller prefixes the package spec with a string that + * looks like a registry (contains a `:`) but is not one of + * `KNOWN_REGISTRIES`. Without this guard the parser used to silently + * fall back to npm, leaving the user with a confusing "package not + * found" error on a completely different registry than they intended. + */ +export class UnsupportedRegistryError extends Error { + constructor(public readonly attempted: string) { + super( + `Unsupported registry "${attempted}". Supported: ${KNOWN_REGISTRIES.join(", ")}.`, + ); + this.name = "UnsupportedRegistryError"; + } +} + +/** + * Raised when the spec is syntactically malformed (empty name, + * trailing `@` with no version, etc.). + */ +export class InvalidPackageSpecError extends Error { + constructor(message: string) { + super(message); + this.name = "InvalidPackageSpecError"; + } +} + +/** + * Raised by surface-level callers for any other caller-input + * validation failure (bad option value, missing required args, etc.). + * Name begins with `Invalid` so the shared classifier maps it to + * `INVALID_ARGUMENT` — so CLI parser errors round-trip through the + * same envelope as server-side invalid inputs. + * + * Lives in `package-spec.ts` for import locality (same module as + * `InvalidPackageSpecError`); may move to its own file if the set + * of invalid-argument flavours grows. + */ +export class InvalidArgumentError extends Error { + constructor(message: string) { + super(message); + this.name = "InvalidArgumentError"; + } +} + +/** + * Parse a user-provided package spec in the form + * `:[@]`. + * + * Rules: + * - The registry prefix is optional; omitting it defaults to `npm`. + * - If a prefix is present, it must be a known registry — otherwise + * `UnsupportedRegistryError` is thrown. The previous behaviour of + * treating `foobar:baz` as an npm package literally named + * `foobar:baz` was a footgun. + * - Scoped npm names (`@types/node`) are preserved unchanged; the + * leading `@` is not interpreted as a version delimiter. + * - `@` is optional and parsed from the last `@` in the + * remaining name. Trailing `@` with no version is rejected. + */ +export function parsePackageSpec(spec: string): ParsedPackageSpec { + if (!spec || spec.trim() === "") { + throw new InvalidPackageSpecError( + "Package spec cannot be empty. Expected :[@].", + ); + } + + let registry: KnownRegistry = "npm"; + let registryExplicit = false; + let rest = spec; + + if (spec.includes(":")) { + const colonIndex = spec.indexOf(":"); + const potentialRegistry = spec.slice(0, colonIndex).toLowerCase(); + if (isKnownRegistry(potentialRegistry)) { + registry = potentialRegistry; + registryExplicit = true; + rest = spec.slice(colonIndex + 1); + } else { + throw new UnsupportedRegistryError(potentialRegistry); + } + } + + const atIndex = rest.lastIndexOf("@"); + if (atIndex > 0) { + const name = rest.slice(0, atIndex); + const version = rest.slice(atIndex + 1); + if (version === "") { + throw new InvalidPackageSpecError( + `Package spec "${spec}" has a trailing "@" with no version. Omit the "@" or add a version.`, + ); + } + if (name === "") { + throw new InvalidPackageSpecError( + `Package spec "${spec}" has a version but no name.`, + ); + } + return { registry, registryExplicit, name, version }; + } + + if (rest === "") { + throw new InvalidPackageSpecError( + `Package spec "${spec}" is missing a package name after the registry prefix.`, + ); + } + + return { registry, registryExplicit, name: rest }; +} + +/** + * Narrows a string to a known registry. + */ +export function isKnownRegistry(value: string): value is KnownRegistry { + return KNOWN_REGISTRIES.includes(value as KnownRegistry); +} diff --git a/src/shared/search-symbols-request.test.ts b/src/shared/search-symbols-request.test.ts new file mode 100644 index 00000000..edd56830 --- /dev/null +++ b/src/shared/search-symbols-request.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "bun:test"; +import { FILE_INTENT_ALL } from "./code-navigation-defaults.js"; +import { buildSearchSymbolsParams } from "./search-symbols-request.js"; + +describe("buildSearchSymbolsParams", () => { + const baseTarget = { registry: "NPM", packageName: "express" } as const; + + it("defaults fileIntent to PRODUCTION and records it as defaulted", () => { + const { params, defaulted } = buildSearchSymbolsParams({ + target: baseTarget, + query: "middleware", + }); + + expect(params.fileIntent).toBe("PRODUCTION"); + expect(defaulted).toContain("fileIntent"); + }); + + it("defaults waitTimeoutMs to 20000 and records it as defaulted", () => { + const { params, defaulted } = buildSearchSymbolsParams({ + target: baseTarget, + query: "middleware", + }); + + expect(params.waitTimeoutMs).toBe(20_000); + expect(defaulted).toContain("waitTimeoutMs"); + }); + + it("preserves explicit fileIntent values and does not mark as defaulted", () => { + const { params, defaulted } = buildSearchSymbolsParams({ + target: baseTarget, + query: "middleware", + fileIntent: "TEST", + }); + + expect(params.fileIntent).toBe("TEST"); + expect(defaulted).not.toContain("fileIntent"); + }); + + it("preserves explicit waitTimeoutMs and does not mark as defaulted", () => { + const { params, defaulted } = buildSearchSymbolsParams({ + target: baseTarget, + query: "middleware", + waitTimeoutMs: 5000, + }); + + expect(params.waitTimeoutMs).toBe(5000); + expect(defaulted).not.toContain("waitTimeoutMs"); + }); + + it("maps FILE_INTENT_ALL sentinel to undefined (omit filter at service layer)", () => { + const { params, defaulted } = buildSearchSymbolsParams({ + target: baseTarget, + query: "middleware", + fileIntent: FILE_INTENT_ALL, + }); + + expect(params.fileIntent).toBeUndefined(); + // Explicit caller choice — not a silent default. + expect(defaulted).not.toContain("fileIntent"); + }); + + it("passes all other fields through unchanged", () => { + const { params } = buildSearchSymbolsParams({ + target: baseTarget, + query: "middleware", + keywords: ["a", "b"], + matchMode: "AND", + kind: "FUNCTION", + filePath: "lib/", + limit: 25, + waitTimeoutMs: 3000, + }); + + expect(params).toEqual({ + target: baseTarget, + query: "middleware", + keywords: ["a", "b"], + matchMode: "AND", + kind: "FUNCTION", + filePath: "lib/", + limit: 25, + fileIntent: "PRODUCTION", + waitTimeoutMs: 3000, + }); + }); +}); diff --git a/src/shared/search-symbols-request.ts b/src/shared/search-symbols-request.ts new file mode 100644 index 00000000..3f3d829b --- /dev/null +++ b/src/shared/search-symbols-request.ts @@ -0,0 +1,117 @@ +import type { + CodeNavigationTarget, + SearchSymbolsFileIntent, + SearchSymbolsKind, + SearchSymbolsMatchMode, + SearchSymbolsParams, + SymbolCategory, +} from "../services/index.js"; +import { + DEFAULT_WAIT_TIMEOUT_MS, + FILE_INTENT_ALL, + type FileIntentInput, + SEARCH_SYMBOLS_DEFAULT_FILE_INTENT, +} from "./code-navigation-defaults.js"; + +/** + * Caller-facing, surface-agnostic input to `search_symbols`. Both the + * CLI command and the MCP tool normalise their respective arguments + * into this shape before invoking `buildSearchSymbolsParams`, so the + * two surfaces cannot diverge on default application or semantic + * translation. + * + * `fileIntent` is tri-valued: specific intent, `FILE_INTENT_ALL` + * (caller asked for all), or `undefined` (caller omitted the field — + * the builder fills in the default). + */ +export interface SearchSymbolsRequestInput { + target: CodeNavigationTarget; + query?: string; + keywords?: string[]; + matchMode?: SearchSymbolsMatchMode; + kind?: SearchSymbolsKind; + category?: SymbolCategory; + filePath?: string; + limit?: number; + fileIntent?: FileIntentInput; + waitTimeoutMs?: number; +} + +/** + * Result of building a search-symbols request. The `params` object is + * ready to hand to `CodeNavigationService.searchSymbols`; the + * `defaulted` array lists the fields whose values were filled in by + * this builder rather than supplied by the caller, for inclusion in + * the surface's `query.defaulted` echo on the JSON envelope. + */ +export interface SearchSymbolsRequestBuildResult { + params: SearchSymbolsParams; + defaulted: ReadonlyArray<"fileIntent" | "waitTimeoutMs">; +} + +/** + * Build a `SearchSymbolsParams` object from caller-facing input. + * + * Responsibilities: + * - Fill in defaults for `fileIntent` and `waitTimeoutMs`. + * - Translate the `FILE_INTENT_ALL` sentinel to "omit the GraphQL + * variable" by leaving `fileIntent: undefined` on the outgoing + * params — omission returns all intents on the live backend. + * - Record which fields were defaulted so the surface can mark them + * in its JSON echo. + * + * Shared between CLI and MCP per PARITY-REQUEST and PARITY-DEFAULTS. + */ +export function buildSearchSymbolsParams( + input: SearchSymbolsRequestInput, +): SearchSymbolsRequestBuildResult { + const defaulted: Array<"fileIntent" | "waitTimeoutMs"> = []; + + const resolvedFileIntent = resolveFileIntent(input.fileIntent, defaulted); + const resolvedWaitTimeoutMs = resolveWaitTimeoutMs( + input.waitTimeoutMs, + defaulted, + ); + + return { + params: { + target: input.target, + query: input.query, + keywords: input.keywords, + matchMode: input.matchMode, + kind: input.kind, + category: input.category, + filePath: input.filePath, + limit: input.limit, + fileIntent: resolvedFileIntent, + waitTimeoutMs: resolvedWaitTimeoutMs, + }, + defaulted, + }; +} + +function resolveFileIntent( + input: FileIntentInput, + defaulted: Array<"fileIntent" | "waitTimeoutMs">, +): SearchSymbolsFileIntent | undefined { + if (input === FILE_INTENT_ALL) { + // Caller asked for all intents; send no filter to the backend. + return undefined; + } + if (input === undefined) { + defaulted.push("fileIntent"); + return SEARCH_SYMBOLS_DEFAULT_FILE_INTENT; + } + return input; +} + +function resolveWaitTimeoutMs( + input: number | undefined, + defaulted: Array<"fileIntent" | "waitTimeoutMs">, +): number { + if (input === undefined) { + defaulted.push("waitTimeoutMs"); + return DEFAULT_WAIT_TIMEOUT_MS; + } + return input; +} diff --git a/src/shared/search-symbols-response.test.ts b/src/shared/search-symbols-response.test.ts new file mode 100644 index 00000000..bcc60a41 --- /dev/null +++ b/src/shared/search-symbols-response.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "bun:test"; +import { + CodeNavigationBackendError, + CodeNavigationIndexingError, + CodeNavigationVersionNotFoundError, +} from "../services/code-navigation-service.js"; +import type { + SearchSymbolsParams, + SearchSymbolsResult, +} from "../services/index.js"; +import { + buildSearchSymbolsErrorPayload, + buildSearchSymbolsSuccessPayload, +} from "./search-symbols-response.js"; + +const baseParams: SearchSymbolsParams = { + target: { registry: "NPM", packageName: "express" }, + query: "middleware", + fileIntent: "PRODUCTION", + waitTimeoutMs: 20_000, +}; + +const empty: SearchSymbolsResult = { + results: [], + totalMatches: 0, + hasMore: false, +}; + +describe("buildSearchSymbolsSuccessPayload — query echo", () => { + it("echoes category as a lowercase string when the caller supplied one", () => { + const payload = buildSearchSymbolsSuccessPayload( + { + ...baseParams, + category: "CALLABLE", + }, + [], + empty, + ); + expect(payload.query.category).toBe("callable"); + }); + + it("omits category from the echo when the caller did not supply one", () => { + const payload = buildSearchSymbolsSuccessPayload(baseParams, [], empty); + expect(payload.query.category).toBeUndefined(); + }); +}); + +describe("buildSearchSymbolsSuccessPayload — zero-result hint handling", () => { + it("passes the backend zero-result hint through in the success envelope", () => { + const payload = buildSearchSymbolsSuccessPayload(baseParams, [], { + ...empty, + version: "v5.2.1", + hint: "120 chunks indexed across 45 files. Try broader search terms or use fetch_code_context to read specific files directly.", + }); + expect(payload.hint).toContain("120 chunks indexed across 45 files"); + }); + + it("passes the docs-only hint through on zero-result", () => { + const payload = buildSearchSymbolsSuccessPayload(baseParams, [], { + ...empty, + hint: "This package has no searchable code chunks — it may be docs-only, binary-heavy, or use a language the extractor doesn't support.", + }); + expect(payload.hint).toContain("docs-only, binary-heavy"); + }); + + it("suppresses the legacy '0 searchable chunks' phrasing even when backend regresses", () => { + const payload = buildSearchSymbolsSuccessPayload(baseParams, [], { + ...empty, + hint: "Repository indexed but contains 0 searchable chunks.", + }); + expect(payload.hint).toBeUndefined(); + }); + + it("omits hint entirely when backend sends none", () => { + const payload = buildSearchSymbolsSuccessPayload(baseParams, [], empty); + expect(payload.hint).toBeUndefined(); + }); +}); + +describe("buildSearchSymbolsErrorPayload — retryable surfacing", () => { + it("surfaces retryable: true for INDEXING", () => { + const payload = buildSearchSymbolsErrorPayload( + new CodeNavigationIndexingError("still indexing", "idx-1"), + ); + expect(payload.code).toBe("INDEXING"); + expect(payload.retryable).toBe(true); + }); + + it("surfaces retryable: false for VERSION_NOT_FOUND and carries structured details", () => { + const payload = buildSearchSymbolsErrorPayload( + new CodeNavigationVersionNotFoundError( + "No version of npm/express matches '4'.", + "npm/express", + "4", + "5.2.1", + [{ version: "5.2.1", ref: "v5.2.1" }], + ), + ); + expect(payload.code).toBe("VERSION_NOT_FOUND"); + expect(payload.retryable).toBe(false); + expect(payload.details).toMatchObject({ + package: "npm/express", + requestedVersion: "4", + latestIndexed: "5.2.1", + }); + }); + + it("honours backend-supplied retryable override on CodeNavigationBackendError", () => { + const payload = buildSearchSymbolsErrorPayload( + new CodeNavigationBackendError("hiccup", 500, "INTERNAL_ERROR", true), + ); + expect(payload.code).toBe("BACKEND_ERROR"); + expect(payload.retryable).toBe(true); + }); +}); diff --git a/src/shared/search-symbols-response.ts b/src/shared/search-symbols-response.ts new file mode 100644 index 00000000..d4b55cf1 --- /dev/null +++ b/src/shared/search-symbols-response.ts @@ -0,0 +1,150 @@ +import type { + CodeNavigationTarget, + SearchSymbolsParams, + SearchSymbolsResult, +} from "../services/index.js"; +import { mapCodeNavigationError } from "./code-navigation-error-map.js"; + +/** + * The canonical success envelope emitted by both the CLI (`--json`) + * and the MCP tool text payload. Both paths round-trip through + * `buildSearchSymbolsSuccessPayload` so the shapes can never drift. + * + * No leading-underscore keys — `warning` / `hint` are plain. + * `returnedCount` is an explicit echo of `results.length`; + * `totalMatches` carries the backend-provided total (today equal to + * `returnedCount` — see backend request B2). + */ +export interface SearchSymbolsSuccessPayload { + query: SearchSymbolsQueryEcho; + results: SearchSymbolsResult["results"]; + returnedCount: number; + totalMatches: number; + hasMore: boolean; + version?: string; + resolution?: SearchSymbolsResult["resolution"]; + warning?: string; + hint?: string; +} + +/** + * Echo of the resolved request. Agents and human readers see exactly + * which parameters were applied to the search; `defaulted` names the + * fields the client filled in rather than the caller supplying. + */ +export interface SearchSymbolsQueryEcho { + target: CodeNavigationTarget; + query?: string; + keywords?: string[]; + matchMode?: string; + kind?: string; + /** + * Lowercase broad-category filter applied to the query — one of + * `callable`, `type`, `module`, `data`, `documentation`. Absent + * when the caller supplied no category. + */ + category?: string; + filePath?: string; + fileIntent: string; // lowercase enum value or the literal "all" + limit?: number; + waitTimeoutMs: number; + defaulted: ReadonlyArray<"fileIntent" | "waitTimeoutMs">; +} + +export interface SearchSymbolsErrorPayload { + error: string; + code: string; + /** + * Whether the caller can retry the same request. Sourced from + * `mapCodeNavigationError(...).retryable`; populated on every + * error path so agents do not need a per-code retryability table. + */ + retryable?: boolean; + details?: Record; +} + +/** + * Build the success envelope from the already-resolved request + * `params` plus the raw service result and the `defaulted` array + * returned by `buildSearchSymbolsParams`. + * + * `buildSuccessPayload` is pure so it is cheap to cover in both the + * CLI and MCP surface tests and in the shared parity test. + */ +export function buildSearchSymbolsSuccessPayload( + params: SearchSymbolsParams, + defaulted: ReadonlyArray<"fileIntent" | "waitTimeoutMs">, + result: SearchSymbolsResult, +): SearchSymbolsSuccessPayload { + const payload: SearchSymbolsSuccessPayload = { + query: { + target: params.target, + query: params.query, + keywords: params.keywords, + matchMode: params.matchMode?.toLowerCase(), + kind: params.kind?.toLowerCase(), + category: params.category?.toLowerCase(), + filePath: params.filePath, + fileIntent: echoFileIntent(params.fileIntent), + limit: params.limit, + // waitTimeoutMs is always resolved to a concrete number by the builder. + waitTimeoutMs: params.waitTimeoutMs ?? 0, + defaulted, + }, + results: result.results, + returnedCount: result.results.length, + totalMatches: result.totalMatches, + hasMore: result.hasMore, + }; + + if (result.version) payload.version = result.version; + if (result.resolution) payload.resolution = result.resolution; + if (result.warning) payload.warning = result.warning; + // Pass the server hint through on all result counts. The April + // 2026 backend rewrite made zero-result hints accurate and + // actionable ("N chunks indexed across M files..." or "docs-only, + // binary-heavy, or uses an unsupported language..."). Defensive + // filter for the legacy "0 searchable chunks" string remains in + // place until every backend deploy is confirmed on the new + // contract. + if (result.hint && !isLegacyZeroChunksHint(result.hint)) { + payload.hint = result.hint; + } + + return payload; +} + +function isLegacyZeroChunksHint(hint: string): boolean { + return hint.toLowerCase().includes("0 searchable chunks"); +} + +/** + * Build the error envelope from any thrown error. Routes through + * `mapCodeNavigationError` so the emitted `code` is stable and every + * non-UNKNOWN branch carries a specific classification. + */ +export function buildSearchSymbolsErrorPayload( + error: unknown, +): SearchSymbolsErrorPayload { + const mapped = mapCodeNavigationError(error); + const payload: SearchSymbolsErrorPayload = { + error: mapped.message, + code: mapped.code, + }; + if (typeof mapped.retryable === "boolean") { + payload.retryable = mapped.retryable; + } + if (mapped.details && Object.keys(mapped.details).length > 0) { + payload.details = mapped.details as Record; + } + return payload; +} + +function echoFileIntent(resolved: SearchSymbolsParams["fileIntent"]): string { + // When the builder translated `FILE_INTENT_ALL` to "omit the + // GraphQL variable", `params.fileIntent` is `undefined`. Echo that + // as the literal "all" so the agent/user sees the intent that was + // actually applied. + if (resolved === undefined) return "all"; + return resolved.toLowerCase(); +} diff --git a/src/tools/code-navigation-shared.ts b/src/tools/code-navigation-shared.ts new file mode 100644 index 00000000..8a9dae43 --- /dev/null +++ b/src/tools/code-navigation-shared.ts @@ -0,0 +1,124 @@ +import { z } from "zod"; +import type { CodeNavigationTarget } from "../services/index.js"; +import { + type CodeNavigationRegistryArg, + toCodeNavigationRegistry, +} from "../shared/code-navigation.js"; +import { errorResult, type ToolResult } from "./types.js"; + +// Re-export the wait-timeout default so callers already importing this +// module keep working; the canonical definition lives in +// src/shared/code-navigation-defaults.ts per the CLI/MCP parity rules. +export { DEFAULT_WAIT_TIMEOUT_MS } from "../shared/code-navigation-defaults.js"; + +export const codeTargetSchema = z + .object({ + registry: z + .enum([ + "npm", + "pypi", + "hex", + "crates", + "nuget", + "maven", + "zig", + "vcpkg", + "packagist", + ]) + .optional() + .describe( + "Package registry (npm, pypi, hex, etc.). Required for package scope.", + ), + package_name: z + .string() + .max(255) + .optional() + .describe("Package name. Required for package scope."), + version: z + .string() + .max(100) + .optional() + .describe( + "Package version, e.g. '4.18.2' (defaults to latest). For package scope only.", + ), + repo_url: z + .string() + .optional() + .describe( + "Repository URL (GitHub). Required for repo scope. Example: https://github.com/expressjs/express", + ), + git_ref: z + .string() + .optional() + .describe( + "Git ref - tag, branch, or commit. Required with repo_url. Use HEAD for latest.", + ), + }) + .describe( + "Target: provide registry + package_name (package scope) or repo_url + git_ref (repo scope).", + ); + +export type CodeTargetArg = { + registry?: CodeNavigationRegistryArg; + package_name?: string; + version?: string; + repo_url?: string; + git_ref?: string; +}; + +/** + * Validates and normalizes a code navigation target. + * + * Error results carry a JSON-encoded `{ error, code: "INVALID_ARGUMENT" }` + * envelope per PARITY-ERROR-ENVELOPE — MCP error text must always be + * valid JSON regardless of which validation branch fires. + */ +export function resolveCodeTarget( + target: CodeTargetArg, +): CodeNavigationTarget | ToolResult { + const hasPackageTarget = Boolean(target.registry || target.package_name); + const hasRepoTarget = Boolean(target.repo_url || target.git_ref); + + if (hasPackageTarget && hasRepoTarget) { + return invalidTargetResult( + "Invalid target: provide either registry + package_name or repo_url + git_ref, not both.", + ); + } + + if (!hasPackageTarget && !hasRepoTarget) { + return invalidTargetResult( + "Missing target: provide registry + package_name or repo_url + git_ref.", + ); + } + + if (hasPackageTarget) { + if (!target.registry || !target.package_name) { + return invalidTargetResult( + "Incomplete package target: both registry and package_name are required.", + ); + } + + return { + registry: toCodeNavigationRegistry(target.registry), + packageName: target.package_name, + version: target.version, + }; + } + + if (!target.repo_url || !target.git_ref) { + return invalidTargetResult( + "Incomplete repository target: both repo_url and git_ref are required.", + ); + } + + return { + repoUrl: target.repo_url, + gitRef: target.git_ref, + }; +} + +function invalidTargetResult(message: string): ToolResult { + return errorResult( + JSON.stringify({ error: message, code: "INVALID_ARGUMENT" }), + ); +} diff --git a/src/tools/index.ts b/src/tools/index.ts index da8b5a5d..8877d47b 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -1,6 +1,7 @@ export { createFeedbackTool } from "./feedback.js"; export { createSearchTool } from "./search.js"; export { createSearchLanguageTool } from "./search-language.js"; +export { createSearchSymbolsTool } from "./search-symbols.js"; export type { ToolDefinition, ToolHandler, diff --git a/src/tools/search-symbols-parity.test.ts b/src/tools/search-symbols-parity.test.ts new file mode 100644 index 00000000..cfd88a73 --- /dev/null +++ b/src/tools/search-symbols-parity.test.ts @@ -0,0 +1,239 @@ +// PARITY TEST — enforces rule IDs from docs/implementation/mcp-cli-parity.md: +// PARITY-JSON-KEYS CLI --json output and MCP text payload parse to +// deepEqual JSON objects for equivalent inputs. +// PARITY-ERROR-ENVELOPE Both surfaces emit { error, code, details? } on +// every error path; MCP error text is always valid +// JSON. +// +// Rule IDs are stable; changes to either this test or the parity doc +// are coordinated (renaming a rule ID here without updating the doc, +// or vice versa, should fail review). + +import { describe, expect, it, mock, spyOn } from "bun:test"; +import { + type SearchSymbolsCommandDependencies, + searchSymbolsAction, +} from "../commands/code/search-symbols.js"; +import { + CodeNavigationIndexingError, + CodeNavigationTargetNotFoundError, +} from "../services/index.js"; +import { + createMockCodeNavigationService, + defaultSearchSymbolsResult, +} from "../services/test-helpers.js"; +import { createSearchSymbolsTool } from "./search-symbols.js"; + +function cliDeps( + overrides: Partial = {}, +): SearchSymbolsCommandDependencies { + return { + codeNavigationService: createMockCodeNavigationService(), + codeNavigationUrl: "https://nav.example.com", + hasValidToken: true, + mcpUrl: "https://mcp.example.com", + ...overrides, + }; +} + +async function cliJson( + packageArg: string, + query: string | undefined, + options: Parameters[2] = {}, + deps: SearchSymbolsCommandDependencies = cliDeps(), +): Promise { + const logSpy = spyOn(console, "log").mockImplementation(() => {}); + const errSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + try { + try { + await searchSymbolsAction( + packageArg, + query, + { ...options, json: true }, + deps, + ); + } catch { + // CLI error paths call process.exit — caught. + } + const fromLog = logSpy.mock.calls[0]?.[0] as string | undefined; + const fromErr = errSpy.mock.calls[0]?.[0] as string | undefined; + const raw = fromLog ?? fromErr; + return raw ? JSON.parse(raw) : undefined; + } finally { + logSpy.mockRestore(); + errSpy.mockRestore(); + exitSpy.mockRestore(); + } +} + +async function mcpJson( + args: Parameters["handler"]>[0], + searchMock?: () => Promise, +): Promise { + const service = createMockCodeNavigationService( + searchMock ? { searchSymbols: mock(searchMock) as never } : {}, + ); + const tool = createSearchSymbolsTool(service); + const result = await tool.handler(args, {}); + const text = result.content[0]?.text ?? ""; + return JSON.parse(text); +} + +describe("search_symbols CLI ↔ MCP JSON parity", () => { + it("PARITY-JSON-KEYS: successful search produces the same envelope", async () => { + const cliPayload = await cliJson("npm:express", "middleware"); + const mcpPayload = await mcpJson({ + target: { registry: "npm", package_name: "express" }, + query: "middleware", + }); + + expect(cliPayload).toEqual(mcpPayload); + }); + + it("PARITY-JSON-KEYS: zero-result search omits the `hint` key when server hint is empty", async () => { + const emptyResult = { results: [], totalMatches: 0, hasMore: false }; + const cliPayload = await cliJson( + "npm:express", + "nonexistent-token", + {}, + cliDeps({ + codeNavigationService: createMockCodeNavigationService({ + searchSymbols: mock(() => Promise.resolve(emptyResult)), + }), + }), + ); + const mcpPayload = await mcpJson( + { + target: { registry: "npm", package_name: "express" }, + query: "nonexistent-token", + }, + () => Promise.resolve(emptyResult), + ); + + expect(cliPayload).toEqual(mcpPayload); + expect((cliPayload as Record).hint).toBeUndefined(); + }); + + it("PARITY-ERROR-ENVELOPE: NOT_FOUND emits the same envelope on both surfaces", async () => { + const err = new CodeNavigationTargetNotFoundError("Package not found", [ + { version: "5.2.1", ref: "v5.2.1" }, + ]); + const cliPayload = await cliJson( + "npm:nonexistent", + "middleware", + {}, + cliDeps({ + codeNavigationService: createMockCodeNavigationService({ + searchSymbols: mock(() => Promise.reject(err)), + }), + }), + ); + const mcpPayload = await mcpJson( + { + target: { registry: "npm", package_name: "nonexistent" }, + query: "middleware", + }, + () => Promise.reject(err), + ); + + expect(cliPayload).toEqual(mcpPayload); + expect(cliPayload).toEqual({ + error: "Package not found", + code: "NOT_FOUND", + retryable: false, + details: { availableVersions: [{ version: "5.2.1", ref: "v5.2.1" }] }, + }); + }); + + it("PARITY-ERROR-ENVELOPE: INVALID_ARGUMENT produces a valid-JSON envelope on both surfaces", async () => { + // CLI: unknown-registry prefix rejected by the package-spec parser. + const cliPayload = await cliJson("foobar:baz", "middleware"); + expect(cliPayload).toMatchObject({ + code: "INVALID_ARGUMENT", + error: expect.stringContaining("Unsupported registry"), + }); + + // MCP: resolveCodeTarget rejects an invalid target shape and emits + // the same envelope rather than a free-form error string. + const tool = createSearchSymbolsTool(createMockCodeNavigationService()); + const result = await tool.handler({ target: {}, query: "middleware" }, {}); + expect(result.isError).toBe(true); + const mcpPayload = JSON.parse(result.content[0]?.text ?? ""); + expect(mcpPayload).toMatchObject({ + code: "INVALID_ARGUMENT", + error: expect.stringContaining("Missing target"), + }); + }); + + it("PARITY-ERROR-ENVELOPE: INDEXING errors carry identical details", async () => { + const err = new CodeNavigationIndexingError( + "Target is being indexed.", + "idx-42", + ); + const cliPayload = await cliJson( + "npm:express", + "middleware", + {}, + cliDeps({ + codeNavigationService: createMockCodeNavigationService({ + searchSymbols: mock(() => Promise.reject(err)), + }), + }), + ); + const mcpPayload = await mcpJson( + { + target: { registry: "npm", package_name: "express" }, + query: "middleware", + }, + () => Promise.reject(err), + ); + + expect(cliPayload).toEqual(mcpPayload); + expect(cliPayload).toEqual({ + error: "Target is being indexed.", + code: "INDEXING", + retryable: true, + details: { indexingRef: "idx-42" }, + }); + }); + + it("PARITY-JSON-KEYS: query.defaulted lists the client-applied fields on both surfaces", async () => { + const cliPayload = (await cliJson("npm:express", "middleware")) as { + query: { defaulted: string[] }; + }; + const mcpPayload = (await mcpJson({ + target: { registry: "npm", package_name: "express" }, + query: "middleware", + })) as { query: { defaulted: string[] } }; + + expect(cliPayload.query.defaulted).toEqual(mcpPayload.query.defaulted); + expect(cliPayload.query.defaulted).toContain("fileIntent"); + expect(cliPayload.query.defaulted).toContain("waitTimeoutMs"); + }); + + it("PARITY-JSON-KEYS: no leading-underscore keys in success or default payloads", async () => { + const payload = (await cliJson("npm:express", "middleware")) as Record< + string, + unknown + >; + + const assertNoUnderscoreKeys = (value: unknown, path = "") => { + if (value === null || typeof value !== "object") return; + for (const key of Object.keys(value as Record)) { + expect(key).not.toMatch(/^_/); + assertNoUnderscoreKeys( + (value as Record)[key], + `${path}.${key}`, + ); + } + }; + + assertNoUnderscoreKeys(payload); + // And the fixture definitely exercises at least one success field + // so the assertion is not vacuous. + expect(payload.results).toEqual(defaultSearchSymbolsResult.results); + }); +}); diff --git a/src/tools/search-symbols.test.ts b/src/tools/search-symbols.test.ts new file mode 100644 index 00000000..6e479190 --- /dev/null +++ b/src/tools/search-symbols.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, it, mock } from "bun:test"; +import { CodeNavigationIndexingError } from "../services/index.js"; +import { + createMockCodeNavigationService, + defaultSearchSymbolsResult, +} from "../services/test-helpers.js"; +import { createSearchSymbolsTool } from "./search-symbols.js"; + +describe("searchSymbolsTool", () => { + it("returns tool metadata", () => { + const tool = createSearchSymbolsTool(createMockCodeNavigationService()); + expect(tool.name).toBe("search_symbols"); + expect(tool.description).toContain("exact-token matches"); + expect(tool.description).toContain("'production'"); + expect(tool.description).toContain("'all'"); + // Category is the preferred filtering surface per the April 2026 + // backend taxonomy split. + expect(tool.description).toContain("category"); + }); + + it("calls service with normalized target and search params", async () => { + const searchSymbols = mock(() => + Promise.resolve({ results: [], totalMatches: 0, hasMore: false }), + ); + const tool = createSearchSymbolsTool( + createMockCodeNavigationService({ searchSymbols }), + ); + + await tool.handler( + { + target: { registry: "npm", package_name: "express", version: "4.18.0" }, + query: "middleware", + kind: "function", + limit: 10, + wait_timeout_ms: 5000, + }, + {}, + ); + + expect(searchSymbols).toHaveBeenCalledWith({ + target: { registry: "NPM", packageName: "express", version: "4.18.0" }, + query: "middleware", + keywords: undefined, + matchMode: undefined, + kind: "FUNCTION", + filePath: undefined, + limit: 10, + fileIntent: "PRODUCTION", + waitTimeoutMs: 5000, + }); + }); + + it("returns the shared success envelope on success", async () => { + const tool = createSearchSymbolsTool(createMockCodeNavigationService()); + + const result = await tool.handler( + { + target: { registry: "npm", package_name: "express" }, + query: "middleware", + }, + {}, + ); + + expect(result.isError).toBeUndefined(); + const payload = JSON.parse(result.content[0]?.text ?? ""); + expect(payload.results).toEqual(defaultSearchSymbolsResult.results); + expect(payload.returnedCount).toBe(1); + expect(payload.totalMatches).toBe(1); + expect(payload.hasMore).toBe(false); + expect(payload.version).toBe("4.18.0"); + expect(payload.query.target).toEqual({ + registry: "NPM", + packageName: "express", + version: undefined, + }); + expect(payload.query.query).toBe("middleware"); + expect(payload.query.fileIntent).toBe("production"); + expect(payload.query.defaulted).toContain("fileIntent"); + expect(payload.query.defaulted).toContain("waitTimeoutMs"); + // No underscore-prefixed keys in the shared envelope. + expect(payload._warning).toBeUndefined(); + expect(payload._hint).toBeUndefined(); + }); + + it("emits the shared error envelope on indexing errors", async () => { + const tool = createSearchSymbolsTool( + createMockCodeNavigationService({ + searchSymbols: mock(() => + Promise.reject( + new CodeNavigationIndexingError( + "Target is being indexed.", + "idx-123", + [{ version: "4.18.0", ref: "v4.18.0" }], + ), + ), + ), + }), + ); + + const result = await tool.handler( + { + target: { registry: "npm", package_name: "express" }, + query: "middleware", + }, + {}, + ); + + expect(result.isError).toBe(true); + expect(JSON.parse(result.content[0]?.text ?? "")).toEqual({ + error: "Target is being indexed.", + code: "INDEXING", + retryable: true, + details: { + indexingRef: "idx-123", + availableVersions: [{ version: "4.18.0", ref: "v4.18.0" }], + }, + }); + }); + + it("emits a valid-JSON error envelope when query and keywords are missing", async () => { + const tool = createSearchSymbolsTool(createMockCodeNavigationService()); + const result = await tool.handler( + { target: { registry: "npm", package_name: "express" } }, + {}, + ); + + expect(result.isError).toBe(true); + expect(JSON.parse(result.content[0]?.text ?? "")).toEqual({ + error: "Provide either query or keywords.", + code: "INVALID_ARGUMENT", + }); + }); + + it("treats file_intent 'all' as the FILE_INTENT_ALL sentinel and echoes 'all'", async () => { + const searchSymbols = mock< + ( + params: import("../services/index.js").SearchSymbolsParams, + ) => Promise + >(() => Promise.resolve({ results: [], totalMatches: 0, hasMore: false })); + const tool = createSearchSymbolsTool( + createMockCodeNavigationService({ searchSymbols }), + ); + const result = await tool.handler( + { + target: { registry: "npm", package_name: "express" }, + query: "middleware", + file_intent: "all", + }, + {}, + ); + + // Service receives undefined fileIntent — omit the GraphQL variable. + expect(searchSymbols.mock.calls[0]?.[0]?.fileIntent).toBeUndefined(); + const payload = JSON.parse(result.content[0]?.text ?? ""); + expect(payload.query.fileIntent).toBe("all"); + expect(payload.query.defaulted).not.toContain("fileIntent"); + }); + + it("echoes an explicit non-default file_intent without marking it as defaulted", async () => { + const tool = createSearchSymbolsTool(createMockCodeNavigationService()); + const result = await tool.handler( + { + target: { registry: "npm", package_name: "express" }, + query: "middleware", + file_intent: "test", + }, + {}, + ); + + const payload = JSON.parse(result.content[0]?.text ?? ""); + expect(payload.query.fileIntent).toBe("test"); + expect(payload.query.defaulted).not.toContain("fileIntent"); + }); + + it("rejects `mode` and `verbose` as unknown inputs (removed from schema)", () => { + const tool = createSearchSymbolsTool(createMockCodeNavigationService()); + // The compile-time SearchSymbolsArgs no longer declares these keys. + // At runtime the Zod schema also omits them; a defensive runtime + // assertion keeps us honest: + const schemaKeys = Object.keys(tool.schema); + expect(schemaKeys).not.toContain("mode"); + expect(schemaKeys).not.toContain("verbose"); + }); + + it("passes category and kind through to the service and echoes category lowercase", async () => { + const searchSymbols = mock< + ( + params: import("../services/index.js").SearchSymbolsParams, + ) => Promise + >(() => Promise.resolve({ results: [], totalMatches: 0, hasMore: false })); + const tool = createSearchSymbolsTool( + createMockCodeNavigationService({ searchSymbols }), + ); + + const result = await tool.handler( + { + target: { registry: "npm", package_name: "express" }, + query: "Router", + category: "callable", + kind: "trait", + }, + {}, + ); + + expect(searchSymbols.mock.calls[0]?.[0]?.category).toBe("CALLABLE"); + expect(searchSymbols.mock.calls[0]?.[0]?.kind).toBe("TRAIT"); + const payload = JSON.parse(result.content[0]?.text ?? ""); + expect(payload.query.category).toBe("callable"); + expect(payload.query.kind).toBe("trait"); + }); +}); diff --git a/src/tools/search-symbols.ts b/src/tools/search-symbols.ts new file mode 100644 index 00000000..dde19632 --- /dev/null +++ b/src/tools/search-symbols.ts @@ -0,0 +1,236 @@ +import { z } from "zod"; +import type { CodeNavigationService } from "../services/index.js"; +import { + toSearchSymbolsFileIntent, + toSearchSymbolsKind, + toSearchSymbolsMatchMode, + toSymbolCategory, +} from "../shared/code-navigation.js"; +import { FILE_INTENT_ALL } from "../shared/code-navigation-defaults.js"; +import { buildSearchSymbolsParams } from "../shared/search-symbols-request.js"; +import { + buildSearchSymbolsErrorPayload, + buildSearchSymbolsSuccessPayload, +} from "../shared/search-symbols-response.js"; +import { + type CodeTargetArg, + codeTargetSchema, + resolveCodeTarget, +} from "./code-navigation-shared.js"; +import { errorResult, type ToolDefinition, textResult } from "./types.js"; + +export interface SearchSymbolsArgs { + target: CodeTargetArg; + query?: string; + keywords?: string[]; + match_mode?: "or" | "and"; + category?: "callable" | "type" | "module" | "data" | "documentation"; + kind?: + | "function" + | "method" + | "constructor" + | "getter" + | "setter" + | "operator" + | "class" + | "interface" + | "trait" + | "struct" + | "enum" + | "record" + | "protocol" + | "extension" + | "delegate" + | "mixin" + | "actor" + | "annotation" + | "type" + | "module" + | "namespace" + | "package" + | "object" + | "field" + | "property" + | "event" + | "constant" + | "doc_section"; + file_path?: string; + limit?: number; + file_intent?: + | "production" + | "test" + | "benchmark" + | "example" + | "generated" + | "fixture" + | "build" + | "vendor" + | "all"; + wait_timeout_ms?: number; +} + +const schema = { + target: codeTargetSchema, + query: z + .string() + .max(500) + .optional() + .describe( + "Search keywords - exact source tokens, not natural language. Required if keywords not provided.", + ), + keywords: z + .array(z.string()) + .max(20) + .optional() + .describe( + "Search keywords (max 20). Combined using match_mode. Can be used alone or together with query.", + ), + match_mode: z + .enum(["or", "and"]) + .optional() + .describe("How to combine keywords: or (any match) or and (all match)"), + category: z + .enum(["callable", "type", "module", "data", "documentation"]) + .optional() + .describe( + "Broad symbol category filter — the preferred surface for filtering. callable (function/method/constructor/getter/setter/operator), type (class/interface/trait/struct/enum/record/protocol/extension/etc.), module (module/namespace/package/object), data (field/property/event/constant), documentation (doc_section).", + ), + kind: z + .enum([ + "function", + "method", + "constructor", + "getter", + "setter", + "operator", + "class", + "interface", + "trait", + "struct", + "enum", + "record", + "protocol", + "extension", + "delegate", + "mixin", + "actor", + "annotation", + "type", + "module", + "namespace", + "package", + "object", + "field", + "property", + "event", + "constant", + "doc_section", + ]) + .optional() + .describe( + "Precise symbol kind filter. Prefer `category` for broad filtering; use `kind` only when you need a specific construct (e.g. trait vs interface).", + ), + file_path: z + .string() + .optional() + .describe( + "Filter results to files whose path starts with this value (e.g., 'src/' for a directory)", + ), + limit: z.coerce + .number() + .int() + .min(1) + .max(50) + .optional() + .describe("Max results to return (max 50)"), + file_intent: z + .enum([ + "production", + "test", + "benchmark", + "example", + "generated", + "fixture", + "build", + "vendor", + "all", + ]) + .optional() + .describe( + "File intent filter. Defaults to 'production' so top results are production source. Pass 'all' to include tests, benchmarks, examples, and other non-production files.", + ), + wait_timeout_ms: z.coerce + .number() + .int() + .min(0) + .max(60000) + .optional() + .describe( + "Max milliseconds to wait for indexing before returning. Defaults to 20000 (20 seconds) to cover typical indexing (p50 ~11s). On an INDEXING response, retry with wait_timeout_ms up to 60000 to block until ready.", + ), +}; + +const DESCRIPTION = + "Search a dependency's source code by exact-token matches. Use for symbol lookup, not natural-language questions. " + + "Package scope: pass target.registry + target.package_name. Repo scope: pass target.repo_url + target.git_ref. " + + "`file_intent` defaults to 'production' so top results are production source; pass 'all' to include tests, examples, benchmarks. " + + "`query` and `keywords` can combine; `match_mode` controls AND vs OR across keywords. " + + "Filter by `category` (broad: callable/type/module/data/documentation — preferred) or `kind` (precise construct like trait/record/namespace). " + + "Prefer `file_path` to scope to a directory (e.g., 'src/'). " + + "Responses include each match's source code, line range, and unified `kind`/`category` classification. " + + "If the response is an INDEXING error, the package is being indexed on-demand — retry the same call with `wait_timeout_ms: 60000` to block until ready."; + +export function createSearchSymbolsTool( + service: CodeNavigationService, +): ToolDefinition { + return { + name: "search_symbols", + description: DESCRIPTION, + schema, + annotations: { readOnlyHint: true }, + handler: async (args) => { + const target = resolveCodeTarget(args.target); + if ("content" in target) return target; + + if (!args.query && (!args.keywords || args.keywords.length === 0)) { + // Emit a valid-JSON error payload so MCP clients can always + // parse `content[0].text` (PARITY-ERROR-ENVELOPE). + return errorResult( + JSON.stringify({ + error: "Provide either query or keywords.", + code: "INVALID_ARGUMENT", + }), + ); + } + + try { + const { params, defaulted } = buildSearchSymbolsParams({ + target, + query: args.query, + keywords: args.keywords, + matchMode: toSearchSymbolsMatchMode(args.match_mode), + kind: toSearchSymbolsKind(args.kind), + category: toSymbolCategory(args.category), + filePath: args.file_path, + limit: args.limit, + fileIntent: + args.file_intent === "all" + ? FILE_INTENT_ALL + : toSearchSymbolsFileIntent(args.file_intent), + waitTimeoutMs: args.wait_timeout_ms, + }); + const result = await service.searchSymbols(params); + const payload = buildSearchSymbolsSuccessPayload( + params, + defaulted, + result, + ); + return textResult(JSON.stringify(payload)); + } catch (error) { + return errorResult( + JSON.stringify(buildSearchSymbolsErrorPayload(error)), + ); + } + }, + }; +} diff --git a/src/tools/types.ts b/src/tools/types.ts index f4d97558..a5ec766d 100644 --- a/src/tools/types.ts +++ b/src/tools/types.ts @@ -1,3 +1,4 @@ +import type { ToolAnnotations } from "@modelcontextprotocol/sdk/types.js"; import type { z } from "zod"; /** @@ -31,6 +32,7 @@ export interface ToolDefinition< name: string; description: string; schema: TSchema; + annotations?: ToolAnnotations; handler: ToolHandler; }