diff --git a/docs/implementation/cli-commands.md b/docs/implementation/cli-commands.md index cf5a3eea..0cbcb350 100644 --- a/docs/implementation/cli-commands.md +++ b/docs/implementation/cli-commands.md @@ -17,6 +17,9 @@ The CLI exposes three primary commands (`search`, `languages`, `feedback`) that | `pkg vulns ` | package spec (optional `@version`) | `--severity`, `--include-withdrawn`, `--verbose`, `--json` | List known vulnerabilities for a package (npm/pypi/hex/crates) | | `pkg deps ` | package spec (optional `@version`) | `--groups`, `--lifecycle`, `--transitive`, `--depth`, `--verbose`, `--json` | Analyse dependencies: direct runtime deps, structured groups, optional transitive graph (npm/pypi/hex/crates/vcpkg/zig) | | `pkg changelog [spec]` | package spec OR `--repo-url` | `--from`, `--to`, `--limit`, `--git-ref`, `--no-body`, `--verbose`, `--json` | Release notes / changelog entries for a package or GitHub repo (GitHub Releases, CHANGELOG.md, or HexDocs). Default shows each entry with a 10-line body preview; `--verbose` uncaps, `--no-body` drops. | +| `code files [spec] [path-prefix]` | package spec OR `--repo-url` + `--git-ref`; optional `[path-prefix]` | `--limit`, `--wait`, `--verbose`, `--json` | List files in an indexed dependency. `[path-prefix]` is a literal directory prefix (not a glob). Plain output is one path per line; `--verbose` adds language / type / size annotations. Indexing-retry via `--wait` or the `availableVersions` hint in the error envelope. | +| `code read ` | package spec OR `--repo-url` + `--git-ref`; plus `` | `--lines`, `--start`, `--end`, `--wait`, `--verbose`, `--json` | Read a file's contents. Plain output is the raw file bytes (pipe-friendly); `--verbose` adds a header and a line-number gutter. `--lines 10-40` concise form; `--start`/`--end` equivalent. Binary files show a sentinel line. | +| `code grep ` | package spec OR `--repo-url` + `--git-ref`; plus `` and `` | `--context`, `--limit`, `--wait`, `--verbose`, `--json` | Search within a single file for a case-insensitive substring (not regex). Plain output is matching lines only (pipe-friendly, `grep`-style); `--verbose` adds a header and a line-number gutter with `>` markers on match lines. `--context ` adds surrounding lines (default 0, up to 10); overlapping blocks merge without duplicates. Max 200-char pattern; up to 200 matches. | ### `githits init` @@ -224,6 +227,78 @@ Fetches release notes or changelog entries for a package or GitHub repository. O **Troubleshooting.** Same debug areas (`GITHITS_DEBUG=pkg-intel` for classified errors; `GITHITS_DEBUG=pkg-graphql` for transport failures). +### `githits code files` + +``` +githits code files npm:express +githits code files npm:express lib # scope by prefix +githits code files npm:express lib --verbose # + language / type / size +githits code files --repo-url https://github.com/expressjs/express --git-ref main lib +githits code files npm:express --json +``` + +Lists files in an indexed dependency. `[spec] [path-prefix]` positionals mirror `code read` / `code grep` so the three commands chain without friction. `[path-prefix]` is a literal directory prefix; glob / extension filtering is not supported by the backend today (tracked as an upstream ask). + +**Plain output (default).** One bare path per line on stdout — pipe-friendly. No header, no annotations. `code files npm:express lib | xargs -I{} …` works cleanly. + +**`--verbose`.** Adds a contextual header (` · `), the resolution line (`indexed at · commit `), and per-row language / file-type / byte-size annotations. + +**`stdout` vs `stderr` routing (plain mode).** Truncation warnings (`More files available — pass --limit higher …`) and empty-result hints go to **stderr**, not stdout, so they stay visible to humans without polluting pipes. In `--verbose` the same text renders inline. + +**Addressing ambiguity guard.** In `--repo-url` mode, a positional that matches a known registry prefix (`npm:`, `pypi:`, `hex:`, `crates:`, `nuget:`, `maven:`, `zig:`, `vcpkg:`, `packagist:`) is rejected with a "looks like a package spec" error — catches `code files npm:express --repo-url …` typos that would otherwise silently interpret the spec as a path prefix. + +**Exit codes.** `0` on success (including empty results — absence of files is not an error). `1` on error (authentication, indexing, invalid arguments, backend failures). + +### `githits code read` + +``` +githits code read npm:express lib/express.js +githits code read npm:express lib/express.js --lines 1-40 +githits code read npm:express lib/express.js --verbose # + header + gutter +githits code read --repo-url https://github.com/expressjs/express --git-ref main lib/express.js +githits code read npm:express lib/express.js --json +``` + +Reads a file from an indexed dependency. `` is package-relative in spec mode, repo-relative in `--repo-url` mode. + +**Plain output (default).** Raw file bytes, verbatim (preserves the backend's trailing newline). Piping `code read … | grep …` or `code read … > file` round-trips cleanly. + +**`--verbose`.** Adds the ` · · lines of ` header and a right-aligned line-number gutter. No stderr routing — `read` has no truncation path. + +**Line ranges.** `--lines 10-40` (concise form), `--lines 10-` (open end), `--lines -40` (open start). `--start ` / `--end ` are the verbose equivalents. Combining `--lines` with `--start` / `--end` is rejected. + +**Binary files.** Plain mode writes `Binary file — cannot display as text.` to stdout (consistent with `grep`'s binary-file convention). `--verbose` adds the header above the sentinel. `--json` exposes the classification via `isBinary: true` with `content` omitted — agents branch on the flag, not a null check. + +**Exit codes.** `0` on success. `1` on error — `FILE_NOT_FOUND` (path doesn't resolve) carries a "Use `code files` to list available paths" hint in terminal output. + +### `githits code grep` + +``` +githits code grep npm:express express lib/express.js +githits code grep npm:express express lib/express.js --context 2 # merged blocks +githits code grep npm:express express lib/express.js --verbose # + header + gutter + `>` marker +githits code grep --repo-url https://github.com/expressjs/express --git-ref main export lib/express.js +githits code grep npm:express express lib/express.js --json +``` + +Case-insensitive **substring** search inside a single file — not regex. Max pattern 200 chars; up to 200 matches with up to 10 context lines each. For symbol-shaped searches use `githits code search` (backed by `search_symbols`). + +**Plain output (default).** Matching lines only, one per line on stdout — mirrors `grep`'s default. `--context ` (0–10, default **0**) adds surrounding lines; nearby matches whose contexts touch or overlap merge into a single block with no duplicated lines. Distinct blocks are separated by `--` on its own line, matching `grep -C` / `rg -C` convention. + +**`--verbose`.** Adds header, right-aligned line-number gutter, and a `>` marker on match lines so they're distinguishable from context at a glance. + +**`stdout` vs `stderr` routing (plain mode).** "More matches available" truncation warning goes to **stderr**. When a zero-match pattern looks like a regex attempt (`\bfoo\b`, `^start`, character classes, etc.) a one-line nudge — "Note: pattern matched literally — this tool does case-insensitive substring search, not regex." — also goes to stderr. Pipes stay clean; humans still see the hints. + +**Exit codes (grep-compatible).** + +- `0` — at least one match. +- `1` — zero matches. Fires in both plain and `--json` modes so scripting (`if code grep X file; then …`) behaves consistently across surfaces. +- `2` — error (missing file, indexing, invalid arguments, backend failure). Distinguished from "no match" so scripts can branch correctly. + +This is the standard `grep(1)` contract; the tool adopts it deliberately because its output shape mirrors grep's. + +**Regex pattern note.** The `GREP_PATTERN_SEMANTICS_NOTE` string ("Case-insensitive substring matching. NOT regex — …") is shared verbatim across the CLI help text, the MCP tool description, and the MCP `pattern` argument's `describe` so the three surfaces never disagree about pattern semantics. + ## Architecture ``` diff --git a/docs/implementation/mcp-cli-parity.md b/docs/implementation/mcp-cli-parity.md index f170d7a7..fd5e5214 100644 --- a/docs/implementation/mcp-cli-parity.md +++ b/docs/implementation/mcp-cli-parity.md @@ -154,23 +154,41 @@ When a new tool lands with both MCP and CLI surfaces: | `src/shared/package-dependencies-response.ts` | Lean JSON envelope builder for `package_dependencies` (shared); terminal formatter (CLI-only). | | `src/shared/package-changelog-request.ts` | Shared request builder for `package_changelog`; owns spec-XOR-repo-URL validation, `@` rejection, `--from` / `--limit` mutex, tag-style version rejection, and the `explicitFilterFields` tracker. | | `src/shared/package-changelog-response.ts` | JSON envelope builder for `package_changelog` (shared); terminal formatter (CLI-only). | +| `src/shared/list-files-request.ts` | Shared request builder for `list_files`; applies the shared `DEFAULT_WAIT_TIMEOUT_MS`, enforces limit bounds, tracks explicit-filter fields. | +| `src/shared/list-files-response.ts` | JSON envelope builder for `list_files` (shared); terminal formatter (CLI-only). Resolves the `hasMore` → `N+` header behaviour. | +| `src/shared/read-file-request.ts` | Shared request builder for `read_file`; trims filePath, validates start/end line positive-integer rules, rejects reversed ranges. | +| `src/shared/read-file-response.ts` | JSON envelope builder for `read_file` (shared); terminal formatter (CLI-only). Normalises the envelope key to `path` (not `filePath`) so `list_files` → `read_file` chains without renames. | +| `src/shared/grep-file-request.ts` | Shared request builder for `grep_file`; exports `GREP_PATTERN_SEMANTICS_NOTE` referenced by MCP description, MCP `pattern` describe, and CLI help. Also exports `looksLikeRegexAttempt` heuristic. | +| `src/shared/grep-file-response.ts` | JSON envelope builder for `grep_file` (shared); terminal formatter (CLI-only) owns the regex-char empty-result nudge. | +| `src/shared/code-navigation-error-map.ts` | `mapCodeNavigationError` classifier. Owns the `INDEXING` / `FILE_NOT_FOUND` / `NOT_FOUND` codes shared across all four code-nav tools. | +| `src/shared/code-navigation-defaults.ts` | `DEFAULT_WAIT_TIMEOUT_MS = 20_000` + `MAX_WAIT_TIMEOUT_MS = 60_000`. Both CLI and MCP request builders import these so defaults never diverge. | +| `src/tools/code-navigation-shared.ts` | `codeTargetSchema` + `resolveCodeTarget` — the single addressing primitive used by `search_symbols`, `list_files`, `read_file`, `grep_file`. | | `src/shared/package-intelligence-error-map.ts` | `mapPackageIntelligenceError` classifier (reuses `MappedError` from the code-nav map). | -| `src/services/promote-version-not-found.ts` | Shared helper that promotes generic backend errors with "no matching version" messages into typed `VERSION_NOT_FOUND`. Used by `packageVulnerabilities`, `packageDependencies`, and `packageChangelog` executors. Extended in P4 to recognise `fromVersion` / `toVersion` (and to skip the `details.package` synthesis when registry/name aren't available, i.e. repo-URL mode). | +| `src/services/promote-version-not-found.ts` | Shared helper that promotes generic backend errors with "no matching version" messages into typed `VERSION_NOT_FOUND`. Used by the `packageVulnerabilities`, `packageDependencies`, and `packageChangelog` executors. Handles both `version` (single-version queries) and `fromVersion` / `toVersion` (range queries), and skips `details.package` synthesis when registry/name aren't available (repo-URL mode). | | `src/tools/search-symbols.ts` | MCP tool definition for `search_symbols`. | | `src/tools/package-summary.ts` | MCP tool definition for `package_summary`. | | `src/tools/package-vulnerabilities.ts` | MCP tool definition for `package_vulnerabilities`. | | `src/tools/package-dependencies.ts` | MCP tool definition for `package_dependencies`. | | `src/tools/package-changelog.ts` | MCP tool definition for `package_changelog`. | +| `src/tools/list-files.ts` | MCP tool definition for `list_files`. | +| `src/tools/read-file.ts` | MCP tool definition for `read_file`. | +| `src/tools/grep-file.ts` | MCP tool definition for `grep_file`. | | `src/commands/code/search-symbols.ts` | CLI command. | | `src/commands/pkg/info.ts` | CLI command for `pkg info`. | | `src/commands/pkg/vulns.ts` | CLI command for `pkg vulns`. | | `src/commands/pkg/deps.ts` | CLI command for `pkg deps`. | | `src/commands/pkg/changelog.ts` | CLI command for `pkg changelog`. | +| `src/commands/code/files.ts` | CLI command for `code files`. | +| `src/commands/code/read.ts` | CLI command for `code read`. | +| `src/commands/code/grep.ts` | CLI command for `code grep`. | | `src/tools/search-symbols-parity.test.ts` | Parity tests (cite rule IDs). | | `src/tools/package-summary-parity.test.ts` | Parity tests for `package_summary` (cite rule IDs). | | `src/tools/package-vulnerabilities-parity.test.ts` | Parity tests for `package_vulnerabilities` (cite rule IDs). | | `src/tools/package-dependencies-parity.test.ts` | Parity tests for `package_dependencies` (cite rule IDs). | | `src/tools/package-changelog-parity.test.ts` | Parity tests for `package_changelog` (cite rule IDs). | +| `src/tools/list-files-parity.test.ts` | Parity tests for `list_files` (cite rule IDs). | +| `src/tools/read-file-parity.test.ts` | Parity tests for `read_file` (cite rule IDs). | +| `src/tools/grep-file-parity.test.ts` | Parity tests for `grep_file` (cite rule IDs). | ## Per-tool notes @@ -305,7 +323,8 @@ When a new tool lands with both MCP and CLI surfaces: `packageChangelog` is intrinsically repo-level on the backend (its sources are GitHub Releases, CHANGELOG.md, HexDocs); repo-URL isn't a bolt-on, it's a peer addressing mode on the GraphQL - signature. P1 / P2 / P3 omit it because their backend queries are + signature. `package_summary` / `package_vulnerabilities` / + `package_dependencies` omit it because their backend queries are registry-metadata APIs without repo-URL alternatives. Future pkg-intel tool authors should not cargo-cult the asymmetry. - **`@` rejected.** Other `pkg` commands give @@ -369,3 +388,48 @@ When a new tool lands with both MCP and CLI surfaces: `BACKEND_ERROR`. - `toMatchObject` for builder-sourced `INVALID_ARGUMENT` cases: `@` rejection, `--from` + `--limit` mutex. + +### `list_files` / `read_file` / `grep_file` (file-exploration bundle) + +All three reuse `codeTargetSchema` + `resolveCodeTarget` from +`src/tools/code-navigation-shared.ts`. The indexing lifecycle is +shared (see `tools.md` "Indexing lifecycle" section). Parity +tests cover dual addressing, default + explicit filter echoes, +INDEXING error envelope, NOT_FOUND envelope, and INVALID_ARGUMENT +with full envelope shape (`{error, code, retryable}`) — the +partial-match policy is deliberately *not* used on INVALID_ARGUMENT +so envelope-drift surfaces in the test rather than at an agent. + +- **`list_files`**: `filter.path_prefix` / `filter.limit` echo + only when explicit. Default `limit: 200` never round-trips. + Backend returns `total` capped at returned count when + `hasMore: true`; terminal formatter renders `N+` to avoid + misleading users. +- **`read_file`**: envelope uses `path` (not `filePath`) to + match `list_files.files[].path`, so agent chains mechanically. + Binary files: `isBinary: true` + `content` omitted (not + `null`). Parity fixture locks this in. `fetchCodeContext` + on the backend doesn't return `availableVersions` on + INDEXING responses, so its `details` block carries only + `indexingRef` — MCP description calls this out explicitly. +- **`grep_file`**: `GREP_PATTERN_SEMANTICS_NOTE` constant + (exported from `grep-file-request.ts`) ensures the + substring-only disclosure is identical in the MCP + description, MCP `pattern` field describe, and CLI help text. + Regex-char heuristic in the terminal formatter nudges users + who typed clearly-regex patterns; the JSON envelope never + carries this hint. Triggered signals cover `\b\B\w\W\d\D\s\S`, + escaped metacharacters, character classes, non-capturing / + lookaround / named groups / inline flags, and brace + quantifiers. Deliberately excludes bare `.`, `*`, `+`, `?`, + `^`, `$`, `|`, `(`, `)` — too common in ordinary code. + +- **Parity assertion policy** (coded in the three parity + tests): + - `toEqual` across service-sourced fixtures: happy (package + and repo-URL addressing), filter echoes, INDEXING, NOT_FOUND, + and (for `read_file`) the binary fixture; (for + `read_file`) FILE_NOT_FOUND and line range. + - `toMatchObject` with explicit `retryable: false` assertion + for builder-sourced `INVALID_ARGUMENT` — both surfaces + must emit the same envelope keys so drift is loud. diff --git a/docs/implementation/tools.md b/docs/implementation/tools.md index 7782823c..63c74ad2 100644 --- a/docs/implementation/tools.md +++ b/docs/implementation/tools.md @@ -27,8 +27,11 @@ Both expose the same tools with identical names, parameters, and descriptions. T | `package_vulnerabilities` | `registry`, `package_name`, `version?`, `min_severity?`, `include_withdrawn?` | Known vulnerabilities for a package on npm, PyPI, Hex, or Crates. Count summary, per-advisory OSV ID + severity + affected/fix ranges, and upgrade paths. Malware is surfaced in a disjoint bucket. | | `package_dependencies` | `registry`, `package_name`, `version?`, `lifecycle?`, `include_transitive?`, `include_importers?`, `max_depth?` | Direct runtime dependency list (each `{name, version, constraint}` — the backend resolves each constraint to a concrete version) plus, when the backend has them, structured groups for dev / peer / build / optional with registry-specific condition metadata (PyPI extras, Crates features). Optional transitive block with aggregate edge counts, the preprocessed install footprint as `{name, version}`, typed conflicts and circular-dependency cycles; opt into per-package importer provenance with `include_importers`. | | `package_changelog` | `registry?`, `package_name?`, `repo_url?`, `from_version?`, `to_version?`, `limit?`, `git_ref?`, `include_bodies?` | Release notes or changelog entries for a package or GitHub repo. Default latest mode returns the ten most recent entries; `from_version` switches to range mode (no count cap). Dual addressing (spec vs repo URL) mutually exclusive. Response always includes `source` (`"releases"` / `"changelog_file"` / `"hexdocs"`), `mode` (`"latest"` / `"range"`), and `entries: { count, items }` with full markdown bodies by default; set `include_bodies: false` for a lean version / date / URL timeline. | +| `list_files` | `target`, `path_prefix?`, `limit?`, `wait_timeout_ms?` | List files in an indexed dependency. Returns `{total, hasMore, files: [{path, name, language, fileType, byteSize}], resolution, indexedVersion}`. Dual addressing via `target.registry + target.package_name` (spec) or `target.repo_url + target.git_ref` (repo). `path_prefix` is a literal directory prefix — NOT a glob (`*.ts` won't match); glob / pattern filtering is an upstream enhancement. Emits an `INDEXING` error envelope when the dependency is being indexed on-demand — retry with a longer `wait_timeout_ms` or pick a version from `details.availableVersions`. | +| `read_file` | `target`, `file_path`, `start_line?`, `end_line?`, `wait_timeout_ms?` | Read a file from an indexed dependency. Default full file; use `start_line` / `end_line` for a bounded range. Binary files set `isBinary: true` and omit `content` — agents branch on the flag. On `NOT_FOUND` / `FILE_NOT_FOUND` call `list_files` to discover the actual path. | +| `grep_file` | `target`, `path`, `pattern`, `context_lines?`, `max_matches?`, `wait_timeout_ms?` | Search within a single file for a case-insensitive substring (not regex). Returns matches — `context_lines` defaults to 0 (matches only, token-efficient); pass explicitly for surrounding lines (0–10). Max pattern 200 chars; up to 200 matches. For symbol-shaped searches use `search_symbols`. | -`search_symbols`, `package_summary`, `package_vulnerabilities`, `package_dependencies`, and `package_changelog` are only registered when the startup token advertises `code_navigation` capability. The backend endpoint can be overridden via `GITHITS_CODE_NAV_URL` for local development. Capability gating keeps the tools hidden from public/default flows while the feature is still rolling out. +`search_symbols`, `package_summary`, `package_vulnerabilities`, `package_dependencies`, `package_changelog`, `list_files`, `read_file`, and `grep_file` are only registered when the startup token advertises `code_navigation` capability. The backend endpoint can be overridden via `GITHITS_CODE_NAV_URL` for local development. `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. @@ -90,7 +93,7 @@ Both expose the same tools with identical names, parameters, and descriptions. T **Per-entry shape.** `{version, normalizedVersion?, publishedAt?, htmlUrl?, body?}`. `version` is kept in the envelope even when `null` so agents can write `items.map(e => e.version)` without guarding; every other nullable field is stripped when absent. `body` is additionally stripped when the caller set `include_bodies: false`. The backend's opaque per-entry `metadata` GenericJSON is deliberately dropped from the envelope in v1 — revisit via agent feedback. -**Dual addressing (`registry` + `package_name` XOR `repo_url`).** `package_changelog` is the only pkg-intel MCP tool with dual addressing. P1 / P2 / P3 all accept only `registry` + `package_name` because their underlying backend queries (summary / vulnerabilities / dependencies) are registry-metadata APIs without repo-URL alternatives. `packageChangelog` is intrinsically repo-level — its sources are GitHub Releases, CHANGELOG.md, and HexDocs — so `repoUrl` is a peer addressing mode in the GraphQL signature, not a bolt-on. Future tool authors should not cargo-cult the asymmetry without reading this rationale. +**Dual addressing (`registry` + `package_name` XOR `repo_url`).** `package_changelog` is the only metadata-side MCP tool with dual addressing. `package_summary` / `package_vulnerabilities` / `package_dependencies` all accept only `registry` + `package_name` because their underlying backend queries are registry-metadata APIs without repo-URL alternatives. `packageChangelog` is intrinsically repo-level — its sources are GitHub Releases, CHANGELOG.md, and HexDocs — so `repoUrl` is a peer addressing mode in the GraphQL signature, not a bolt-on. Future tool authors should not cargo-cult the asymmetry without reading this rationale. **Mode selection.** `from_version` triggers range mode (returns every entry in `[fromVersion, toVersion]` with no cap). Latest mode is the default, capped by `limit` (1–50, backend default 10). `from_version` + `limit` is rejected client-side with `INVALID_ARGUMENT` rather than silently routed to one mode. @@ -106,6 +109,39 @@ Both expose the same tools with identical names, parameters, and descriptions. T `package_changelog` shares its envelope builder with the CLI `githits pkg changelog` command via `src/shared/package-changelog-request.ts` and `src/shared/package-changelog-response.ts`. The terminal formatter is CLI-only. The parity test (`src/tools/package-changelog-parity.test.ts`) asserts `toEqual` across every service-sourced success / error fixture (happy latest, range mode, repo-URL addressing, `--no-body` / `include_bodies: false`, default bodies, empty entries, NOT_FOUND, PackageIntelligenceTargetNotFoundError, VERSION_NOT_FOUND, BACKEND_ERROR) and `toMatchObject` for builder-sourced `INVALID_ARGUMENT`. +### `list_files` / `read_file` / `grep_file` response shapes + +These three indexing-gated tools share an addressing and lifecycle contract (documented below) and then each projects its own data-first envelope. All three reuse the shipped `codeTargetSchema` + `resolveCodeTarget` from `src/tools/code-navigation-shared.ts` — no parallel addressing module. + +**`list_files` envelope**: `{registry?|repoUrl?+gitRef?, total, hasMore, indexedVersion?, resolution?, files: [{path, name?, language?, fileType?, byteSize?}], hint?, filter?}`. `fileType` values come from the backend verbatim (uppercase: `CONFIG`, `SOURCE`, `DOC`, `TEST`). `total` is capped at returned count when `hasMore: true` — the terminal formatter renders `N+ files` in that case to avoid misleading users. `filter.pathPrefix` / `filter.limit` echo only when the caller supplied them explicitly; default limit (200) never round-trips. + +**`read_file` envelope**: `{registry?|repoUrl?+gitRef?, path, language?, totalLines?, startLine?, endLine?, content?, isBinary?}`. `path` (not `filePath`) so the key matches `list_files.files[].path` and `grep_file`'s `path` input — the `list_files` → `read_file` / `grep_file` chain needs no renames. Binary files set `isBinary: true` and **omit** `content` (not `null`); agents branch on the flag. + +**`grep_file` envelope**: `{registry?|repoUrl?+gitRef?, pattern, path, totalMatches, hasMore, matches: [{lineNumber, lineContent, contextBefore?, contextAfter?}], language?, totalLines?, indexedVersion?, resolution?, hint?, filter?}`. Single `path` field (backend echo wins, caller input is the fallback) — no separate `filePath`. Context arrays stripped when empty. + +### Indexing lifecycle (shared across `search_symbols`, `list_files`, `read_file`, `grep_file`) + +All four code-navigation tools share the same indexing-retry contract. The state reaches us via two wire shapes — a GraphQL error (`extensions.code: "PACKAGE_INDEXING"`) and a data-path sentinel (`indexingStatus: "INDEXING"` on a successful response) — and the service layer collapses both to the same typed `CodeNavigationIndexingError` before the envelope builder runs. Agents therefore never see an `indexingStatus` field in a success envelope; they branch on the error path instead. + +**`INDEXING` error envelope**: +```json +{ + "error": "Target is still indexing. …", + "code": "INDEXING", + "retryable": true, + "details": { + "indexingRef": "ref_…", + "availableVersions": [{"version": "4.21.0", "ref": "v4.21.0"}] + } +} +``` + +`details.availableVersions` is populated when the backend returned a list of already-indexed versions alongside the sentinel. Agents can pick one to retry against immediately without waiting. `read_file` / `fetchCodeContext` on the backend doesn't emit `availableVersions` on INDEXING responses, so its error detail carries only `indexingRef` — the MCP description calls this out so agents know to rely on the `wait_timeout_ms` retry path. + +**Retry default**: `DEFAULT_WAIT_TIMEOUT_MS = 20_000` (shared, defined in `src/shared/code-navigation-defaults.ts`). Applied inside each request builder so both CLI and MCP surfaces get the same default by construction. CLI's `--wait ` and MCP's `wait_timeout_ms` override. + +**`FILE_NOT_FOUND` vs `NOT_FOUND`**: `read_file` / `grep_file` can hit "path doesn't resolve" errors. The classifier is pre-wired to emit `FILE_NOT_FOUND` when the backend sends `extensions.code: "FILE_NOT_FOUND"`, but today the backend emits generic `NOT_FOUND` for both "package missing" and "path missing". The distinction is filed upstream. CLI terminal output for `code read` / `code grep` emits the hint "Use `code files` to list available paths." on both codes so users have an actionable next step regardless of classification. + ## Server instructions The MCP server advertises a short, cross-tool orientation via the protocol's server-level `instructions` field. This is distinct from per-tool `description` text: instructions cover rationale, workflow glue, and decisions that span multiple tools, while per-tool descriptions remain the source of truth for arguments, output shape, and tool-specific constraints. diff --git a/src/commands/code/code-nav-cli-helpers.ts b/src/commands/code/code-nav-cli-helpers.ts new file mode 100644 index 00000000..71bc9996 --- /dev/null +++ b/src/commands/code/code-nav-cli-helpers.ts @@ -0,0 +1,193 @@ +/** + * Shared CLI helpers for the indexing-gated `code files` / `code read` + * / `code grep` commands. Each command parses its own positionals + * (because the shape varies — `[spec] [path]` vs `[spec] [pattern] + * [path]`), but addressing resolution, numeric-option parsing, and + * error-envelope rendering are all identical across them. + * + * Extracted once three verbatim copies had accumulated. + */ + +import type { + CodeNavigationService, + CodeNavigationTarget, +} from "../../services/index.js"; +import { + type MappedError, + mapCodeNavigationError, +} from "../../shared/code-navigation-error-map.js"; +import { + InvalidPackageSpecError, + parsePackageSpec, + toPkgseerRegistry, +} from "../../shared/index.js"; + +/** + * Fields every `pkg` indexing-gated command shares. + */ +export interface SharedCodeNavCliDependencies { + codeNavigationService: CodeNavigationService | undefined; + codeNavigationUrl: string | undefined; + hasValidToken: boolean; + mcpUrl: string; +} + +/** + * Fields every `pkg` indexing-gated command's options carry. + */ +export interface SharedCodeNavCliOptions { + repoUrl?: string; + gitRef?: string; +} + +/** + * Resolve a `CodeNavigationTarget` from CLI input. `` mode + * and `--repo-url --git-ref ` mode are mutually + * exclusive; each command parses its own positionals and calls + * this with the resolved spec string (or `undefined` in repo-URL + * mode). + */ +export function resolveCliCodeNavTarget( + spec: string | undefined, + options: SharedCodeNavCliOptions, +): CodeNavigationTarget { + const hasSpec = Boolean(spec); + const hasRepoUrl = Boolean(options.repoUrl); + const hasGitRef = Boolean(options.gitRef); + + if (hasSpec && (hasRepoUrl || hasGitRef)) { + throw new InvalidPackageSpecError( + "Provide either a package spec (e.g. `npm:express`) or `--repo-url` + `--git-ref`, not both.", + ); + } + if (!hasSpec && !hasRepoUrl) { + throw new InvalidPackageSpecError( + "A package spec (e.g. `npm:express`) or `--repo-url` + `--git-ref` is required.", + ); + } + if (hasRepoUrl && !hasGitRef) { + throw new InvalidPackageSpecError( + "`--repo-url` requires `--git-ref` (a tag, branch, commit, or `HEAD`).", + ); + } + + if (hasSpec) { + const parsed = parsePackageSpec(spec as string); + return { + registry: toPkgseerRegistry(parsed.registry), + packageName: parsed.name, + version: parsed.version, + }; + } + + return { + repoUrl: options.repoUrl, + gitRef: options.gitRef, + }; +} + +/** + * Parse an optional `--flag N` integer option with bounds. + * Returns `undefined` when the caller didn't supply the flag. + * Throws `InvalidPackageSpecError` on non-integer or out-of-range + * input so the error classifier routes to `INVALID_ARGUMENT`. + */ +export function parseIntCliOption( + raw: string | undefined, + name: string, + min: number, + max: number, +): number | undefined { + if (raw === undefined) return undefined; + if (!/^-?\d+$/.test(raw.trim())) { + throw new InvalidPackageSpecError( + `${name} expects an integer between ${min} and ${max}. Got '${raw}'.`, + ); + } + const parsed = Number.parseInt(raw, 10); + if (parsed < min || parsed > max) { + throw new InvalidPackageSpecError( + `${name} expects an integer between ${min} and ${max}. Got ${parsed}.`, + ); + } + return parsed; +} + +/** + * Render the `INDEXING` error for terminal output — surfaces + * `indexingRef` + a sample of `availableVersions` as dimmed + * detail lines under the error message. + * + * Common to `code files` / `code read` / `code grep` since all three + * share the same indexing-retry story. + */ +export function formatIndexingError(mapped: MappedError): string { + if (mapped.code !== "INDEXING") return mapped.message; + const detail = mapped.details ?? {}; + const lines = [mapped.message]; + if (detail.indexingRef) lines.push(` indexingRef: ${detail.indexingRef}`); + const versions = detail.availableVersions; + if (versions && versions.length > 0) { + const shown = versions + .slice(0, 5) + .map((entry) => entry.version ?? entry.ref) + .join(", "); + const more = versions.length - 5; + const suffix = more > 0 ? ` (+${more} more)` : ""; + lines.push(` already-indexed versions: ${shown}${suffix}`); + } + return lines.join("\n"); +} + +/** + * Terminal error renderer for `code read` / `code grep`. Treats + * both `FILE_NOT_FOUND` and the backend's currently-generic + * `NOT_FOUND` (gap #11) the same way — the user experience + * shouldn't depend on which classification the backend picked, + * and the next action is always "call `code files` to discover + * the actual paths". + */ +export function formatFileErrorWithFilesHint(mapped: MappedError): string { + if (mapped.code === "FILE_NOT_FOUND" || mapped.code === "NOT_FOUND") { + return `${mapped.message}\n Use \`code files\` to list available paths.`; + } + return formatIndexingError(mapped); +} + +/** + * Shared error-printing + `process.exit` path used by every + * indexing-gated `pkg` command. JSON callers get the shared + * `{error, code, retryable, details?}` envelope on stderr; + * terminal callers get a per-command renderer wrapper. + * + * Each command passes its own `terminalRenderer` so the hint + * message can differ (e.g. `code files` doesn't need the + * `code files`-as-recovery hint; `code read` / `code grep` do). + * + * `exitCode` defaults to 1; `code grep` overrides to 2 so callers + * can distinguish "no matches" (exit 1, `grep` convention) from + * "error" (exit 2). + */ +export function handleCodeNavCommandError( + error: unknown, + json: boolean, + terminalRenderer: (mapped: MappedError) => string, + exitCode = 1, +): never { + const mapped = mapCodeNavigationError(error); + if (json) { + // eslint-disable-next-line no-console + console.error( + JSON.stringify({ + error: mapped.message, + code: mapped.code, + retryable: mapped.retryable ?? false, + ...(mapped.details ? { details: mapped.details } : {}), + }), + ); + process.exit(exitCode); + } + // eslint-disable-next-line no-console + console.error(terminalRenderer(mapped)); + process.exit(exitCode); +} diff --git a/src/commands/code/files.test.ts b/src/commands/code/files.test.ts new file mode 100644 index 00000000..96d0b5c2 --- /dev/null +++ b/src/commands/code/files.test.ts @@ -0,0 +1,378 @@ +import { describe, expect, it, mock, spyOn } from "bun:test"; +import { + CodeNavigationIndexingError, + CodeNavigationTargetNotFoundError, +} from "../../services/index.js"; +import { + createMockCodeNavigationService, + defaultListFilesResult, +} from "../../services/test-helpers.js"; +import { type PkgFilesCommandDependencies, pkgFilesAction } from "./files.js"; + +describe("pkgFilesAction", () => { + const mcpUrl = "https://mcp.githits.com"; + + function createDeps( + overrides: Partial = {}, + ): PkgFilesCommandDependencies { + return { + codeNavigationService: createMockCodeNavigationService(), + codeNavigationUrl: "https://pkgseer.dev", + hasValidToken: true, + mcpUrl, + ...overrides, + }; + } + + it("renders default plain stdout = bare paths only", async () => { + const writes: string[] = []; + const writeSpy = spyOn(process.stdout, "write").mockImplementation((( + chunk: string | Uint8Array, + ) => { + writes.push( + typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk), + ); + return true; + }) as typeof process.stdout.write); + + await pkgFilesAction("npm:express", undefined, {}, createDeps()); + + const combined = writes.join(""); + expect(combined).toContain("src/index.js"); + expect(combined).toContain("src/lib/app.js"); + // Plain mode: no header on stdout — pipes stay clean. + expect(combined).not.toContain("express · npm"); + expect(combined).not.toContain("2 files"); + writeSpy.mockRestore(); + }); + + it("default plain output has paths only — no classification", async () => { + const writes: string[] = []; + const writeSpy = spyOn(process.stdout, "write").mockImplementation((( + chunk: string | Uint8Array, + ) => { + writes.push( + typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk), + ); + return true; + }) as typeof process.stdout.write); + + await pkgFilesAction("npm:express", undefined, {}, createDeps()); + + const combined = writes.join(""); + // Classification labels (language/fileType/byteSize) are + // verbose-only — absent from the default output. + expect(combined).not.toMatch(/javascript/i); + expect(combined).not.toContain("KB"); + writeSpy.mockRestore(); + }); + + it("verbose output includes classification annotations", async () => { + const writes: string[] = []; + const writeSpy = spyOn(process.stdout, "write").mockImplementation((( + chunk: string | Uint8Array, + ) => { + writes.push( + typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk), + ); + return true; + }) as typeof process.stdout.write); + + await pkgFilesAction( + "npm:express", + undefined, + { verbose: true }, + createDeps(), + ); + + const combined = writes.join(""); + // `defaultListFilesResult` includes language metadata; verbose + // mode surfaces it inline. + expect(combined).toMatch(/javascript/i); + writeSpy.mockRestore(); + }); + + it("forwards positional path-prefix to the service", async () => { + const listFiles = mock(() => Promise.resolve(defaultListFilesResult)); + const service = createMockCodeNavigationService({ listFiles }); + const writeSpy = spyOn(process.stdout, "write").mockImplementation( + (() => true) as typeof process.stdout.write, + ); + await pkgFilesAction( + "npm:express", + "src/middleware", + {}, + createDeps({ codeNavigationService: service }), + ); + const calls = listFiles.mock.calls as unknown as Array< + [{ pathPrefix?: string }] + >; + expect(calls[0]?.[0]?.pathPrefix).toBe("src/middleware"); + writeSpy.mockRestore(); + }); + + it("forwards positional path-prefix in --repo-url mode", async () => { + const listFiles = mock(() => Promise.resolve(defaultListFilesResult)); + const service = createMockCodeNavigationService({ listFiles }); + const writeSpy = spyOn(process.stdout, "write").mockImplementation( + (() => true) as typeof process.stdout.write, + ); + await pkgFilesAction( + "lib/", + undefined, + { + repoUrl: "https://github.com/expressjs/express", + gitRef: "main", + }, + createDeps({ codeNavigationService: service }), + ); + const calls = listFiles.mock.calls as unknown as Array< + [{ pathPrefix?: string; target: { repoUrl?: string } }] + >; + expect(calls[0]?.[0]?.pathPrefix).toBe("lib/"); + expect(calls[0]?.[0]?.target.repoUrl).toBe( + "https://github.com/expressjs/express", + ); + writeSpy.mockRestore(); + }); + + it("rejects a second positional in --repo-url mode", async () => { + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + try { + await pkgFilesAction( + "lib/", + "extra", + { repoUrl: "https://github.com/x/y", gitRef: "main" }, + createDeps(), + ); + } catch { + /* expected */ + } + expect(errorSpy.mock.calls[0]?.[0]).toMatch(/--repo-url mode/); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it("emits the JSON envelope with --json", async () => { + const logSpy = spyOn(console, "log").mockImplementation(() => {}); + await pkgFilesAction( + "npm:express", + undefined, + { json: true }, + createDeps(), + ); + const payload = JSON.parse(logSpy.mock.calls[0]?.[0] as string); + expect(payload.registry).toBe("npm"); + expect(payload.name).toBe("express"); + expect(payload.total).toBe(2); + expect(payload.files[0].path).toBe("src/index.js"); + logSpy.mockRestore(); + }); + + it("sends waitTimeoutMs defaulting to DEFAULT_WAIT_TIMEOUT_MS (20000)", async () => { + const listFiles = mock(() => Promise.resolve(defaultListFilesResult)); + const service = createMockCodeNavigationService({ listFiles }); + const writeSpy = spyOn(process.stdout, "write").mockImplementation( + (() => true) as typeof process.stdout.write, + ); + await pkgFilesAction( + "npm:express", + undefined, + {}, + createDeps({ codeNavigationService: service }), + ); + const calls = listFiles.mock.calls as unknown as Array< + [{ waitTimeoutMs?: number }] + >; + expect(calls[0]?.[0]?.waitTimeoutMs).toBe(20000); + writeSpy.mockRestore(); + }); + + it("sends an explicit --wait value on the wire", async () => { + const listFiles = mock(() => Promise.resolve(defaultListFilesResult)); + const service = createMockCodeNavigationService({ listFiles }); + const writeSpy = spyOn(process.stdout, "write").mockImplementation( + (() => true) as typeof process.stdout.write, + ); + await pkgFilesAction( + "npm:express", + undefined, + { wait: "5000" }, + createDeps({ codeNavigationService: service }), + ); + const calls = listFiles.mock.calls as unknown as Array< + [{ waitTimeoutMs?: number }] + >; + expect(calls[0]?.[0]?.waitTimeoutMs).toBe(5000); + writeSpy.mockRestore(); + }); + + it("sends repo-url addressing when --repo-url + --git-ref are set", async () => { + const listFiles = mock(() => Promise.resolve(defaultListFilesResult)); + const service = createMockCodeNavigationService({ listFiles }); + const writeSpy = spyOn(process.stdout, "write").mockImplementation( + (() => true) as typeof process.stdout.write, + ); + await pkgFilesAction( + undefined, + undefined, + { + repoUrl: "https://github.com/expressjs/express", + gitRef: "main", + }, + createDeps({ codeNavigationService: service }), + ); + const calls = listFiles.mock.calls as unknown as Array< + [{ target: { registry?: string; repoUrl?: string; gitRef?: string } }] + >; + expect(calls[0]?.[0]?.target.registry).toBeUndefined(); + expect(calls[0]?.[0]?.target.repoUrl).toBe( + "https://github.com/expressjs/express", + ); + expect(calls[0]?.[0]?.target.gitRef).toBe("main"); + writeSpy.mockRestore(); + }); + + it("rejects spec + --repo-url together", async () => { + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + try { + await pkgFilesAction( + "npm:express", + undefined, + { repoUrl: "https://github.com/x/y", gitRef: "main" }, + createDeps(), + ); + } catch { + /* expected */ + } + expect(errorSpy.mock.calls[0]?.[0]).toMatch(/not both/); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it("rejects --repo-url without --git-ref", async () => { + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + try { + await pkgFilesAction( + undefined, + undefined, + { repoUrl: "https://github.com/x/y" }, + createDeps(), + ); + } catch { + /* expected */ + } + expect(errorSpy.mock.calls[0]?.[0]).toMatch(/--git-ref/); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it("rejects missing addressing entirely", async () => { + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + try { + await pkgFilesAction(undefined, undefined, {}, createDeps()); + } catch { + /* expected */ + } + expect(errorSpy.mock.calls[0]?.[0]).toMatch(/required/); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it("rejects --limit out of range", async () => { + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + try { + await pkgFilesAction( + "npm:express", + undefined, + { limit: "1001" }, + createDeps(), + ); + } catch { + /* expected */ + } + expect(errorSpy.mock.calls[0]?.[0]).toMatch(/1 and 1000/); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it("enriches INDEXING error with indexingRef + already-indexed versions", async () => { + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + const service = createMockCodeNavigationService({ + listFiles: mock(() => + Promise.reject( + new CodeNavigationIndexingError( + "Target is still indexing.", + "ref_xyz", + [ + { version: "4.21.0", ref: "v4.21.0" }, + { version: "4.20.1", ref: "v4.20.1" }, + ], + ), + ), + ), + }); + try { + await pkgFilesAction( + "npm:express", + undefined, + {}, + createDeps({ codeNavigationService: service }), + ); + } catch { + /* expected */ + } + const output = errorSpy.mock.calls[0]?.[0] as string; + expect(output).toContain("indexing"); + expect(output).toContain("indexingRef: ref_xyz"); + expect(output).toContain("already-indexed versions: 4.21.0, 4.20.1"); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it("routes NOT_FOUND through --json error envelope", async () => { + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + const service = createMockCodeNavigationService({ + listFiles: mock(() => + Promise.reject( + new CodeNavigationTargetNotFoundError("Package not found"), + ), + ), + }); + try { + await pkgFilesAction( + "npm:ghost", + undefined, + { json: true }, + createDeps({ codeNavigationService: service }), + ); + } catch { + /* expected */ + } + const payload = JSON.parse(errorSpy.mock.calls[0]?.[0] as string); + expect(payload.code).toBe("NOT_FOUND"); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }); +}); diff --git a/src/commands/code/files.ts b/src/commands/code/files.ts new file mode 100644 index 00000000..7890826c --- /dev/null +++ b/src/commands/code/files.ts @@ -0,0 +1,216 @@ +import type { Command } from "commander"; +import { createContainer } from "../../container.js"; +import type { CodeNavigationService } from "../../services/index.js"; +import { + DEFAULT_WAIT_TIMEOUT_MS, + MAX_WAIT_TIMEOUT_MS, +} from "../../shared/code-navigation-defaults.js"; +import { shouldUseColors } from "../../shared/colors.js"; +import { InvalidPackageSpecError, requireAuth } from "../../shared/index.js"; +import { buildListFilesParams } from "../../shared/list-files-request.js"; +import { + buildListFilesSuccessPayload, + formatListFilesTerminal, +} from "../../shared/list-files-response.js"; +import { toPkgseerRegistryLowercase } from "../../shared/pkgseer-registry.js"; +import { + formatIndexingError, + handleCodeNavCommandError, + parseIntCliOption, + resolveCliCodeNavTarget, +} from "./code-nav-cli-helpers.js"; + +export interface PkgFilesCommandOptions { + repoUrl?: string; + gitRef?: string; + limit?: string; + wait?: string; + verbose?: boolean; + json?: boolean; +} + +export interface PkgFilesCommandDependencies { + codeNavigationService: CodeNavigationService | undefined; + codeNavigationUrl: string | undefined; + hasValidToken: boolean; + mcpUrl: string; +} + +/** + * Core `code files` action. Positional order mirrors the sibling + * file-exploration commands: + * `code files [path-prefix]` + * `code files --repo-url --git-ref [path-prefix]` + * Commander binds left-to-right; we resolve (spec, path-prefix) from + * the two optional positionals based on whether repo-URL mode is + * active. + */ +export async function pkgFilesAction( + firstArg: string | undefined, + secondArg: string | undefined, + options: PkgFilesCommandOptions, + deps: PkgFilesCommandDependencies, +): Promise { + requireAuth(deps); + + try { + if (!deps.codeNavigationUrl || !deps.codeNavigationService) { + throw new InvalidPackageSpecError( + "Code navigation is not configured for this environment.", + ); + } + + const hasRepoUrl = Boolean(options.repoUrl); + const { spec, pathPrefix } = resolvePositionals( + firstArg, + secondArg, + hasRepoUrl, + ); + + const target = resolveCliCodeNavTarget(spec, options); + const limit = parseIntCliOption(options.limit, "--limit", 1, 1000); + const wait = parseIntCliOption( + options.wait, + "--wait", + 0, + MAX_WAIT_TIMEOUT_MS, + ); + + const build = buildListFilesParams({ + target, + pathPrefix, + limit, + waitTimeoutMs: wait, + }); + const result = await deps.codeNavigationService.listFiles(build.params); + + const payload = buildListFilesSuccessPayload(result, { + registry: target.registry + ? toPkgseerRegistryLowercase(target.registry) + : undefined, + name: target.packageName, + repoUrl: target.repoUrl, + gitRef: target.gitRef, + limitExplicit: build.limitExplicit, + pathPrefixExplicit: build.pathPrefixExplicit, + pathPrefix: build.params.pathPrefix, + limit: build.params.limit, + }); + + if (options.json) { + console.log(JSON.stringify(payload)); + return; + } + + const rendered = formatListFilesTerminal(payload, { + verbose: options.verbose ?? false, + useColors: shouldUseColors(), + }); + process.stdout.write(rendered.stdout); + if (rendered.stderr) process.stderr.write(rendered.stderr); + } catch (error) { + handleCodeNavCommandError( + error, + options.json ?? false, + formatIndexingError, + ); + } +} + +// Detects strings that look like `:` — used to flag +// a common user mistake where a package spec is passed together with +// --repo-url. We'd otherwise silently treat it as a (meaningless) +// path-prefix. +const REGISTRY_SPEC_HINT = + /^(npm|pypi|hex|crates|nuget|maven|zig|vcpkg|packagist):/i; + +function resolvePositionals( + firstArg: string | undefined, + secondArg: string | undefined, + hasRepoUrl: boolean, +): { spec: string | undefined; pathPrefix: string | undefined } { + if (hasRepoUrl) { + // Repo-URL mode: the package spec is replaced by --repo-url, so + // a single positional is the path-prefix. A second one is a + // user error. + if (secondArg !== undefined) { + throw new InvalidPackageSpecError( + "In --repo-url mode, pass only [path-prefix] — the package spec is replaced by --repo-url.", + ); + } + if (firstArg && REGISTRY_SPEC_HINT.test(firstArg)) { + throw new InvalidPackageSpecError( + `'${firstArg}' looks like a package spec. Provide either a package spec or \`--repo-url\` + \`--git-ref\`, not both.`, + ); + } + return { spec: undefined, pathPrefix: firstArg }; + } + return { spec: firstArg, pathPrefix: secondArg }; +} + +const PKG_FILES_DESCRIPTION = `List files in an indexed dependency. Default returns up to 200 +entries; pass [path-prefix] to scope to a directory and --limit to +fetch more. + +[path-prefix] is a literal directory prefix (e.g. \`src/\` or +\`lib/parser\`), NOT a glob — \`*.ts\` and similar patterns won't +match. File-type / extension filtering is not supported server-side. + +Addressing: (registry:name[@version]) OR --repo-url +--git-ref . Supported registries: npm, pypi, hex, crates, +vcpkg, zig, nuget, maven, packagist. + +By default each result is a bare path for easy piping; pass +--verbose to include language / file-type / size annotations. + +On an INDEXING response, the dependency is being indexed on-demand +— retry with a longer --wait (up to 60000 ms) or pick one of the +already-indexed versions surfaced in the error detail.`; + +export function registerCodeFilesCommand(pkgCommand: Command): Command { + return pkgCommand + .command("files") + .summary("List files in an indexed dependency") + .description(PKG_FILES_DESCRIPTION) + .argument( + "[arg1]", + "In spec mode: package spec (e.g. npm:express). In --repo-url mode: the path-prefix.", + ) + .argument( + "[arg2]", + "In spec mode: the path-prefix (literal directory, not a glob). Unused in --repo-url mode.", + ) + .option( + "--repo-url ", + "Repository URL addressing (requires --git-ref)", + ) + .option( + "--git-ref ", + "Tag, commit, branch, or HEAD. Required with --repo-url.", + ) + .option("--limit ", "Max entries (1-1000, default 200)") + .option( + "--wait ", + `Indexing wait timeout (0-${MAX_WAIT_TIMEOUT_MS}, default ${DEFAULT_WAIT_TIMEOUT_MS})`, + ) + .option( + "-v, --verbose", + "Annotate each path with language / file-type / byte size", + ) + .option("--json", "Emit the JSON envelope") + .action( + async ( + arg1: string | undefined, + arg2: string | undefined, + options: PkgFilesCommandOptions, + ) => { + const deps = await createContainer(); + await pkgFilesAction(arg1, arg2, options, { + codeNavigationService: deps.codeNavigationService, + codeNavigationUrl: deps.codeNavigationUrl, + hasValidToken: deps.hasValidToken, + mcpUrl: deps.mcpUrl, + }); + }, + ); +} diff --git a/src/commands/code/grep.test.ts b/src/commands/code/grep.test.ts new file mode 100644 index 00000000..bca1ef09 --- /dev/null +++ b/src/commands/code/grep.test.ts @@ -0,0 +1,598 @@ +import { describe, expect, it, mock, spyOn } from "bun:test"; +import { + CodeNavigationIndexingError, + CodeNavigationTargetNotFoundError, +} from "../../services/index.js"; +import { + createMockCodeNavigationService, + defaultGrepFileResult, +} from "../../services/test-helpers.js"; +import { type PkgGrepCommandDependencies, pkgGrepAction } from "./grep.js"; + +describe("pkgGrepAction", () => { + const mcpUrl = "https://mcp.githits.com"; + + function createDeps( + overrides: Partial = {}, + ): PkgGrepCommandDependencies { + return { + codeNavigationService: createMockCodeNavigationService(), + codeNavigationUrl: "https://pkgseer.dev", + hasValidToken: true, + mcpUrl, + ...overrides, + }; + } + + it("plain mode: emits matching line(s) only — no header, no gutter", async () => { + const writes: string[] = []; + const writeSpy = spyOn(process.stdout, "write").mockImplementation((( + chunk: string | Uint8Array, + ) => { + writes.push( + typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk), + ); + return true; + }) as typeof process.stdout.write); + + await pkgGrepAction( + "npm:express", + "middleware", + "src/index.js", + {}, + createDeps(), + ); + + const combined = writes.join(""); + expect(combined).not.toContain("express · npm"); + expect(combined).not.toContain("1 match in src/index.js"); + expect(combined).not.toMatch(/^>/m); + // `defaultGrepFileResult` has one match line — plain mode emits + // its content. + expect(combined.trim().length).toBeGreaterThan(0); + writeSpy.mockRestore(); + }); + + it("verbose mode: renders the full match block with header and `>` marker", async () => { + const writes: string[] = []; + const writeSpy = spyOn(process.stdout, "write").mockImplementation((( + chunk: string | Uint8Array, + ) => { + writes.push( + typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk), + ); + return true; + }) as typeof process.stdout.write); + + await pkgGrepAction( + "npm:express", + "middleware", + "src/index.js", + { verbose: true }, + createDeps(), + ); + + const combined = writes.join(""); + expect(combined).toContain("express · npm"); + expect(combined).toContain("1 match in src/index.js"); + expect(combined).toContain(">"); + writeSpy.mockRestore(); + }); + + it("default contextLines is 0 on the wire (matches-only)", async () => { + const grepFile = mock(() => Promise.resolve(defaultGrepFileResult)); + const writeSpy = spyOn(process.stdout, "write").mockImplementation( + (() => true) as typeof process.stdout.write, + ); + await pkgGrepAction( + "npm:express", + "middleware", + "src/index.js", + {}, + createDeps({ + codeNavigationService: createMockCodeNavigationService({ grepFile }), + }), + ); + const calls = grepFile.mock.calls as unknown as Array< + [{ contextLines?: number }] + >; + expect(calls[0]?.[0]?.contextLines).toBe(0); + writeSpy.mockRestore(); + }); + + it("emits the JSON envelope with --json", async () => { + const logSpy = spyOn(console, "log").mockImplementation(() => {}); + await pkgGrepAction( + "npm:express", + "middleware", + "src/index.js", + { json: true }, + createDeps(), + ); + const payload = JSON.parse(logSpy.mock.calls[0]?.[0] as string); + expect(payload.pattern).toBe("middleware"); + expect(payload.path).toBe("src/index.js"); + expect(payload.matches.length).toBe(1); + logSpy.mockRestore(); + }); + + it("sends wait default of 20000 on the wire", async () => { + const grepFile = mock(() => Promise.resolve(defaultGrepFileResult)); + const writeSpy = spyOn(process.stdout, "write").mockImplementation( + (() => true) as typeof process.stdout.write, + ); + await pkgGrepAction( + "npm:express", + "middleware", + "src/index.js", + {}, + createDeps({ + codeNavigationService: createMockCodeNavigationService({ grepFile }), + }), + ); + const calls = grepFile.mock.calls as unknown as Array< + [{ waitTimeoutMs?: number }] + >; + expect(calls[0]?.[0]?.waitTimeoutMs).toBe(20000); + writeSpy.mockRestore(); + }); + + it("sends repo-URL addressing with two positionals (pattern, path)", async () => { + const grepFile = mock(() => Promise.resolve(defaultGrepFileResult)); + const writeSpy = spyOn(process.stdout, "write").mockImplementation( + (() => true) as typeof process.stdout.write, + ); + await pkgGrepAction( + "middleware", + "src/index.js", + undefined, + { + repoUrl: "https://github.com/expressjs/express", + gitRef: "main", + }, + createDeps({ + codeNavigationService: createMockCodeNavigationService({ grepFile }), + }), + ); + const calls = grepFile.mock.calls as unknown as Array< + [{ target: { repoUrl?: string }; pattern: string; path: string }] + >; + expect(calls[0]?.[0]?.target.repoUrl).toBe( + "https://github.com/expressjs/express", + ); + expect(calls[0]?.[0]?.pattern).toBe("middleware"); + expect(calls[0]?.[0]?.path).toBe("src/index.js"); + writeSpy.mockRestore(); + }); + + it("rejects extra positional in repo-URL mode", async () => { + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + try { + await pkgGrepAction( + "middleware", + "src/index.js", + "extra-arg", + { + repoUrl: "https://github.com/expressjs/express", + gitRef: "main", + }, + createDeps(), + ); + } catch { + /* expected */ + } + expect(errorSpy.mock.calls[0]?.[0]).toMatch(/--repo-url mode/); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it("rejects missing pattern", async () => { + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + try { + await pkgGrepAction( + "npm:express", + undefined, + undefined, + {}, + createDeps(), + ); + } catch { + /* expected */ + } + expect(errorSpy.mock.calls[0]?.[0]).toMatch(/pattern/); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it("gives a targeted error when caller passes two positionals without --repo-url", async () => { + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + // `code grep middleware src/index.js` with no spec + no --repo-url. + // Commander binds these as first=middleware, second=src/index.js. + // The action must recognise this as a missing-spec mistake, not + // a "missing " mistake. + try { + await pkgGrepAction( + "middleware", + "src/index.js", + undefined, + {}, + createDeps(), + ); + } catch { + /* expected */ + } + const msg = errorSpy.mock.calls[0]?.[0] as string; + expect(msg).toContain("all three positionals are required"); + expect(msg).toContain("--repo-url"); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it("rejects missing path", async () => { + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + try { + await pkgGrepAction( + "npm:express", + "middleware", + undefined, + {}, + createDeps(), + ); + } catch { + /* expected */ + } + expect(errorSpy.mock.calls[0]?.[0]).toMatch(/path/); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it("rejects --context out of range", async () => { + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + try { + await pkgGrepAction( + "npm:express", + "middleware", + "src/index.js", + { context: "11" }, + createDeps(), + ); + } catch { + /* expected */ + } + expect(errorSpy.mock.calls[0]?.[0]).toMatch(/0 and 10/); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it("rejects --limit out of range", async () => { + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + try { + await pkgGrepAction( + "npm:express", + "middleware", + "src/index.js", + { limit: "201" }, + createDeps(), + ); + } catch { + /* expected */ + } + expect(errorSpy.mock.calls[0]?.[0]).toMatch(/1 and 200/); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it("routes NOT_FOUND with a code-files hint", async () => { + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + const service = createMockCodeNavigationService({ + grepFile: mock(() => + Promise.reject( + new CodeNavigationTargetNotFoundError("File not found in repository"), + ), + ), + }); + try { + await pkgGrepAction( + "npm:express", + "middleware", + "nope.js", + {}, + createDeps({ codeNavigationService: service }), + ); + } catch { + /* expected */ + } + const output = errorSpy.mock.calls[0]?.[0] as string; + expect(output).toContain("File not found"); + expect(output).toContain("code files"); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it("enriches INDEXING error", async () => { + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + const service = createMockCodeNavigationService({ + grepFile: mock(() => + Promise.reject( + new CodeNavigationIndexingError("Indexing...", "ref_abc"), + ), + ), + }); + try { + await pkgGrepAction( + "npm:express", + "middleware", + "src/index.js", + {}, + createDeps({ codeNavigationService: service }), + ); + } catch { + /* expected */ + } + expect(errorSpy.mock.calls[0]?.[0]).toContain("indexingRef: ref_abc"); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + // ------------------------------------------------------------------ + // Exit-code contract (grep-style: 0 match / 1 no-match / 2 error) + // ------------------------------------------------------------------ + + it("exits 0 when there is at least one match", async () => { + const writeSpy = spyOn(process.stdout, "write").mockImplementation( + (() => true) as typeof process.stdout.write, + ); + const exitSpy = spyOn(process, "exit").mockImplementation((() => { + return undefined as never; + }) as typeof process.exit); + await pkgGrepAction( + "npm:express", + "middleware", + "src/index.js", + {}, + createDeps(), + ); + // `defaultGrepFileResult` has one match — exit 0 means `process.exit` + // was not called at all (happy path returns normally). + expect(exitSpy.mock.calls.length).toBe(0); + writeSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it("exits 1 when there are zero matches (plain mode)", async () => { + const writeSpy = spyOn(process.stdout, "write").mockImplementation( + (() => true) as typeof process.stdout.write, + ); + const exitCalls: number[] = []; + // Intentionally non-throwing — `process.exit(1)` on zero matches + // is the last statement in the happy path, so letting the mock + // return keeps the action function's control flow clean. + const exitSpy = spyOn(process, "exit").mockImplementation((( + code?: number, + ) => { + exitCalls.push(code ?? 0); + return undefined as never; + }) as typeof process.exit); + const service = createMockCodeNavigationService({ + grepFile: mock(() => + Promise.resolve({ + matches: [], + totalMatches: 0, + hasMore: false, + filePath: "src/index.js", + language: "javascript", + }), + ), + }); + await pkgGrepAction( + "npm:express", + "nonexistent-pattern", + "src/index.js", + {}, + createDeps({ codeNavigationService: service }), + ); + expect(exitCalls).toEqual([1]); + writeSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it("exits 1 when there are zero matches (--json mode)", async () => { + const logSpy = spyOn(console, "log").mockImplementation(() => {}); + const exitCalls: number[] = []; + const exitSpy = spyOn(process, "exit").mockImplementation((( + code?: number, + ) => { + exitCalls.push(code ?? 0); + return undefined as never; + }) as typeof process.exit); + const service = createMockCodeNavigationService({ + grepFile: mock(() => + Promise.resolve({ + matches: [], + totalMatches: 0, + hasMore: false, + filePath: "src/index.js", + }), + ), + }); + await pkgGrepAction( + "npm:express", + "nonexistent", + "src/index.js", + { json: true }, + createDeps({ codeNavigationService: service }), + ); + // JSON is logged BEFORE the exit-1 fires, so callers can still + // parse it via `jq` even under `pipefail`. + expect(logSpy.mock.calls.length).toBe(1); + const payload = JSON.parse(logSpy.mock.calls[0]?.[0] as string); + expect(payload.totalMatches).toBe(0); + expect(exitCalls).toEqual([1]); + logSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it("exits 2 on error paths (distinct from 'no match' = 1)", async () => { + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitCalls: number[] = []; + const exitSpy = spyOn(process, "exit").mockImplementation((( + code?: number, + ) => { + exitCalls.push(code ?? 0); + throw new Error("process.exit"); + }) as typeof process.exit); + const service = createMockCodeNavigationService({ + grepFile: mock(() => + Promise.reject( + new CodeNavigationTargetNotFoundError("File not found in repository"), + ), + ), + }); + try { + await pkgGrepAction( + "npm:express", + "middleware", + "nope.js", + {}, + createDeps({ codeNavigationService: service }), + ); + } catch { + /* expected */ + } + expect(exitCalls).toEqual([2]); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + // ------------------------------------------------------------------ + // stdout vs stderr routing (plain mode) + // ------------------------------------------------------------------ + + it("plain mode hasMore: truncation warning goes to stderr, not stdout", async () => { + const stdoutWrites: string[] = []; + const stderrWrites: string[] = []; + const stdoutSpy = spyOn(process.stdout, "write").mockImplementation((( + chunk: string | Uint8Array, + ) => { + stdoutWrites.push( + typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk), + ); + return true; + }) as typeof process.stdout.write); + const stderrSpy = spyOn(process.stderr, "write").mockImplementation((( + chunk: string | Uint8Array, + ) => { + stderrWrites.push( + typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk), + ); + return true; + }) as typeof process.stderr.write); + const service = createMockCodeNavigationService({ + grepFile: mock(() => + Promise.resolve({ + matches: [ + { + lineNumber: 1, + lineContent: "export const foo = 1;", + }, + ], + totalMatches: 50, + hasMore: true, + filePath: "src/index.js", + }), + ), + }); + await pkgGrepAction( + "npm:express", + "foo", + "src/index.js", + {}, + createDeps({ codeNavigationService: service }), + ); + const stdout = stdoutWrites.join(""); + const stderr = stderrWrites.join(""); + expect(stdout).toContain("export const foo"); + expect(stdout).not.toContain("More matches available"); + expect(stderr).toContain("More matches available"); + stdoutSpy.mockRestore(); + stderrSpy.mockRestore(); + }); + + it("plain mode zero-match with regex-shaped pattern: nudge goes to stderr", async () => { + const stdoutWrites: string[] = []; + const stderrWrites: string[] = []; + const stdoutSpy = spyOn(process.stdout, "write").mockImplementation((( + chunk: string | Uint8Array, + ) => { + stdoutWrites.push( + typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk), + ); + return true; + }) as typeof process.stdout.write); + const stderrSpy = spyOn(process.stderr, "write").mockImplementation((( + chunk: string | Uint8Array, + ) => { + stderrWrites.push( + typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk), + ); + return true; + }) as typeof process.stderr.write); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + const service = createMockCodeNavigationService({ + grepFile: mock(() => + Promise.resolve({ + matches: [], + totalMatches: 0, + hasMore: false, + filePath: "src/index.js", + }), + ), + }); + try { + await pkgGrepAction( + "npm:express", + "\\bfoo\\b", + "src/index.js", + {}, + createDeps({ codeNavigationService: service }), + ); + } catch { + /* expected — exit 1 */ + } + const stdout = stdoutWrites.join(""); + const stderr = stderrWrites.join(""); + // Plain mode stdout stays empty on zero matches. + expect(stdout).toBe(""); + // Regex-hint nudge visible on stderr. + expect(stderr).toContain("substring"); + stdoutSpy.mockRestore(); + stderrSpy.mockRestore(); + exitSpy.mockRestore(); + }); +}); diff --git a/src/commands/code/grep.ts b/src/commands/code/grep.ts new file mode 100644 index 00000000..355a1e3d --- /dev/null +++ b/src/commands/code/grep.ts @@ -0,0 +1,250 @@ +import type { Command } from "commander"; +import { createContainer } from "../../container.js"; +import type { CodeNavigationService } from "../../services/index.js"; +import { + DEFAULT_WAIT_TIMEOUT_MS, + MAX_WAIT_TIMEOUT_MS, +} from "../../shared/code-navigation-defaults.js"; +import { shouldUseColors } from "../../shared/colors.js"; +import { + buildGrepFileParams, + GREP_PATTERN_SEMANTICS_NOTE, +} from "../../shared/grep-file-request.js"; +import { + buildGrepFileSuccessPayload, + formatGrepFileTerminal, +} from "../../shared/grep-file-response.js"; +import { InvalidPackageSpecError, requireAuth } from "../../shared/index.js"; +import { toPkgseerRegistryLowercase } from "../../shared/pkgseer-registry.js"; +import { + formatFileErrorWithFilesHint, + handleCodeNavCommandError, + parseIntCliOption, + resolveCliCodeNavTarget, +} from "./code-nav-cli-helpers.js"; + +export interface PkgGrepCommandOptions { + repoUrl?: string; + gitRef?: string; + context?: string; + limit?: string; + wait?: string; + verbose?: boolean; + json?: boolean; +} + +export interface PkgGrepCommandDependencies { + codeNavigationService: CodeNavigationService | undefined; + codeNavigationUrl: string | undefined; + hasValidToken: boolean; + mcpUrl: string; +} + +/** + * Core `code grep` action. Addressing: `` or + * `--repo-url --git-ref `. Positional order: + * ` ` in spec mode (after the spec); just + * ` ` in repo-URL mode. Commander binds left-to- + * right so we resolve the three positionals with context. + */ +export async function pkgGrepAction( + first: string | undefined, + second: string | undefined, + third: string | undefined, + options: PkgGrepCommandOptions, + deps: PkgGrepCommandDependencies, +): Promise { + requireAuth(deps); + + try { + if (!deps.codeNavigationUrl || !deps.codeNavigationService) { + throw new InvalidPackageSpecError( + "Code navigation is not configured for this environment.", + ); + } + + const hasRepoUrl = Boolean(options.repoUrl); + const { spec, pattern, path } = resolvePositionals( + first, + second, + third, + hasRepoUrl, + ); + if (!pattern || pattern.length === 0) { + throw new InvalidPackageSpecError( + "A argument is required — pass the substring to search for.", + ); + } + if (!path || path.trim().length === 0) { + throw new InvalidPackageSpecError( + "A argument is required — pass the path to the file within the package or repo.", + ); + } + + const target = resolveCliCodeNavTarget(spec, options); + const contextLines = parseIntCliOption(options.context, "--context", 0, 10); + const maxMatches = parseIntCliOption(options.limit, "--limit", 1, 200); + const wait = parseIntCliOption( + options.wait, + "--wait", + 0, + MAX_WAIT_TIMEOUT_MS, + ); + + const build = buildGrepFileParams({ + target, + path, + pattern, + contextLines, + maxMatches, + waitTimeoutMs: wait, + }); + const result = await deps.codeNavigationService.grepFile(build.params); + + const payload = buildGrepFileSuccessPayload(result, { + registry: target.registry + ? toPkgseerRegistryLowercase(target.registry) + : undefined, + name: target.packageName, + repoUrl: target.repoUrl, + gitRef: target.gitRef, + pattern: build.params.pattern, + path: build.params.path, + contextLinesExplicit: build.contextLinesExplicit, + maxMatchesExplicit: build.maxMatchesExplicit, + contextLines: build.params.contextLines ?? 0, + maxMatches: build.params.maxMatches ?? 50, + }); + + if (options.json) { + console.log(JSON.stringify(payload)); + // `grep` convention: exit 1 when no match, 0 when ≥1 match. + // `--json` still honours this so scripting stays consistent + // across `--json` and plain callers. + if (payload.totalMatches === 0) process.exit(1); + return; + } + + const rendered = formatGrepFileTerminal(payload, { + useColors: shouldUseColors(), + verbose: options.verbose ?? false, + }); + process.stdout.write(rendered.stdout); + if (rendered.stderr) process.stderr.write(rendered.stderr); + if (payload.totalMatches === 0) process.exit(1); + } catch (error) { + // `grep` uses exit 2 for errors (distinct from "no match" = + // exit 1). Keeps `if code grep X file; then …` scripts + // correctly classifying missing-file / indexing errors. + handleCodeNavCommandError( + error, + options.json ?? false, + formatFileErrorWithFilesHint, + 2, + ); + } +} + +function resolvePositionals( + first: string | undefined, + second: string | undefined, + third: string | undefined, + hasRepoUrl: boolean, +): { + spec: string | undefined; + pattern: string | undefined; + path: string | undefined; +} { + if (hasRepoUrl) { + // In repo-URL mode: ` `. Third positional is + // a user error. + if (third !== undefined) { + throw new InvalidPackageSpecError( + "In --repo-url mode, pass only — the package spec is replaced by --repo-url.", + ); + } + return { spec: undefined, pattern: first, path: second }; + } + // Spec mode: ` `. Pre-check the positional + // count so users who forget the spec see a targeted error + // rather than the generic " is required" from later in the + // action. Two args + no --repo-url is almost always "I forgot + // the spec" or "I meant to use --repo-url". + if (first !== undefined && second !== undefined && third === undefined) { + throw new InvalidPackageSpecError( + "In spec mode, all three positionals are required: . If you meant to target a repository instead, pass --repo-url --git-ref .", + ); + } + return { spec: first, pattern: second, path: third }; +} + +const PKG_GREP_DESCRIPTION = `Search within a single file for a substring match. + +${GREP_PATTERN_SEMANTICS_NOTE} +For symbol-shaped searches, use \`githits code search\`. + +Addressing: (registry:name[@version]) OR --repo-url +--git-ref . In spec mode pass ; in +repo-URL mode pass only . + +Default output is matching lines only (no line numbers, no +context) — same shape as \`grep\`, pipe-friendly. Use --context + to include surrounding lines (0–10, default 0); nearby +matches with overlapping context merge into a single block. +Pass --verbose for a header, line-number gutter, and a \`>\` +marker on match lines. --limit caps the number of matches +(1–200, default 50).`; + +export function registerCodeGrepCommand(pkgCommand: Command): Command { + return pkgCommand + .command("grep") + .summary("Search within a file in an indexed dependency") + .description(PKG_GREP_DESCRIPTION) + .argument( + "[arg1]", + "In spec mode: package spec (e.g. npm:express). In --repo-url mode: the pattern.", + ) + .argument( + "[arg2]", + "In spec mode: the pattern. In --repo-url mode: the path.", + ) + .argument("[arg3]", "In spec mode: the path. Unused in --repo-url mode.") + .option( + "--repo-url ", + "Repository URL addressing (requires --git-ref)", + ) + .option( + "--git-ref ", + "Tag, commit, branch, or HEAD. Required with --repo-url.", + ) + .option( + "--context ", + "Context lines before and after each match (0-10, default 0). Nearby blocks merge — no duplicated lines.", + ) + .option("--limit ", "Max matches to return (1-200, default 50)") + .option( + "--wait ", + `Indexing wait timeout (0-${MAX_WAIT_TIMEOUT_MS}, default ${DEFAULT_WAIT_TIMEOUT_MS})`, + ) + .option( + "-v, --verbose", + "Render a header and a line-number gutter alongside the matches", + ) + .option("--json", "Emit the JSON envelope") + .action( + async ( + arg1: string | undefined, + arg2: string | undefined, + arg3: string | undefined, + options: PkgGrepCommandOptions, + ) => { + const deps = await createContainer(); + await pkgGrepAction(arg1, arg2, arg3, options, { + codeNavigationService: deps.codeNavigationService, + codeNavigationUrl: deps.codeNavigationUrl, + hasValidToken: deps.hasValidToken, + mcpUrl: deps.mcpUrl, + }); + }, + ); +} diff --git a/src/commands/code/index.ts b/src/commands/code/index.ts index b773215d..753be229 100644 --- a/src/commands/code/index.ts +++ b/src/commands/code/index.ts @@ -6,6 +6,9 @@ import { getEnvApiToken, isCodeNavigationCliOverrideEnabled, } from "../../services/index.js"; +import { registerCodeFilesCommand } from "./files.js"; +import { registerCodeGrepCommand } from "./grep.js"; +import { registerCodeReadCommand } from "./read.js"; import { registerCodeSearchSymbolsCommand } from "./search-symbols.js"; export interface CodeCommandGroupOptions { @@ -54,10 +57,13 @@ export async function registerCodeCommandGroup( const codeCommand = program .command("code") - .summary("Search indexed dependency source code") + .summary("Source-level operations on indexed dependencies") .description( - "Code-navigation commands for searching indexed dependency source. Requires the `code_navigation` capability on the active account.", + "Search, list, read, and grep inside indexed dependency source code. Every command accepts either `` (registry:name[@version]) or `--repo-url --git-ref `. For package-level metadata (versions, vulnerabilities, dependencies, changelog) use `githits pkg`.", ); registerCodeSearchSymbolsCommand(codeCommand); + registerCodeFilesCommand(codeCommand); + registerCodeReadCommand(codeCommand); + registerCodeGrepCommand(codeCommand); } diff --git a/src/commands/code/read.test.ts b/src/commands/code/read.test.ts new file mode 100644 index 00000000..2449ceff --- /dev/null +++ b/src/commands/code/read.test.ts @@ -0,0 +1,387 @@ +import { describe, expect, it, mock, spyOn } from "bun:test"; +import { + CodeNavigationFileNotFoundError, + CodeNavigationIndexingError, +} from "../../services/index.js"; +import { + createMockCodeNavigationService, + defaultReadFileResult, +} from "../../services/test-helpers.js"; +import { type PkgReadCommandDependencies, pkgReadAction } from "./read.js"; + +describe("pkgReadAction", () => { + const mcpUrl = "https://mcp.githits.com"; + + function createDeps( + overrides: Partial = {}, + ): PkgReadCommandDependencies { + return { + codeNavigationService: createMockCodeNavigationService(), + codeNavigationUrl: "https://pkgseer.dev", + hasValidToken: true, + mcpUrl, + ...overrides, + }; + } + + it("plain mode: emits raw content only — no header, no gutter", async () => { + const writes: string[] = []; + const writeSpy = spyOn(process.stdout, "write").mockImplementation((( + chunk: string | Uint8Array, + ) => { + writes.push( + typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk), + ); + return true; + }) as typeof process.stdout.write); + + await pkgReadAction("npm:express", "src/index.js", {}, createDeps()); + + const combined = writes.join(""); + expect(combined).toContain("// Express entry point"); + // Plain mode excludes the contextual header and the gutter. + expect(combined).not.toContain("src/index.js · javascript"); + expect(combined).not.toMatch(/^\s*1\s+\/\//m); + writeSpy.mockRestore(); + }); + + it("verbose mode: adds the header and line-number gutter", async () => { + const writes: string[] = []; + const writeSpy = spyOn(process.stdout, "write").mockImplementation((( + chunk: string | Uint8Array, + ) => { + writes.push( + typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk), + ); + return true; + }) as typeof process.stdout.write); + + await pkgReadAction( + "npm:express", + "src/index.js", + { verbose: true }, + createDeps(), + ); + + const combined = writes.join(""); + expect(combined).toContain("src/index.js · javascript"); + expect(combined).toContain("1 // Express entry point"); + writeSpy.mockRestore(); + }); + + it("emits the JSON envelope with --json", async () => { + const logSpy = spyOn(console, "log").mockImplementation(() => {}); + await pkgReadAction( + "npm:express", + "src/index.js", + { json: true }, + createDeps(), + ); + const payload = JSON.parse(logSpy.mock.calls[0]?.[0] as string); + expect(payload.path).toBe("src/index.js"); + expect(payload.content).toContain("Express entry point"); + logSpy.mockRestore(); + }); + + it("sends wait default of 20000 on the wire", async () => { + const readFile = mock(() => Promise.resolve(defaultReadFileResult)); + const service = createMockCodeNavigationService({ readFile }); + const writeSpy = spyOn(process.stdout, "write").mockImplementation( + (() => true) as typeof process.stdout.write, + ); + await pkgReadAction( + "npm:express", + "src/index.js", + {}, + createDeps({ codeNavigationService: service }), + ); + const calls = readFile.mock.calls as unknown as Array< + [{ waitTimeoutMs?: number }] + >; + expect(calls[0]?.[0]?.waitTimeoutMs).toBe(20000); + writeSpy.mockRestore(); + }); + + it("sends repo-url addressing — single positional as path", async () => { + const readFile = mock(() => Promise.resolve(defaultReadFileResult)); + const service = createMockCodeNavigationService({ readFile }); + const writeSpy = spyOn(process.stdout, "write").mockImplementation( + (() => true) as typeof process.stdout.write, + ); + // Commander binds the single positional to the first argument. + // The action must recognise repo-URL mode and treat it as the path. + await pkgReadAction( + "src/index.js", + undefined, + { + repoUrl: "https://github.com/expressjs/express", + gitRef: "main", + }, + createDeps({ codeNavigationService: service }), + ); + const calls = readFile.mock.calls as unknown as Array< + [{ target: { registry?: string; repoUrl?: string; gitRef?: string } }] + >; + expect(calls[0]?.[0]?.target.registry).toBeUndefined(); + expect(calls[0]?.[0]?.target.repoUrl).toBe( + "https://github.com/expressjs/express", + ); + writeSpy.mockRestore(); + }); + + it("sends start/end from --start --end", async () => { + const readFile = mock(() => Promise.resolve(defaultReadFileResult)); + const writeSpy = spyOn(process.stdout, "write").mockImplementation( + (() => true) as typeof process.stdout.write, + ); + await pkgReadAction( + "npm:express", + "src/index.js", + { start: "10", end: "40" }, + createDeps({ + codeNavigationService: createMockCodeNavigationService({ readFile }), + }), + ); + const calls = readFile.mock.calls as unknown as Array< + [{ startLine?: number; endLine?: number }] + >; + expect(calls[0]?.[0]?.startLine).toBe(10); + expect(calls[0]?.[0]?.endLine).toBe(40); + writeSpy.mockRestore(); + }); + + it.each([ + ["10-40", 10, 40], + ["10-", 10, undefined], + ["-40", 1, 40], + ])("parses --lines '%s' into start=%s end=%s", async (lines, expectedStart, expectedEnd) => { + const readFile = mock(() => Promise.resolve(defaultReadFileResult)); + const writeSpy = spyOn(process.stdout, "write").mockImplementation( + (() => true) as typeof process.stdout.write, + ); + await pkgReadAction( + "npm:express", + "src/index.js", + { lines }, + createDeps({ + codeNavigationService: createMockCodeNavigationService({ readFile }), + }), + ); + const calls = readFile.mock.calls as unknown as Array< + [{ startLine?: number; endLine?: number }] + >; + expect(calls[0]?.[0]?.startLine).toBe(expectedStart); + expect(calls[0]?.[0]?.endLine).toBe(expectedEnd); + writeSpy.mockRestore(); + }); + + it.each([ + "10", // single line — ambiguous + "40-10", // reversed + "abc", // non-numeric + "0-5", // zero isn't 1-indexed + "-", // bare dash — no bounds + ])("rejects --lines '%s'", async (lines) => { + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + try { + await pkgReadAction( + "npm:express", + "src/index.js", + { lines }, + createDeps(), + ); + } catch { + /* expected */ + } + expect(errorSpy.mock.calls.length).toBeGreaterThan(0); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it("rejects --lines combined with --start/--end", async () => { + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + try { + await pkgReadAction( + "npm:express", + "src/index.js", + { lines: "10-40", start: "10" }, + createDeps(), + ); + } catch { + /* expected */ + } + expect(errorSpy.mock.calls[0]?.[0]).toMatch(/Pick one/); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it("rejects extra positional in repo-URL mode", async () => { + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + try { + await pkgReadAction( + "src/index.js", + "unexpected.js", + { + repoUrl: "https://github.com/expressjs/express", + gitRef: "main", + }, + createDeps(), + ); + } catch { + /* expected */ + } + expect(errorSpy.mock.calls[0]?.[0]).toMatch(/--repo-url mode/); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it("rejects missing ", async () => { + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + try { + await pkgReadAction("npm:express", undefined, {}, createDeps()); + } catch { + /* expected */ + } + expect(errorSpy.mock.calls[0]?.[0]).toMatch(/path/); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it("renders the binary sentinel via stdout.write", async () => { + const writes: string[] = []; + const writeSpy = spyOn(process.stdout, "write").mockImplementation((( + chunk: string | Uint8Array, + ) => { + writes.push( + typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk), + ); + return true; + }) as typeof process.stdout.write); + const service = createMockCodeNavigationService({ + readFile: mock(() => + Promise.resolve({ + filePath: "assets/logo.png", + isBinary: true, + }), + ), + }); + await pkgReadAction( + "npm:express", + "assets/logo.png", + {}, + createDeps({ codeNavigationService: service }), + ); + const combined = writes.join(""); + // Plain-mode binary output: sentinel only (no header). + expect(combined).toContain("Binary file — cannot display as text."); + expect(combined).not.toContain("assets/logo.png"); + writeSpy.mockRestore(); + }); + + it("routes NOT_FOUND on missing path with a code-files hint (backend currently emits NOT_FOUND, not FILE_NOT_FOUND)", async () => { + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + const { CodeNavigationTargetNotFoundError } = await import( + "../../services/index.js" + ); + const service = createMockCodeNavigationService({ + readFile: mock(() => + Promise.reject( + new CodeNavigationTargetNotFoundError("File not found in repository"), + ), + ), + }); + try { + await pkgReadAction( + "npm:express", + "nope.js", + {}, + createDeps({ codeNavigationService: service }), + ); + } catch { + /* expected */ + } + const output = errorSpy.mock.calls[0]?.[0] as string; + expect(output).toContain("File not found"); + expect(output).toContain("code files"); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it("routes FILE_NOT_FOUND with a code-files hint", async () => { + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + const service = createMockCodeNavigationService({ + readFile: mock(() => + Promise.reject( + new CodeNavigationFileNotFoundError( + "File not found: nope.js", + "nope.js", + ), + ), + ), + }); + try { + await pkgReadAction( + "npm:express", + "nope.js", + {}, + createDeps({ codeNavigationService: service }), + ); + } catch { + /* expected */ + } + const output = errorSpy.mock.calls[0]?.[0] as string; + expect(output).toContain("File not found"); + expect(output).toContain("code files"); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it("enriches INDEXING error", async () => { + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + const service = createMockCodeNavigationService({ + readFile: mock(() => + Promise.reject( + new CodeNavigationIndexingError("Indexing...", "ref_xyz", [ + { version: "4.21.0", ref: "v4.21.0" }, + ]), + ), + ), + }); + try { + await pkgReadAction( + "npm:express", + "src/index.js", + {}, + createDeps({ codeNavigationService: service }), + ); + } catch { + /* expected */ + } + const output = errorSpy.mock.calls[0]?.[0] as string; + expect(output).toContain("indexingRef: ref_xyz"); + expect(output).toContain("already-indexed versions: 4.21.0"); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }); +}); diff --git a/src/commands/code/read.ts b/src/commands/code/read.ts new file mode 100644 index 00000000..402930a2 --- /dev/null +++ b/src/commands/code/read.ts @@ -0,0 +1,316 @@ +import type { Command } from "commander"; +import { createContainer } from "../../container.js"; +import type { CodeNavigationService } from "../../services/index.js"; +import { + DEFAULT_WAIT_TIMEOUT_MS, + MAX_WAIT_TIMEOUT_MS, +} from "../../shared/code-navigation-defaults.js"; +import { shouldUseColors } from "../../shared/colors.js"; +import { InvalidPackageSpecError, requireAuth } from "../../shared/index.js"; +import { toPkgseerRegistryLowercase } from "../../shared/pkgseer-registry.js"; +import { buildReadFileParams } from "../../shared/read-file-request.js"; +import { + buildReadFileSuccessPayload, + formatReadFileTerminal, +} from "../../shared/read-file-response.js"; +import { + formatFileErrorWithFilesHint, + handleCodeNavCommandError, + parseIntCliOption, + resolveCliCodeNavTarget, +} from "./code-nav-cli-helpers.js"; + +export interface PkgReadCommandOptions { + repoUrl?: string; + gitRef?: string; + lines?: string; + start?: string; + end?: string; + wait?: string; + verbose?: boolean; + json?: boolean; +} + +export interface PkgReadCommandDependencies { + codeNavigationService: CodeNavigationService | undefined; + codeNavigationUrl: string | undefined; + hasValidToken: boolean; + mcpUrl: string; +} + +/** + * Core `code read` action. Accepts `` OR + * `--repo-url --git-ref ` (mutually exclusive) and a + * required `` positional. + * + * Line-range grammar: + * `--lines 10-40` → start=10, end=40 + * `--lines 10-` → start=10, end=EOF + * `--lines -40` → start=1, end=40 + * `--lines 10` → rejected (did you mean `--lines 10-` or `--start 10`?) + * `--lines 40-10` → rejected (reversed) + * `--start N --end M` → equivalent + * `--lines` combined with `--start`/`--end` → rejected + */ +export async function pkgReadAction( + firstArg: string | undefined, + secondArg: string | undefined, + options: PkgReadCommandOptions, + deps: PkgReadCommandDependencies, +): Promise { + requireAuth(deps); + + try { + if (!deps.codeNavigationUrl || !deps.codeNavigationService) { + throw new InvalidPackageSpecError( + "Code navigation is not configured for this environment.", + ); + } + + // Commander binds two optional positionals left-to-right. + // Resolve our (spec, path) pair based on whether repo-URL mode + // is active: + // `code read ` → firstArg=spec, secondArg=path + // `code read --repo-url X --git-ref Y ` + // → firstArg=path, secondArg=undefined + const hasRepoUrl = Boolean(options.repoUrl); + const { spec, path } = resolvePositionals(firstArg, secondArg, hasRepoUrl); + if (!path || path.trim().length === 0) { + throw new InvalidPackageSpecError( + "A argument is required — pass the path to the file within the package or repo.", + ); + } + + const target = resolveCliCodeNavTarget(spec, options); + const range = resolveLineRange(options); + const wait = parseIntCliOption( + options.wait, + "--wait", + 0, + MAX_WAIT_TIMEOUT_MS, + ); + + const build = buildReadFileParams({ + target, + filePath: path, + startLine: range.startLine, + endLine: range.endLine, + waitTimeoutMs: wait, + }); + const result = await deps.codeNavigationService.readFile(build.params); + + const payload = buildReadFileSuccessPayload(result, { + registry: target.registry + ? toPkgseerRegistryLowercase(target.registry) + : undefined, + name: target.packageName, + repoUrl: target.repoUrl, + gitRef: target.gitRef, + requestedFilePath: build.params.filePath, + }); + + if (options.json) { + console.log(JSON.stringify(payload)); + return; + } + + process.stdout.write( + formatReadFileTerminal(payload, { + useColors: shouldUseColors(), + verbose: options.verbose ?? false, + }), + ); + } catch (error) { + handleCodeNavCommandError( + error, + options.json ?? false, + formatFileErrorWithFilesHint, + ); + } +} + +function resolvePositionals( + firstArg: string | undefined, + secondArg: string | undefined, + hasRepoUrl: boolean, +): { spec: string | undefined; path: string | undefined } { + if (hasRepoUrl) { + // In repo-URL mode the package spec doesn't apply. A single + // positional is the path; a second one is an error. + if (secondArg !== undefined) { + throw new InvalidPackageSpecError( + "In --repo-url mode, pass only the positional — the package spec is replaced by --repo-url.", + ); + } + return { spec: undefined, path: firstArg }; + } + // Spec mode: require both positionals to avoid Commander's + // left-bind-ambiguous single-positional case. + return { spec: firstArg, path: secondArg }; +} + +interface LineRange { + startLine?: number; + endLine?: number; +} + +function resolveLineRange(options: PkgReadCommandOptions): LineRange { + const hasLines = Boolean(options.lines); + const hasStart = Boolean(options.start); + const hasEnd = Boolean(options.end); + + if (hasLines && (hasStart || hasEnd)) { + throw new InvalidPackageSpecError( + "--lines is the concise form — don't combine it with --start / --end. Pick one.", + ); + } + + if (hasLines) { + return parseLinesOption(options.lines as string); + } + + return { + startLine: parseIntCliOption( + options.start, + "--start", + 1, + Number.MAX_SAFE_INTEGER, + ), + endLine: parseIntCliOption( + options.end, + "--end", + 1, + Number.MAX_SAFE_INTEGER, + ), + }; +} + +/** + * Parse the `--lines` concise form. Grammar pinned to: + * `"N-M"` → start=N, end=M (both integers) + * `"N-"` → start=N, end=EOF + * `"-M"` → start=1, end=M + * Anything else rejects with a hint. + */ +function parseLinesOption(raw: string): LineRange { + const trimmed = raw.trim(); + const dashIndex = trimmed.indexOf("-"); + if (dashIndex < 0) { + throw new InvalidPackageSpecError( + `--lines expects a range like \`10-40\`, \`10-\`, or \`-40\`. Single-line form isn't accepted — use --start ${trimmed}.`, + ); + } + + const startRaw = trimmed.slice(0, dashIndex).trim(); + const endRaw = trimmed.slice(dashIndex + 1).trim(); + + if (startRaw.length === 0 && endRaw.length === 0) { + throw new InvalidPackageSpecError( + "--lines requires at least one bound. Use `10-40`, `10-` for open end, or `-40` for open start.", + ); + } + + const startLine = + startRaw.length > 0 + ? requirePositiveInteger(startRaw, "--lines start") + : undefined; + const endLine = + endRaw.length > 0 + ? requirePositiveInteger(endRaw, "--lines end") + : undefined; + + if (startLine !== undefined && endLine !== undefined && startLine > endLine) { + throw new InvalidPackageSpecError( + `--lines range is reversed: ${startLine} > ${endLine}.`, + ); + } + + if (startLine === undefined && endLine !== undefined) { + return { startLine: 1, endLine }; + } + return { startLine, endLine }; +} + +function requirePositiveInteger(raw: string, label: string): number { + if (!/^\d+$/.test(raw)) { + throw new InvalidPackageSpecError( + `${label} must be a positive integer. Got '${raw}'.`, + ); + } + const parsed = Number.parseInt(raw, 10); + if (parsed < 1) { + throw new InvalidPackageSpecError( + `${label} must be ≥ 1 (lines are 1-indexed). Got ${parsed}.`, + ); + } + return parsed; +} + +const PKG_READ_DESCRIPTION = `Read a file from an indexed dependency. + +Default output is the raw file content — pipe-friendly for +downstream tools (\`code read … | grep …\`). Pass --verbose for a +header and a line-number gutter. + +Use --lines for a bounded range (e.g. \`--lines 10-40\`). + +Addressing: (registry:name[@version]) OR --repo-url +--git-ref . is package-relative for spec addressing, +repo-relative for --repo-url. + +Binary files show a one-line sentinel instead of content. When a +path is missing, the response is a FILE_NOT_FOUND error — use +\`code files\` to discover available paths.`; + +export function registerCodeReadCommand(pkgCommand: Command): Command { + return pkgCommand + .command("read") + .summary("Read a file from an indexed dependency") + .description(PKG_READ_DESCRIPTION) + .argument( + "[spec-or-path]", + "In spec mode: package spec (e.g. npm:express). In --repo-url mode: the file path. See examples in `--help`.", + ) + .argument( + "[path]", + "File path (spec mode only — in --repo-url mode use the first positional).", + ) + .option( + "--repo-url ", + "Repository URL addressing (requires --git-ref)", + ) + .option( + "--git-ref ", + "Tag, commit, branch, or HEAD. Required with --repo-url.", + ) + .option( + "--lines ", + "Line range (e.g. `10-40`, `10-` for open end, `-40` for open start)", + ) + .option("--start ", "Starting line (1-indexed). Alternative to --lines.") + .option("--end ", "Ending line (inclusive). Alternative to --lines.") + .option( + "--wait ", + `Indexing wait timeout (0-${MAX_WAIT_TIMEOUT_MS}, default ${DEFAULT_WAIT_TIMEOUT_MS})`, + ) + .option( + "-v, --verbose", + "Render a header and a line-number gutter alongside the content", + ) + .option("--json", "Emit the JSON envelope") + .action( + async ( + spec: string | undefined, + path: string | undefined, + options: PkgReadCommandOptions, + ) => { + const deps = await createContainer(); + await pkgReadAction(spec, path, options, { + codeNavigationService: deps.codeNavigationService, + codeNavigationUrl: deps.codeNavigationUrl, + hasValidToken: deps.hasValidToken, + mcpUrl: deps.mcpUrl, + }); + }, + ); +} diff --git a/src/commands/mcp-instructions.test.ts b/src/commands/mcp-instructions.test.ts index 7a6fc129..ff41a460 100644 --- a/src/commands/mcp-instructions.test.ts +++ b/src/commands/mcp-instructions.test.ts @@ -47,6 +47,9 @@ const KNOWN_TOOLS = [ "search_language", "feedback", "search_symbols", + "list_files", + "read_file", + "grep_file", "package_summary", "package_vulnerabilities", "package_dependencies", @@ -247,6 +250,9 @@ describe("buildMcpInstructions", () => { // appear in backtick form. const packageTools = [ "search_symbols", + "list_files", + "read_file", + "grep_file", "package_summary", "package_vulnerabilities", "package_dependencies", diff --git a/src/commands/mcp-instructions.ts b/src/commands/mcp-instructions.ts index cc5fba53..5e9c527e 100644 --- a/src/commands/mcp-instructions.ts +++ b/src/commands/mcp-instructions.ts @@ -38,6 +38,15 @@ const PACKAGE_CHANGELOG_BULLET = const SEARCH_SYMBOLS_BULLET = "- `search_symbols` — text search across a dependency's source. On an INDEXING response, retry with a larger `wait_timeout_ms` (up to 60000)."; +const LIST_FILES_BULLET = + "- `list_files` — discover what files a dependency ships. Use `path_prefix` to scope to a subdirectory; the response includes each file's language, type, and byte size. Same indexing-retry rules as `search_symbols`."; + +const READ_FILE_BULLET = + "- `read_file` — fetch a file's contents from a dependency. Default returns the full file; pass `start_line` / `end_line` for a bounded range. Binary files set `isBinary: true` and omit `content` — branch on the flag, not the null. A `FILE_NOT_FOUND` (or `NOT_FOUND`) response is the signal to call `list_files` for the actual path."; + +const GREP_FILE_BULLET = + "- `grep_file` — find a case-insensitive substring within a single file (not regex). Returns matches with context lines. Max pattern 200 chars, up to 200 matches with up to 10 context lines each. For symbol-shaped searches use `search_symbols`. Same addressing and indexing-retry rules as `list_files`."; + const SEARCH_VS_SYMBOLS_TIP = "Prefer `search` for natural-language example questions; prefer `search_symbols` for exact-token lookups inside a specific package."; @@ -86,6 +95,9 @@ export function buildMcpInstructions(deps: Dependencies): string { } if (deps.codeNavigationService) { bullets.push(SEARCH_SYMBOLS_BULLET); + bullets.push(LIST_FILES_BULLET); + bullets.push(READ_FILE_BULLET); + bullets.push(GREP_FILE_BULLET); } if (bullets.length === 0) { diff --git a/src/commands/mcp.test.ts b/src/commands/mcp.test.ts index 3cc5124f..288388db 100644 --- a/src/commands/mcp.test.ts +++ b/src/commands/mcp.test.ts @@ -74,6 +74,9 @@ describe("createMcpServer", () => { "search_language", "feedback", "search_symbols", + "list_files", + "read_file", + "grep_file", ]); }); diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index 48d22790..842cc3f1 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -6,10 +6,13 @@ import { createContainer, type Dependencies } from "../container.js"; import { dim, highlight, shouldUseColors } from "../shared/colors.js"; import { createFeedbackTool, + createGrepFileTool, + createListFilesTool, createPackageChangelogTool, createPackageDependenciesTool, createPackageSummaryTool, createPackageVulnerabilitiesTool, + createReadFileTool, createSearchLanguageTool, createSearchSymbolsTool, createSearchTool, @@ -36,6 +39,9 @@ export function getMcpToolDefinitions( if (gateOpen && deps.codeNavigationService) { tools.push(createSearchSymbolsTool(deps.codeNavigationService)); + tools.push(createListFilesTool(deps.codeNavigationService)); + tools.push(createReadFileTool(deps.codeNavigationService)); + tools.push(createGrepFileTool(deps.codeNavigationService)); } if (gateOpen && deps.packageIntelligenceService) { diff --git a/src/commands/pkg/index.ts b/src/commands/pkg/index.ts index 570b9fc0..807ce6be 100644 --- a/src/commands/pkg/index.ts +++ b/src/commands/pkg/index.ts @@ -68,9 +68,9 @@ export async function registerPkgCommandGroup( const pkgCommand = program .command("pkg") - .summary("Package metadata, security, and docs") + .summary("Package metadata: info, vulnerabilities, dependencies, changelog") .description( - "Inspect registry metadata, known vulnerabilities, and documentation for packages from npm, PyPI, Hex, Crates, NuGet, Maven, Packagist, vcpkg, and Zig.", + "Inspect registry metadata for packages from npm, PyPI, Hex, Crates, NuGet, Maven, Packagist, vcpkg, and Zig. For source-level operations (list files, read file, grep inside a file) use `githits code`.", ); registerPkgInfoCommand(pkgCommand); diff --git a/src/services/code-navigation-service.test.ts b/src/services/code-navigation-service.test.ts index c0e3d103..c97af25c 100644 --- a/src/services/code-navigation-service.test.ts +++ b/src/services/code-navigation-service.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; import { CodeNavigationBackendError, + CodeNavigationFileNotFoundError, CodeNavigationIndexingError, CodeNavigationNetworkError, CodeNavigationServiceImpl, @@ -725,4 +726,470 @@ describe("CodeNavigationServiceImpl", () => { expect(body.query).toContain("mode: DETAILED"); expect(body.query).not.toContain("@include(if: $verbose)"); }); + + // ------------------------------------------------------------------ + // listFiles + // ------------------------------------------------------------------ + + it("normalises a successful listRepoFiles response", async () => { + mockFetch(() => + Promise.resolve( + new Response( + JSON.stringify({ + data: { + listRepoFiles: { + files: [ + { + path: "src/index.js", + name: "index.js", + language: "javascript", + fileType: "SOURCE", + byteSize: 1234, + }, + { path: "src/only-path.txt" }, + ], + total: 2, + hasMore: false, + indexedVersion: "v5.2.1", + resolution: { + resolvedRef: "v5.2.1", + commitSha: "abc123", + }, + diagnostics: null, + indexingStatus: "INDEXED", + }, + }, + }), + { status: 200 }, + ), + ), + ); + const service = new CodeNavigationServiceImpl( + BASE_URL, + createMockTokenProvider(), + ); + + const result = await service.listFiles({ + target: { registry: "NPM", packageName: "express" }, + }); + + expect(result.files.length).toBe(2); + expect(result.files[0]).toEqual({ + path: "src/index.js", + name: "index.js", + language: "javascript", + fileType: "SOURCE", + byteSize: 1234, + }); + // null fields are stripped — second entry carries only `path`. + expect(result.files[1]).toEqual({ path: "src/only-path.txt" }); + expect(result.total).toBe(2); + expect(result.hasMore).toBe(false); + expect(result.indexedVersion).toBe("v5.2.1"); + expect(result.resolution?.resolvedRef).toBe("v5.2.1"); + }); + + it("throws CodeNavigationIndexingError for data-path INDEXING sentinel on listFiles", async () => { + mockFetch(() => + Promise.resolve( + new Response( + JSON.stringify({ + data: { + listRepoFiles: { + files: [], + total: 0, + hasMore: false, + indexedVersion: null, + resolution: null, + diagnostics: null, + indexingStatus: "INDEXING", + indexingRef: "ref_xyz", + availableVersions: [{ version: "4.21.0", ref: "v4.21.0" }], + }, + }, + }), + { status: 200 }, + ), + ), + ); + const service = new CodeNavigationServiceImpl( + BASE_URL, + createMockTokenProvider(), + ); + + try { + await service.listFiles({ + target: { registry: "NPM", packageName: "express" }, + }); + throw new Error("expected listFiles to throw"); + } catch (error) { + expect(error).toBeInstanceOf(CodeNavigationIndexingError); + const typed = error as CodeNavigationIndexingError; + expect(typed.indexingRef).toBe("ref_xyz"); + expect(typed.availableVersions).toEqual([ + { version: "4.21.0", ref: "v4.21.0" }, + ]); + } + }); + + it("surfaces diagnostics.hint on listFiles empty responses", async () => { + mockFetch(() => + Promise.resolve( + new Response( + JSON.stringify({ + data: { + listRepoFiles: { + files: [], + total: 0, + hasMore: false, + indexedVersion: "v5.2.1", + resolution: null, + diagnostics: { hint: "No files match that prefix." }, + indexingStatus: "INDEXED", + }, + }, + }), + { status: 200 }, + ), + ), + ); + const service = new CodeNavigationServiceImpl( + BASE_URL, + createMockTokenProvider(), + ); + + const result = await service.listFiles({ + target: { registry: "NPM", packageName: "express" }, + pathPrefix: "no-such-dir/", + }); + + expect(result.files).toEqual([]); + expect(result.hint).toBe("No files match that prefix."); + }); + + // ------------------------------------------------------------------ + // readFile + // ------------------------------------------------------------------ + + it("normalises a successful fetchCodeContext response", async () => { + mockFetch(() => + Promise.resolve( + new Response( + JSON.stringify({ + data: { + fetchCodeContext: { + content: "// hello\nconsole.log('hi');\n", + filePath: "src/hello.js", + language: "javascript", + totalLines: 2, + startLine: 1, + endLine: 2, + isBinary: false, + indexingStatus: "INDEXED", + }, + }, + }), + { status: 200 }, + ), + ), + ); + const service = new CodeNavigationServiceImpl( + BASE_URL, + createMockTokenProvider(), + ); + const result = await service.readFile({ + target: { registry: "NPM", packageName: "express" }, + filePath: "src/hello.js", + }); + expect(result.filePath).toBe("src/hello.js"); + expect(result.content).toContain("console.log"); + expect(result.isBinary).toBe(false); + }); + + it("preserves isBinary + null content from fetchCodeContext", async () => { + mockFetch(() => + Promise.resolve( + new Response( + JSON.stringify({ + data: { + fetchCodeContext: { + content: null, + filePath: "assets/logo.png", + language: null, + totalLines: null, + startLine: null, + endLine: null, + isBinary: true, + indexingStatus: "INDEXED", + }, + }, + }), + { status: 200 }, + ), + ), + ); + const service = new CodeNavigationServiceImpl( + BASE_URL, + createMockTokenProvider(), + ); + const result = await service.readFile({ + target: { registry: "NPM", packageName: "express" }, + filePath: "assets/logo.png", + }); + expect(result.isBinary).toBe(true); + expect(result.content).toBeUndefined(); + }); + + it("throws CodeNavigationIndexingError for data-path INDEXING sentinel on readFile", async () => { + mockFetch(() => + Promise.resolve( + new Response( + JSON.stringify({ + data: { + fetchCodeContext: { + content: null, + filePath: null, + language: null, + indexingStatus: "INDEXING", + indexingRef: "ref_read", + }, + }, + }), + { status: 200 }, + ), + ), + ); + const service = new CodeNavigationServiceImpl( + BASE_URL, + createMockTokenProvider(), + ); + try { + await service.readFile({ + target: { registry: "NPM", packageName: "express" }, + filePath: "src/x.js", + }); + throw new Error("expected readFile to throw"); + } catch (error) { + expect(error).toBeInstanceOf(CodeNavigationIndexingError); + expect((error as CodeNavigationIndexingError).indexingRef).toBe( + "ref_read", + ); + } + }); + + it("throws CodeNavigationFileNotFoundError when backend emits FILE_NOT_FOUND code", async () => { + mockFetch(() => + Promise.resolve( + new Response( + JSON.stringify({ + errors: [ + { + message: "File not found: nope.js", + extensions: { + code: "FILE_NOT_FOUND", + file_path: "nope.js", + }, + }, + ], + }), + { status: 200 }, + ), + ), + ); + const service = new CodeNavigationServiceImpl( + BASE_URL, + createMockTokenProvider(), + ); + try { + await service.readFile({ + target: { registry: "NPM", packageName: "express" }, + filePath: "nope.js", + }); + throw new Error("expected readFile to throw"); + } catch (error) { + expect(error).toBeInstanceOf(CodeNavigationFileNotFoundError); + expect((error as CodeNavigationFileNotFoundError).filePath).toBe( + "nope.js", + ); + } + }); + + // ------------------------------------------------------------------ + // grepFile + // ------------------------------------------------------------------ + + it("normalises a successful grepRepoFile response", async () => { + mockFetch(() => + Promise.resolve( + new Response( + JSON.stringify({ + data: { + grepRepoFile: { + matches: [ + { + lineNumber: 10, + lineContent: "const app = express();", + contextBefore: ["", "// setup"], + contextAfter: ["", "app.get();"], + }, + ], + totalMatches: 1, + hasMore: false, + filePath: "src/index.js", + language: "javascript", + totalLines: 50, + indexedVersion: "v5.2.1", + resolution: { + resolvedRef: "v5.2.1", + commitSha: "abc", + }, + diagnostics: null, + indexingStatus: "INDEXED", + }, + }, + }), + { status: 200 }, + ), + ), + ); + const service = new CodeNavigationServiceImpl( + BASE_URL, + createMockTokenProvider(), + ); + const result = await service.grepFile({ + target: { registry: "NPM", packageName: "express" }, + path: "src/index.js", + pattern: "middleware", + }); + expect(result.matches.length).toBe(1); + expect(result.matches[0]?.lineNumber).toBe(10); + expect(result.totalMatches).toBe(1); + expect(result.filePath).toBe("src/index.js"); + expect(result.resolution?.resolvedRef).toBe("v5.2.1"); + }); + + it("throws CodeNavigationIndexingError for data-path INDEXING sentinel on grepFile", async () => { + mockFetch(() => + Promise.resolve( + new Response( + JSON.stringify({ + data: { + grepRepoFile: { + matches: [], + totalMatches: 0, + hasMore: false, + indexingStatus: "INDEXING", + indexingRef: "ref_grep", + availableVersions: [{ version: "4.21.0", ref: "v4.21.0" }], + }, + }, + }), + { status: 200 }, + ), + ), + ); + const service = new CodeNavigationServiceImpl( + BASE_URL, + createMockTokenProvider(), + ); + try { + await service.grepFile({ + target: { registry: "NPM", packageName: "express" }, + path: "src/index.js", + pattern: "middleware", + }); + throw new Error("expected grepFile to throw"); + } catch (error) { + expect(error).toBeInstanceOf(CodeNavigationIndexingError); + expect((error as CodeNavigationIndexingError).indexingRef).toBe( + "ref_grep", + ); + } + }); + + it("sends grepRepoFile variables with the correct shape", async () => { + const fn = mockFetch(() => + Promise.resolve( + new Response( + JSON.stringify({ + data: { + grepRepoFile: { + matches: [], + totalMatches: 0, + hasMore: false, + indexingStatus: "INDEXED", + }, + }, + }), + { status: 200 }, + ), + ), + ); + const service = new CodeNavigationServiceImpl( + BASE_URL, + createMockTokenProvider(), + ); + await service.grepFile({ + target: { registry: "NPM", packageName: "express", version: "5.2.1" }, + path: "src/index.js", + pattern: "middleware", + contextLines: 5, + maxMatches: 100, + waitTimeoutMs: 5000, + }); + const [, init] = fn.mock.calls[0] as unknown as [string, RequestInit]; + const body = JSON.parse(init.body as string); + expect(body.variables).toMatchObject({ + registry: "NPM", + packageName: "express", + version: "5.2.1", + filePath: "src/index.js", + pattern: "middleware", + contextLines: 5, + maxMatches: 100, + waitTimeoutMs: 5000, + }); + }); + + it("sends GraphQL variables with the correct listRepoFiles shape", async () => { + const fn = mockFetch(() => + Promise.resolve( + new Response( + JSON.stringify({ + data: { + listRepoFiles: { + files: [], + total: 0, + hasMore: false, + indexingStatus: "INDEXED", + }, + }, + }), + { status: 200 }, + ), + ), + ); + const service = new CodeNavigationServiceImpl( + BASE_URL, + createMockTokenProvider(), + ); + + await service.listFiles({ + target: { registry: "NPM", packageName: "express", version: "5.2.1" }, + pathPrefix: "src/", + limit: 100, + waitTimeoutMs: 5000, + }); + + const [, init] = fn.mock.calls[0] as unknown as [string, RequestInit]; + const body = JSON.parse(init.body as string); + expect(body.variables).toMatchObject({ + registry: "NPM", + packageName: "express", + version: "5.2.1", + pathPrefix: "src/", + limit: 100, + waitTimeoutMs: 5000, + }); + }); }); diff --git a/src/services/code-navigation-service.ts b/src/services/code-navigation-service.ts index 90c747f7..4fa2874d 100644 --- a/src/services/code-navigation-service.ts +++ b/src/services/code-navigation-service.ts @@ -169,8 +169,94 @@ export interface AvailableVersion { ref: string; } +/** + * Input for {@link CodeNavigationService.listFiles}. + */ +export interface ListFilesParams { + target: CodeNavigationTarget; + pathPrefix?: string; + limit?: number; + waitTimeoutMs?: number; +} + +export interface RepoFileEntry { + path: string; + name?: string; + language?: string; + fileType?: string; + byteSize?: number; +} + +export interface ListFilesResult { + files: RepoFileEntry[]; + total: number; + hasMore: boolean; + indexedVersion?: string; + resolution?: SearchSymbolsResolution; + hint?: string; +} + +/** + * Input for {@link CodeNavigationService.readFile}. + */ +export interface ReadFileParams { + target: CodeNavigationTarget; + filePath: string; + startLine?: number; + endLine?: number; + waitTimeoutMs?: number; +} + +export interface ReadFileResult { + filePath?: string; + language?: string; + totalLines?: number; + startLine?: number; + endLine?: number; + content?: string; + isBinary?: boolean; +} + +/** + * Input for {@link CodeNavigationService.grepFile}. + * + * `path` today addresses a single file; naming it `path` (rather + * than `filePath`) leaves room for the same slot to accept broader + * shapes later without a rename. + */ +export interface GrepFileParams { + target: CodeNavigationTarget; + path: string; + pattern: string; + contextLines?: number; + maxMatches?: number; + waitTimeoutMs?: number; +} + +export interface GrepMatch { + lineNumber: number; + lineContent: string; + contextBefore?: string[]; + contextAfter?: string[]; +} + +export interface GrepFileResult { + matches: GrepMatch[]; + totalMatches: number; + hasMore: boolean; + filePath?: string; + language?: string; + totalLines?: number; + indexedVersion?: string; + resolution?: SearchSymbolsResolution; + hint?: string; +} + export interface CodeNavigationService { searchSymbols(params: SearchSymbolsParams): Promise; + listFiles(params: ListFilesParams): Promise; + readFile(params: ReadFileParams): Promise; + grepFile(params: GrepFileParams): Promise; } export class CodeNavigationAccessError extends Error { @@ -225,6 +311,23 @@ export class CodeNavigationTargetNotFoundError extends Error { } } +/** + * Raised when the backend confirmed the package / repo exists but + * the requested file path could not be found within it. Distinct + * from `CodeNavigationTargetNotFoundError` (package itself missing) + * because the recovery path differs — callers should re-check the + * path against `list_files` rather than re-check the package name. + */ +export class CodeNavigationFileNotFoundError extends Error { + constructor( + message: string, + public readonly filePath: string | undefined, + ) { + super(message); + this.name = "CodeNavigationFileNotFoundError"; + } +} + /** * Raised when the target package exists in the index but the * requested version has no matching indexed ref. Distinct from @@ -436,6 +539,266 @@ const graphQLErrorSchema = z.object({ extensions: z.record(z.string(), z.unknown()).optional(), }); +// -------------------------------------------------------------------- +// Zod schemas + queries for the file-exploration bundle. +// `listRepoFiles` / `fetchCodeContext` / `grepRepoFile` share the same +// indexing lifecycle (`indexingStatus` + `indexingRef` + +// `availableVersions`) but otherwise have distinct result shapes — +// normalise per tool rather than under one abstraction. +// -------------------------------------------------------------------- + +const navigationResolutionSchema = z + .object({ + requestedVersion: z.string().nullable().optional(), + requestedRef: z.string().nullable().optional(), + resolvedRef: z.string().nullable().optional(), + commitSha: z.string().nullable().optional(), + }) + .nullable() + .optional(); + +const navigationDiagnosticsSchema = z + .object({ + hint: z.string().nullable().optional(), + }) + .nullable() + .optional(); + +// listRepoFiles ------------------------------------------------------ + +const repoFileEntrySchema = z.object({ + path: z.string(), + name: z.string().nullable().optional(), + language: z.string().nullable().optional(), + fileType: z.string().nullable().optional(), + byteSize: z.number().int().nullable().optional(), +}); + +const listRepoFilesResponseSchema = z.object({ + files: z.array(repoFileEntrySchema), + total: z.number().int(), + hasMore: z.boolean(), + indexedVersion: z.string().nullable().optional(), + resolution: navigationResolutionSchema, + diagnostics: navigationDiagnosticsSchema, + indexingStatus: z.string(), + indexingRef: z.string().nullable().optional(), + availableVersions: z.array(availableVersionSchema).nullable().optional(), +}); + +const listRepoFilesGraphQLResponseSchema = z.object({ + data: z + .object({ + listRepoFiles: listRepoFilesResponseSchema.nullable().optional(), + }) + .nullable() + .optional(), + errors: z.array(graphQLErrorSchema).optional(), +}); + +const LIST_REPO_FILES_QUERY = ` +query ListRepoFiles( + $registry: Registry + $packageName: String + $repoUrl: String + $gitRef: String + $version: String + $pathPrefix: String + $limit: Int + $waitTimeoutMs: Int +) { + listRepoFiles( + registry: $registry + packageName: $packageName + repoUrl: $repoUrl + gitRef: $gitRef + version: $version + pathPrefix: $pathPrefix + limit: $limit + waitTimeoutMs: $waitTimeoutMs + ) { + files { + path + name + language + fileType + byteSize + } + total + hasMore + indexedVersion + resolution { + requestedVersion + requestedRef + resolvedRef + commitSha + } + diagnostics { + hint + } + indexingStatus + indexingRef + availableVersions { + version + ref + } + } +}`; + +// fetchCodeContext --------------------------------------------------- + +// `CodeContextResult` is a separate family — no availableVersions, +// no resolution, no diagnostics. Only indexing fields are shared. +const codeContextResponseSchema = z.object({ + content: z.string().nullable().optional(), + filePath: z.string().nullable().optional(), + language: z.string().nullable().optional(), + totalLines: z.number().int().nullable().optional(), + startLine: z.number().int().nullable().optional(), + endLine: z.number().int().nullable().optional(), + repoUrl: z.string().nullable().optional(), + gitRef: z.string().nullable().optional(), + isBinary: z.boolean().nullable().optional(), + indexingStatus: z.string(), + indexingRef: z.string().nullable().optional(), +}); + +const fetchCodeContextGraphQLResponseSchema = z.object({ + data: z + .object({ + fetchCodeContext: codeContextResponseSchema.nullable().optional(), + }) + .nullable() + .optional(), + errors: z.array(graphQLErrorSchema).optional(), +}); + +const FETCH_CODE_CONTEXT_QUERY = ` +query FetchCodeContext( + $registry: Registry + $packageName: String + $repoUrl: String + $gitRef: String + $version: String + $filePath: String! + $startLine: Int + $endLine: Int + $waitTimeoutMs: Int +) { + fetchCodeContext( + registry: $registry + packageName: $packageName + repoUrl: $repoUrl + gitRef: $gitRef + version: $version + filePath: $filePath + startLine: $startLine + endLine: $endLine + waitTimeoutMs: $waitTimeoutMs + ) { + content + filePath + language + totalLines + startLine + endLine + repoUrl + gitRef + isBinary + indexingStatus + indexingRef + } +}`; + +// grepRepoFile ------------------------------------------------------- + +const grepMatchSchema = z.object({ + lineNumber: z.number().int(), + lineContent: z.string(), + contextBefore: z.array(z.string()).nullable().optional(), + contextAfter: z.array(z.string()).nullable().optional(), +}); + +const grepRepoFileResponseSchema = z.object({ + matches: z.array(grepMatchSchema), + totalMatches: z.number().int(), + hasMore: z.boolean(), + filePath: z.string().nullable().optional(), + language: z.string().nullable().optional(), + totalLines: z.number().int().nullable().optional(), + indexedVersion: z.string().nullable().optional(), + resolution: navigationResolutionSchema, + diagnostics: navigationDiagnosticsSchema, + indexingStatus: z.string(), + indexingRef: z.string().nullable().optional(), + availableVersions: z.array(availableVersionSchema).nullable().optional(), +}); + +const grepRepoFileGraphQLResponseSchema = z.object({ + data: z + .object({ + grepRepoFile: grepRepoFileResponseSchema.nullable().optional(), + }) + .nullable() + .optional(), + errors: z.array(graphQLErrorSchema).optional(), +}); + +const GREP_REPO_FILE_QUERY = ` +query GrepRepoFile( + $registry: Registry + $packageName: String + $repoUrl: String + $gitRef: String + $filePath: String! + $pattern: String! + $contextLines: Int + $maxMatches: Int + $version: String + $waitTimeoutMs: Int +) { + grepRepoFile( + registry: $registry + packageName: $packageName + repoUrl: $repoUrl + gitRef: $gitRef + filePath: $filePath + pattern: $pattern + contextLines: $contextLines + maxMatches: $maxMatches + version: $version + waitTimeoutMs: $waitTimeoutMs + ) { + matches { + lineNumber + lineContent + contextBefore + contextAfter + } + totalMatches + hasMore + filePath + language + totalLines + indexedVersion + resolution { + requestedVersion + requestedRef + resolvedRef + commitSha + } + diagnostics { + hint + } + indexingStatus + indexingRef + availableVersions { + version + ref + } + } +}`; + // `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. @@ -664,6 +1027,16 @@ export class CodeNavigationServiceImpl implements CodeNavigationService { case "NO_REPOSITORY_URL": return new CodeNavigationTargetNotFoundError(message); + case "FILE_NOT_FOUND": + return new CodeNavigationFileNotFoundError( + message, + typeof extensions?.file_path === "string" + ? extensions.file_path + : typeof extensions?.filePath === "string" + ? extensions.filePath + : undefined, + ); + case "UNSUPPORTED_REGISTRY": case "VALIDATION_ERROR": return new CodeNavigationValidationError(message); @@ -731,6 +1104,310 @@ export class CodeNavigationServiceImpl implements CodeNavigationService { } return base; } + + /** + * Shared sentinel-promotion for the file-exploration tools. When the backend + * response carries `indexingStatus: "INDEXING"` (data-path variant), + * throw the typed error so the envelope builder / caller never sees + * the raw sentinel. Mirrors the inline check `searchSymbols` does + * today. + */ + private throwIfIndexing(data: { + indexingStatus: string; + indexingRef?: string | null; + availableVersions?: Array<{ version?: string | null; ref: string }> | null; + }): void { + 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, + })), + ); + } + } + + // ------------------------------------------------------------------ + // listFiles → listRepoFiles + // ------------------------------------------------------------------ + + async listFiles(params: ListFilesParams): Promise { + return executeWithTokenRefresh({ + getToken: () => this.tokenProvider.getToken(), + forceRefresh: () => this.tokenProvider.forceRefresh(), + shouldRefresh: (error) => error instanceof AuthenticationError, + executeWithToken: (token) => this.executeListFiles(token, params), + }); + } + + private async executeListFiles( + token: string, + params: ListFilesParams, + ): Promise { + let response: PkgseerGraphqlResponse; + try { + response = await postPkgseerGraphql({ + endpointUrl: this.codeNavigationUrl, + token, + query: LIST_REPO_FILES_QUERY, + variables: { + registry: params.target.registry, + packageName: params.target.packageName, + repoUrl: params.target.repoUrl, + gitRef: params.target.gitRef, + version: params.target.version, + pathPrefix: params.pathPrefix, + limit: params.limit, + waitTimeoutMs: params.waitTimeoutMs, + }, + fetchFn: this.fetchFn, + }); + } catch (cause) { + if (cause instanceof PkgseerTransportError) { + throw new CodeNavigationNetworkError( + "Could not reach the code navigation service. Check your connection or set GITHITS_CODE_NAV_URL.", + { cause }, + ); + } + throw cause; + } + + if (response.status < 200 || response.status >= 300) { + throw this.createHttpError(response); + } + + const parsed = listRepoFilesGraphQLResponseSchema.safeParse( + response.parsedBody, + ); + 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?.listRepoFiles; + if (!data) { + throw new MalformedCodeNavigationResponseError( + "Malformed response from code navigation service.", + ); + } + + this.throwIfIndexing(data); + + return { + files: data.files.map((entry) => ({ + path: entry.path, + name: entry.name ?? undefined, + language: entry.language ?? undefined, + fileType: entry.fileType ?? undefined, + byteSize: entry.byteSize ?? undefined, + })), + total: data.total, + hasMore: data.hasMore, + indexedVersion: 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, + }; + } + + // ------------------------------------------------------------------ + // readFile → fetchCodeContext + // ------------------------------------------------------------------ + + async readFile(params: ReadFileParams): Promise { + return executeWithTokenRefresh({ + getToken: () => this.tokenProvider.getToken(), + forceRefresh: () => this.tokenProvider.forceRefresh(), + shouldRefresh: (error) => error instanceof AuthenticationError, + executeWithToken: (token) => this.executeReadFile(token, params), + }); + } + + private async executeReadFile( + token: string, + params: ReadFileParams, + ): Promise { + let response: PkgseerGraphqlResponse; + try { + response = await postPkgseerGraphql({ + endpointUrl: this.codeNavigationUrl, + token, + query: FETCH_CODE_CONTEXT_QUERY, + variables: { + registry: params.target.registry, + packageName: params.target.packageName, + repoUrl: params.target.repoUrl, + gitRef: params.target.gitRef, + version: params.target.version, + filePath: params.filePath, + startLine: params.startLine, + endLine: params.endLine, + waitTimeoutMs: params.waitTimeoutMs, + }, + fetchFn: this.fetchFn, + }); + } catch (cause) { + if (cause instanceof PkgseerTransportError) { + throw new CodeNavigationNetworkError( + "Could not reach the code navigation service. Check your connection or set GITHITS_CODE_NAV_URL.", + { cause }, + ); + } + throw cause; + } + + if (response.status < 200 || response.status >= 300) { + throw this.createHttpError(response); + } + + const parsed = fetchCodeContextGraphQLResponseSchema.safeParse( + response.parsedBody, + ); + 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?.fetchCodeContext; + if (!data) { + throw new MalformedCodeNavigationResponseError( + "Malformed response from code navigation service.", + ); + } + + // `fetchCodeContext` doesn't return availableVersions; pass a + // minimal object to the shared helper. + this.throwIfIndexing({ + indexingStatus: data.indexingStatus, + indexingRef: data.indexingRef, + }); + + return { + filePath: data.filePath ?? undefined, + language: data.language ?? undefined, + totalLines: data.totalLines ?? undefined, + startLine: data.startLine ?? undefined, + endLine: data.endLine ?? undefined, + content: data.content ?? undefined, + isBinary: data.isBinary ?? undefined, + }; + } + + // ------------------------------------------------------------------ + // grepFile → grepRepoFile + // ------------------------------------------------------------------ + + async grepFile(params: GrepFileParams): Promise { + return executeWithTokenRefresh({ + getToken: () => this.tokenProvider.getToken(), + forceRefresh: () => this.tokenProvider.forceRefresh(), + shouldRefresh: (error) => error instanceof AuthenticationError, + executeWithToken: (token) => this.executeGrepFile(token, params), + }); + } + + private async executeGrepFile( + token: string, + params: GrepFileParams, + ): Promise { + let response: PkgseerGraphqlResponse; + try { + response = await postPkgseerGraphql({ + endpointUrl: this.codeNavigationUrl, + token, + query: GREP_REPO_FILE_QUERY, + variables: { + registry: params.target.registry, + packageName: params.target.packageName, + repoUrl: params.target.repoUrl, + gitRef: params.target.gitRef, + version: params.target.version, + filePath: params.path, + pattern: params.pattern, + contextLines: params.contextLines, + maxMatches: params.maxMatches, + waitTimeoutMs: params.waitTimeoutMs, + }, + fetchFn: this.fetchFn, + }); + } catch (cause) { + if (cause instanceof PkgseerTransportError) { + throw new CodeNavigationNetworkError( + "Could not reach the code navigation service. Check your connection or set GITHITS_CODE_NAV_URL.", + { cause }, + ); + } + throw cause; + } + + if (response.status < 200 || response.status >= 300) { + throw this.createHttpError(response); + } + + const parsed = grepRepoFileGraphQLResponseSchema.safeParse( + response.parsedBody, + ); + 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?.grepRepoFile; + if (!data) { + throw new MalformedCodeNavigationResponseError( + "Malformed response from code navigation service.", + ); + } + + this.throwIfIndexing(data); + + return { + matches: data.matches.map((entry) => ({ + lineNumber: entry.lineNumber, + lineContent: entry.lineContent, + contextBefore: entry.contextBefore ?? undefined, + contextAfter: entry.contextAfter ?? undefined, + })), + totalMatches: data.totalMatches, + hasMore: data.hasMore, + filePath: data.filePath ?? undefined, + language: data.language ?? undefined, + totalLines: data.totalLines ?? undefined, + indexedVersion: 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, + }; + } } function parseDetail(body: string): string | undefined { diff --git a/src/services/index.ts b/src/services/index.ts index 0a1516ce..34ac8d80 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -29,6 +29,14 @@ export type { CodeNavigationRegistry, CodeNavigationService, CodeNavigationTarget, + GrepFileParams, + GrepFileResult, + GrepMatch, + ListFilesParams, + ListFilesResult, + ReadFileParams, + ReadFileResult, + RepoFileEntry, SearchSymbolsFileIntent, SearchSymbolsKind, SearchSymbolsMatchMode, @@ -42,6 +50,7 @@ export { CodeNavigationAccessError, CodeNavigationBackendError, CodeNavigationFeatureFlagRequiredError, + CodeNavigationFileNotFoundError, CodeNavigationGraphQLError, CodeNavigationIndexingError, CodeNavigationNetworkError, diff --git a/src/services/test-helpers.ts b/src/services/test-helpers.ts index 02a8e2fb..0baf3795 100644 --- a/src/services/test-helpers.ts +++ b/src/services/test-helpers.ts @@ -220,6 +220,73 @@ export function createMockGitHitsService( }; } +export const defaultListFilesResult = { + files: [ + // Backend returns `fileType` uppercase (observed: CONFIG, SOURCE, + // TEST, DOC). Keep fixtures aligned so formatter tests lock in + // the real contract. + { + path: "src/index.js", + name: "index.js", + language: "javascript", + fileType: "SOURCE", + byteSize: 1234, + }, + { + path: "src/lib/app.js", + name: "app.js", + language: "javascript", + fileType: "SOURCE", + byteSize: 8500, + }, + ], + total: 2, + hasMore: false, + indexedVersion: "v5.2.1", + resolution: { + requestedVersion: undefined, + requestedRef: undefined, + resolvedRef: "v5.2.1", + commitSha: "abc123", + }, + hint: undefined, +}; + +export const defaultReadFileResult = { + filePath: "src/index.js", + language: "javascript", + totalLines: 5, + startLine: 1, + endLine: 5, + content: + "// Express entry point\n'use strict';\n\nmodule.exports = require('./lib/express');\n", + isBinary: false, +}; + +export const defaultGrepFileResult = { + matches: [ + { + lineNumber: 4, + lineContent: "module.exports = require('./lib/express');", + contextBefore: ["// Express entry point", "'use strict';", ""], + contextAfter: [""], + }, + ], + totalMatches: 1, + hasMore: false, + filePath: "src/index.js", + language: "javascript", + totalLines: 5, + indexedVersion: "v5.2.1", + resolution: { + requestedVersion: undefined, + requestedRef: undefined, + resolvedRef: "v5.2.1", + commitSha: "abc123", + }, + hint: undefined, +}; + /** * Creates a mock CodeNavigationService with default implementations. */ @@ -228,6 +295,9 @@ export function createMockCodeNavigationService( ): CodeNavigationService { return { searchSymbols: mock(() => Promise.resolve(defaultSearchSymbolsResult)), + listFiles: mock(() => Promise.resolve(defaultListFilesResult)), + readFile: mock(() => Promise.resolve(defaultReadFileResult)), + grepFile: mock(() => Promise.resolve(defaultGrepFileResult)), ...impl, }; } diff --git a/src/shared/code-navigation-error-map.test.ts b/src/shared/code-navigation-error-map.test.ts index 77a45bc7..ed442444 100644 --- a/src/shared/code-navigation-error-map.test.ts +++ b/src/shared/code-navigation-error-map.test.ts @@ -3,6 +3,7 @@ import { CodeNavigationAccessError, CodeNavigationBackendError, CodeNavigationFeatureFlagRequiredError, + CodeNavigationFileNotFoundError, CodeNavigationGraphQLError, CodeNavigationIndexingError, CodeNavigationNetworkError, @@ -340,6 +341,7 @@ describe("mapCodeNavigationError debug instrumentation", () => { process.env.GITHITS_DEBUG = "*"; const errors: unknown[] = [ new CodeNavigationTargetNotFoundError("x"), + new CodeNavigationFileNotFoundError("x", "some/path"), new CodeNavigationIndexingError("x"), new CodeNavigationUnresolvableError("x"), new CodeNavigationAccessError("x"), @@ -354,4 +356,29 @@ describe("mapCodeNavigationError debug instrumentation", () => { for (const err of errors) mapCodeNavigationError(err); expect(stderrSpy).toHaveBeenCalledTimes(errors.length); }); + + it("classifies CodeNavigationFileNotFoundError as FILE_NOT_FOUND with filePath detail", () => { + const err = new CodeNavigationFileNotFoundError( + "File not found: src/missing.js", + "src/missing.js", + ); + expect(mapCodeNavigationError(err)).toEqual({ + code: "FILE_NOT_FOUND", + message: "File not found: src/missing.js", + retryable: false, + details: { filePath: "src/missing.js" }, + }); + }); + + it("classifies CodeNavigationFileNotFoundError without filePath", () => { + const err = new CodeNavigationFileNotFoundError( + "File not found", + undefined, + ); + expect(mapCodeNavigationError(err)).toEqual({ + code: "FILE_NOT_FOUND", + message: "File not found", + retryable: false, + }); + }); }); diff --git a/src/shared/code-navigation-error-map.ts b/src/shared/code-navigation-error-map.ts index bbc1b904..df8f9736 100644 --- a/src/shared/code-navigation-error-map.ts +++ b/src/shared/code-navigation-error-map.ts @@ -3,6 +3,7 @@ import { CodeNavigationAccessError, CodeNavigationBackendError, CodeNavigationFeatureFlagRequiredError, + CodeNavigationFileNotFoundError, CodeNavigationGraphQLError, CodeNavigationIndexingError, CodeNavigationNetworkError, @@ -17,6 +18,7 @@ import { debugLog } from "./debug-log.js"; export type MappedErrorCode = | "NOT_FOUND" + | "FILE_NOT_FOUND" | "VERSION_NOT_FOUND" | "INDEXING" | "UNRESOLVABLE" @@ -45,6 +47,8 @@ export interface MappedErrorDetails { requestedVersion?: string; /** Fully-qualified package identifier (for `VERSION_NOT_FOUND`). */ package?: string; + /** The file path the caller asked for (for `FILE_NOT_FOUND`). */ + filePath?: string; } export interface MappedError { @@ -115,6 +119,14 @@ function classify(error: unknown): MappedError { : undefined, }; } + if (error instanceof CodeNavigationFileNotFoundError) { + return { + code: "FILE_NOT_FOUND", + message: error.message, + retryable: false, + details: error.filePath ? { filePath: error.filePath } : undefined, + }; + } if (error instanceof CodeNavigationIndexingError) { const details: MappedErrorDetails = {}; if (error.indexingRef) details.indexingRef = error.indexingRef; diff --git a/src/shared/grep-file-request.test.ts b/src/shared/grep-file-request.test.ts new file mode 100644 index 00000000..d35c8b04 --- /dev/null +++ b/src/shared/grep-file-request.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from "bun:test"; +import type { CodeNavigationTarget } from "../services/index.js"; +import { + buildGrepFileParams, + GREP_PATTERN_SEMANTICS_NOTE, + looksLikeRegexAttempt, +} from "./grep-file-request.js"; + +const target: CodeNavigationTarget = { + registry: "NPM", + packageName: "express", +}; + +describe("buildGrepFileParams — defaults + happy path", () => { + it("applies defaults for context (0) and max_matches (50) and wait (20000)", () => { + const { params } = buildGrepFileParams({ + target, + path: "src/index.js", + pattern: "middleware", + }); + expect(params.contextLines).toBe(0); + expect(params.maxMatches).toBe(50); + expect(params.waitTimeoutMs).toBe(20000); + }); + + it("passes explicit values through and marks them explicit", () => { + const { params, contextLinesExplicit, maxMatchesExplicit } = + buildGrepFileParams({ + target, + path: "src/index.js", + pattern: "middleware", + contextLines: 5, + maxMatches: 100, + }); + expect(params.contextLines).toBe(5); + expect(params.maxMatches).toBe(100); + expect(contextLinesExplicit).toBe(true); + expect(maxMatchesExplicit).toBe(true); + }); + + it("trims the path", () => { + const { params } = buildGrepFileParams({ + target, + path: " src/index.js ", + pattern: "middleware", + }); + expect(params.path).toBe("src/index.js"); + }); +}); + +describe("buildGrepFileParams — rejection cases", () => { + it("rejects empty path", () => { + expect(() => + buildGrepFileParams({ + target, + path: " ", + pattern: "middleware", + }), + ).toThrow(/`path` is required/); + }); + + it("rejects empty pattern", () => { + expect(() => + buildGrepFileParams({ + target, + path: "src/index.js", + pattern: "", + }), + ).toThrow(/`pattern` is required/); + }); + + it("rejects pattern over 200 characters", () => { + expect(() => + buildGrepFileParams({ + target, + path: "src/index.js", + pattern: "a".repeat(201), + }), + ).toThrow(/≤ 200 characters/); + }); + + it.each([ + -1, 11, 3.5, + ])("rejects out-of-range contextLines %s", (contextLines) => { + expect(() => + buildGrepFileParams({ + target, + path: "src/index.js", + pattern: "middleware", + contextLines, + }), + ).toThrow(/between 0 and 10/); + }); + + it.each([0, 201, 3.5])("rejects out-of-range maxMatches %s", (maxMatches) => { + expect(() => + buildGrepFileParams({ + target, + path: "src/index.js", + pattern: "middleware", + maxMatches, + }), + ).toThrow(/between 1 and 200/); + }); +}); + +describe("GREP_PATTERN_SEMANTICS_NOTE constant", () => { + it("mentions case-insensitive, substring, not-regex, and 200-char cap", () => { + expect(GREP_PATTERN_SEMANTICS_NOTE).toMatch(/case-insensitive/i); + expect(GREP_PATTERN_SEMANTICS_NOTE).toMatch(/substring/i); + expect(GREP_PATTERN_SEMANTICS_NOTE).toMatch(/NOT regex|not regex/i); + expect(GREP_PATTERN_SEMANTICS_NOTE).toMatch(/200/); + }); +}); + +describe("looksLikeRegexAttempt — narrow heuristic", () => { + it.each([ + "\\bfoo\\b", + "\\Bnot-boundary", + "\\w+", + "\\W+", + "\\d{3}", + "\\D", + "\\s", + "\\S", + "\\.foo", + "\\/path", + "\\(captured\\)", + "\\[bracket\\]", + "[abc]", + "[a-z]", + "(?:foo|bar)", + "(?=bar)", + "(?!bar)", + "(?<=foo)bar", + "(?foo)", + "(?i)case", + "\\\\path", + "a{3}", + "b{2,5}", + "c{2,}", + ])("flags '%s' as a regex attempt", (pattern) => { + expect(looksLikeRegexAttempt(pattern)).toBe(true); + }); + + it.each([ + "foo.bar", // dot in filename — common, not a regex signal + "*.js", // glob — not regex + "hello world", + "ENOTFOUND", + "price?", + "a+b", + "^start", // raw ^ — too common in code to flag + "end$", // raw $ — too common as variable / shell / regex end + "foo|bar", // alternation — common as literal OR text + "middleware()", // parens as function call, not regex group + "{count: 5}", // braces in object literals + ])("does not flag '%s' (avoids false positives)", (pattern) => { + expect(looksLikeRegexAttempt(pattern)).toBe(false); + }); +}); diff --git a/src/shared/grep-file-request.ts b/src/shared/grep-file-request.ts new file mode 100644 index 00000000..0fa621a1 --- /dev/null +++ b/src/shared/grep-file-request.ts @@ -0,0 +1,159 @@ +/** + * Shared request builder for `grep_file`. CLI and MCP normalise + * inputs here so the two surfaces cannot diverge on pattern / + * context / match-count bounds. + * + * The `path` argument deliberately keeps its generic name (rather + * than `file_path`) to leave room for broader shapes later; today + * it addresses a single file. + */ + +import type { + CodeNavigationTarget, + GrepFileParams, +} from "../services/index.js"; +import { DEFAULT_WAIT_TIMEOUT_MS } from "./code-navigation-defaults.js"; +import { InvalidPackageSpecError } from "./package-spec.js"; + +const PATTERN_MAX = 200; +const CONTEXT_MIN = 0; +const CONTEXT_MAX = 10; +// Default is no context — matches-only output stays pipe-friendly +// (`grep -o`-style) and token-efficient for agents. Callers opt into +// context explicitly. +const CONTEXT_DEFAULT = 0; +const LIMIT_MIN = 1; +const LIMIT_MAX = 200; +const LIMIT_DEFAULT = 50; +const WAIT_MIN = 0; +const WAIT_MAX = 60_000; + +/** + * The pattern-semantics disclosure. Shared verbatim across the MCP + * tool description, the MCP `pattern` arg describe, and the CLI + * help text so the three surfaces never disagree. + */ +export const GREP_PATTERN_SEMANTICS_NOTE = + "Case-insensitive substring matching. NOT regex — `\\b`, `^`, `.`, etc. match literally. Max 200 characters."; + +/** + * Regex-like metacharacters that almost always signal a deliberate + * regex attempt. Bare `.`, `*`, `+`, `?`, `^`, `$`, `|`, `(`, `)` + * are intentionally NOT in this set — they show up in ordinary + * filenames / words / code fragments (`foo.bar`, `*.js`, `$foo`, + * `middleware()`, `a|b`), and firing the hint on those would + * create noise. + * + * Covered (deliberate regex signals only): + * - Escape classes: `\b \B \w \W \d \D \s \S`. + * - Escaped regex metacharacters: `\. \/ \( \) \[ \] \{ \} \+ \* + * \? \^ \$ \|` — these are almost never intentional literal + * searches; the user is escaping because they know regex syntax. + * - Double backslash (raw regex string common form). + * - Character class: `[...]`. + * - Non-capturing / lookaround / named group / inline flags: + * `(?:...)`, `(?=...)`, `(?!...)`, `(?<=...)`, `(?...)`, `(?i)` etc. + * - Brace quantifier: `{N}`, `{N,}`, `{N,M}`. + * + * Best-effort: a pattern like `foo|bar` or `^start` won't fire, + * but the backend's "case-insensitive substring" hint still plays + * on zero-match responses, so the user isn't left in the dark. + */ +const REGEX_SIGNAL_PATTERNS: readonly RegExp[] = [ + /\\[bBwWdDsS]/, // word/digit/whitespace/boundary escapes (both cases) + /\\[./(){}[\]+*?^$|]/, // escaped regex metacharacters + /\\\\/, // double backslash + /\[[^\]]*\]/, // character class + /\(\?[=!: re.test(pattern)); +} + +export interface GrepFileRequestInput { + target: CodeNavigationTarget; + path: string; + pattern: string; + contextLines?: number; + maxMatches?: number; + waitTimeoutMs?: number; +} + +export interface GrepFileRequestBuildResult { + params: GrepFileParams; + contextLinesExplicit: boolean; + maxMatchesExplicit: boolean; +} + +export function buildGrepFileParams( + input: GrepFileRequestInput, +): GrepFileRequestBuildResult { + const path = input.path?.trim() ?? ""; + if (!path) { + throw new InvalidPackageSpecError( + "`path` is required — pass the path to the file within the package or repo.", + ); + } + + const pattern = input.pattern ?? ""; + if (pattern.length === 0) { + throw new InvalidPackageSpecError( + "`pattern` is required — pass the substring to search for.", + ); + } + if (pattern.length > PATTERN_MAX) { + throw new InvalidPackageSpecError( + `\`pattern\` must be ≤ ${PATTERN_MAX} characters. Got ${pattern.length}.`, + ); + } + + const contextLines = normaliseContextLines(input.contextLines); + const maxMatches = normaliseMaxMatches(input.maxMatches); + const waitTimeoutMs = normaliseWaitTimeoutMs(input.waitTimeoutMs); + + return { + params: { + target: input.target, + path, + pattern, + contextLines, + maxMatches, + waitTimeoutMs, + }, + contextLinesExplicit: input.contextLines !== undefined, + maxMatchesExplicit: input.maxMatches !== undefined, + }; +} + +function normaliseContextLines(raw: number | undefined): number { + if (raw === undefined) return CONTEXT_DEFAULT; + if (!Number.isInteger(raw) || raw < CONTEXT_MIN || raw > CONTEXT_MAX) { + throw new InvalidPackageSpecError( + `\`context_lines\` must be an integer between ${CONTEXT_MIN} and ${CONTEXT_MAX}. Got ${raw}.`, + ); + } + return raw; +} + +function normaliseMaxMatches(raw: number | undefined): number { + if (raw === undefined) return LIMIT_DEFAULT; + if (!Number.isInteger(raw) || raw < LIMIT_MIN || raw > LIMIT_MAX) { + throw new InvalidPackageSpecError( + `\`max_matches\` must be an integer between ${LIMIT_MIN} and ${LIMIT_MAX}. Got ${raw}.`, + ); + } + return raw; +} + +function normaliseWaitTimeoutMs(raw: number | undefined): number { + if (raw === undefined) return DEFAULT_WAIT_TIMEOUT_MS; + if (!Number.isInteger(raw) || raw < WAIT_MIN || raw > WAIT_MAX) { + throw new InvalidPackageSpecError( + `\`wait_timeout_ms\` must be an integer between ${WAIT_MIN} and ${WAIT_MAX}. Got ${raw}.`, + ); + } + return raw; +} diff --git a/src/shared/grep-file-response.test.ts b/src/shared/grep-file-response.test.ts new file mode 100644 index 00000000..074ebaeb --- /dev/null +++ b/src/shared/grep-file-response.test.ts @@ -0,0 +1,445 @@ +import { describe, expect, it } from "bun:test"; +import type { GrepFileResult } from "../services/index.js"; +import { + buildGrepFileSuccessPayload, + formatGrepFileTerminal, +} from "./grep-file-response.js"; + +const baseResult: GrepFileResult = { + matches: [ + { + lineNumber: 10, + lineContent: "const app = express();", + contextBefore: ["", "// set up express", ""], + contextAfter: ["", "app.get('/', …);"], + }, + ], + totalMatches: 1, + hasMore: false, + filePath: "src/index.js", + language: "javascript", + totalLines: 50, + indexedVersion: "v5.2.1", + resolution: { + resolvedRef: "v5.2.1", + commitSha: "abc123def", + }, + hint: undefined, +}; + +const baseOptions = { + registry: "npm", + name: "express", + pattern: "express()", + path: "src/index.js", + contextLinesExplicit: false, + maxMatchesExplicit: false, + contextLines: 2, + maxMatches: 50, +}; + +describe("buildGrepFileSuccessPayload", () => { + it("projects the envelope shape", () => { + const envelope = buildGrepFileSuccessPayload(baseResult, baseOptions); + expect(envelope.registry).toBe("npm"); + expect(envelope.name).toBe("express"); + expect(envelope.pattern).toBe("express()"); + expect(envelope.path).toBe("src/index.js"); + expect(envelope.totalMatches).toBe(1); + expect(envelope.hasMore).toBe(false); + expect(envelope.matches.length).toBe(1); + expect(envelope.matches[0]?.lineNumber).toBe(10); + expect(envelope.indexedVersion).toBe("v5.2.1"); + expect(envelope.filter).toBeUndefined(); + }); + + it("echoes filter.contextLines and filter.maxMatches when explicit", () => { + const envelope = buildGrepFileSuccessPayload(baseResult, { + ...baseOptions, + contextLines: 5, + maxMatches: 100, + contextLinesExplicit: true, + maxMatchesExplicit: true, + }); + expect(envelope.filter).toEqual({ contextLines: 5, maxMatches: 100 }); + }); + + it("does not echo filter when defaults are used", () => { + const envelope = buildGrepFileSuccessPayload(baseResult, baseOptions); + expect(envelope.filter).toBeUndefined(); + }); + + it("strips empty context arrays", () => { + const envelope = buildGrepFileSuccessPayload( + { + ...baseResult, + matches: [ + { + lineNumber: 10, + lineContent: "const app = express();", + contextBefore: [], + contextAfter: [], + }, + ], + }, + baseOptions, + ); + expect(envelope.matches[0]?.contextBefore).toBeUndefined(); + expect(envelope.matches[0]?.contextAfter).toBeUndefined(); + }); + + it("surfaces hint on empty results", () => { + const envelope = buildGrepFileSuccessPayload( + { + ...baseResult, + matches: [], + totalMatches: 0, + hint: "Pattern not found in file.", + }, + baseOptions, + ); + expect(envelope.matches).toEqual([]); + expect(envelope.hint).toBe("Pattern not found in file."); + }); + + it("surfaces repo-URL addressing", () => { + const envelope = buildGrepFileSuccessPayload(baseResult, { + ...baseOptions, + registry: undefined, + name: undefined, + repoUrl: "https://github.com/expressjs/express", + gitRef: "main", + }); + expect(envelope.repoUrl).toBe("https://github.com/expressjs/express"); + expect(envelope.gitRef).toBe("main"); + }); +}); + +describe("formatGrepFileTerminal", () => { + it("plain mode: emits matching lines only — no header, no gutter", () => { + const envelope = buildGrepFileSuccessPayload( + { + ...baseResult, + matches: [ + { + lineNumber: 10, + lineContent: "const app = express();", + contextBefore: [], + contextAfter: [], + }, + ], + }, + baseOptions, + ); + const { stdout: output } = formatGrepFileTerminal(envelope, { + useColors: false, + }); + expect(output).toContain("const app = express();"); + expect(output).not.toContain("express · npm"); + expect(output).not.toMatch(/^>/m); + // No line number prefix in plain mode. + expect(output).not.toMatch(/^\s*10\s+const/m); + }); + + it("plain mode: zero matches → completely silent (matches grep's exit-1-with-no-output convention)", () => { + const envelope = buildGrepFileSuccessPayload( + { ...baseResult, matches: [], totalMatches: 0 }, + baseOptions, + ); + const { stdout: output } = formatGrepFileTerminal(envelope, { + useColors: false, + }); + expect(output).toBe(""); + }); + + it("plain mode with context: merges overlapping blocks into a single block", () => { + // Two matches at lines 10 and 12 with contextBefore/After=2 each. + // The contexts for match-1 [8..12] and match-2 [10..14] overlap. + const envelope = buildGrepFileSuccessPayload( + { + ...baseResult, + totalMatches: 2, + matches: [ + { + lineNumber: 10, + lineContent: "match one", + contextBefore: ["line 8", "line 9"], + contextAfter: ["line 11", "match two"], + }, + { + lineNumber: 12, + lineContent: "match two", + contextBefore: ["line 10 (dup)", "line 11 (dup)"], + contextAfter: ["line 13", "line 14"], + }, + ], + }, + baseOptions, + ); + const { stdout: output } = formatGrepFileTerminal(envelope, { + useColors: false, + }); + // Every context line must appear exactly once. + expect(output.match(/line 8/g)?.length).toBe(1); + expect(output.match(/line 9/g)?.length).toBe(1); + expect(output.match(/line 11/g)?.length).toBe(1); + expect(output.match(/line 13/g)?.length).toBe(1); + expect(output.match(/line 14/g)?.length).toBe(1); + // Match-1 content (line 10) and match-2 content (line 12) both + // appear once — the context duplicate at line 10 in match-2's + // contextBefore must not overwrite the match line content. + expect(output.match(/match one/g)?.length).toBe(1); + expect(output.match(/match two/g)?.length).toBe(1); + // No `--` separator because both matches merged into a single + // block. + expect(output).not.toContain("--"); + }); + + it("plain mode with context: inserts `--` separator between distinct blocks", () => { + const envelope = buildGrepFileSuccessPayload( + { + ...baseResult, + totalMatches: 2, + matches: [ + { + lineNumber: 5, + lineContent: "match near top", + contextBefore: ["line 4"], + contextAfter: ["line 6"], + }, + { + lineNumber: 50, + lineContent: "match far below", + contextBefore: ["line 49"], + contextAfter: ["line 51"], + }, + ], + }, + baseOptions, + ); + const { stdout: output } = formatGrepFileTerminal(envelope, { + useColors: false, + }); + expect(output).toContain("--"); + expect(output).toContain("line 4"); + expect(output).toContain("line 51"); + }); + + it("verbose mode: renders header + gutter + `>` marker on match lines", () => { + const envelope = buildGrepFileSuccessPayload(baseResult, baseOptions); + const { stdout: output } = formatGrepFileTerminal(envelope, { + useColors: false, + verbose: true, + }); + expect(output).toContain("express · npm · 1 match in src/index.js"); + expect(output).toContain("> 10 const app = express();"); + expect(output).toContain("// set up express"); + expect(output).toContain("app.get('/', …);"); + }); + + it("verbose mode: renders plural 'matches' for counts ≠ 1", () => { + const envelope = buildGrepFileSuccessPayload( + { + ...baseResult, + totalMatches: 3, + matches: Array.from({ length: 3 }, (_, i) => ({ + lineNumber: 10 + i, + lineContent: `line ${10 + i}`, + })), + }, + baseOptions, + ); + const { stdout: output } = formatGrepFileTerminal(envelope, { + useColors: false, + verbose: true, + }); + expect(output).toContain("3 matches"); + }); + + it("verbose mode: uses N+ header when hasMore is true", () => { + const envelope = buildGrepFileSuccessPayload( + { + ...baseResult, + totalMatches: 50, + hasMore: true, + matches: Array.from({ length: 50 }, (_, i) => ({ + lineNumber: i + 1, + lineContent: `line ${i + 1}`, + })), + }, + baseOptions, + ); + const { stdout: output } = formatGrepFileTerminal(envelope, { + useColors: false, + verbose: true, + }); + expect(output).toContain("50+ matches"); + expect(output).toContain("More matches available"); + }); + + it("verbose mode: empty-result + regex-char hint when pattern looks like regex", () => { + const envelope = buildGrepFileSuccessPayload( + { ...baseResult, matches: [], totalMatches: 0 }, + { ...baseOptions, pattern: "\\bfoo\\b" }, + ); + const { stdout: output } = formatGrepFileTerminal(envelope, { + useColors: false, + verbose: true, + }); + expect(output).toContain("No matches for '\\bfoo\\b'"); + expect(output).toContain("literal substring matching"); + }); + + it("verbose mode: does not add the regex hint when pattern doesn't look like regex", () => { + const envelope = buildGrepFileSuccessPayload( + { ...baseResult, matches: [], totalMatches: 0 }, + { ...baseOptions, pattern: "foo.bar" }, + ); + const { stdout: output } = formatGrepFileTerminal(envelope, { + useColors: false, + verbose: true, + }); + expect(output).toContain("No matches for 'foo.bar'"); + expect(output).not.toContain("literal substring matching"); + }); + + it("verbose mode: uses server-supplied hint when present", () => { + const envelope = buildGrepFileSuccessPayload( + { + ...baseResult, + matches: [], + totalMatches: 0, + hint: "Check the file path — we indexed this repo but the path didn't match.", + }, + baseOptions, + ); + const { stdout: output } = formatGrepFileTerminal(envelope, { + useColors: false, + verbose: true, + }); + expect(output).toContain("Check the file path"); + }); + + it("plain mode: handles unsorted matches from the backend — final output is line-number sorted", () => { + // Defensive: the backend should return matches sorted by line + // number, but the merger has no dependency on arrival order. + const envelope = buildGrepFileSuccessPayload( + { + ...baseResult, + totalMatches: 2, + matches: [ + { + lineNumber: 80, + lineContent: "match two", + contextBefore: [], + contextAfter: [], + }, + { + lineNumber: 10, + lineContent: "match one", + contextBefore: [], + contextAfter: [], + }, + ], + }, + baseOptions, + ); + const { stdout } = formatGrepFileTerminal(envelope, { useColors: false }); + const oneIndex = stdout.indexOf("match one"); + const twoIndex = stdout.indexOf("match two"); + expect(oneIndex).toBeGreaterThan(-1); + expect(twoIndex).toBeGreaterThan(-1); + expect(oneIndex).toBeLessThan(twoIndex); + }); + + it("plain mode with context: match line wins over another match's context entry at the same line", () => { + // Match A at line 10; match B at line 12 with contextBefore that + // reaches back to line 10. The match-line entry at 10 must stay + // flagged as a match, not get overwritten by B's context copy. + const envelope = buildGrepFileSuccessPayload( + { + ...baseResult, + totalMatches: 2, + matches: [ + { + lineNumber: 10, + lineContent: "TRUE_MATCH_CONTENT", + contextBefore: [], + contextAfter: ["ctx 11"], + }, + { + lineNumber: 12, + lineContent: "match two", + contextBefore: ["stale context version", "ctx 11 dup"], + contextAfter: [], + }, + ], + }, + baseOptions, + ); + const { stdout } = formatGrepFileTerminal(envelope, { + useColors: false, + verbose: true, + }); + // The match line's actual content must appear with the `>` marker. + expect(stdout).toMatch(/>\s+10\s+TRUE_MATCH_CONTENT/); + // The stale context version must not leak in. + expect(stdout).not.toContain("stale context version"); + }); + + it("plain mode: handles matches with null contextBefore / contextAfter (builder strips empties to undefined)", () => { + const envelope = buildGrepFileSuccessPayload( + { + ...baseResult, + matches: [ + { + lineNumber: 42, + lineContent: "lonely match", + contextBefore: [], + contextAfter: [], + }, + ], + }, + baseOptions, + ); + // Confirm the envelope stripped empty arrays. + expect(envelope.matches[0]?.contextBefore).toBeUndefined(); + expect(envelope.matches[0]?.contextAfter).toBeUndefined(); + const { stdout } = formatGrepFileTerminal(envelope, { useColors: false }); + expect(stdout).toContain("lonely match"); + }); + + it("verbose mode with context: merges overlapping blocks and deduplicates lines", () => { + const envelope = buildGrepFileSuccessPayload( + { + ...baseResult, + totalMatches: 2, + matches: [ + { + lineNumber: 10, + lineContent: "match one", + contextBefore: ["ctx 9"], + contextAfter: ["ctx 11"], + }, + { + lineNumber: 12, + lineContent: "match two", + contextBefore: ["ctx 11 (dup)"], + contextAfter: ["ctx 13"], + }, + ], + }, + baseOptions, + ); + const { stdout: output } = formatGrepFileTerminal(envelope, { + useColors: false, + verbose: true, + }); + // `ctx 11` appears once (match-1's contextAfter wins; match-2's + // contextBefore version is dropped as a dup). + expect(output.match(/ctx 11/g)?.length).toBe(1); + expect(output.match(/ctx 11 \(dup\)/g)?.length).toBeFalsy(); + // Both matches are marked with `>`. + expect(output.match(/^> /gm)?.length).toBe(2); + }); +}); diff --git a/src/shared/grep-file-response.ts b/src/shared/grep-file-response.ts new file mode 100644 index 00000000..f1ed8d6b --- /dev/null +++ b/src/shared/grep-file-response.ts @@ -0,0 +1,488 @@ +/** + * Response envelope for `grep_file`. Shared across CLI `--json` + * output and MCP `content[0].text`; terminal formatter is CLI-only. + * + * Key rules: + * - **Data-first.** `matches` always present (possibly empty); + * `resolution` when backend returned one; `hint` when empty + * results carry a backend diagnostic. + * - **No indexing metadata in the success envelope.** Service + * promotes `indexingStatus: INDEXING` to a typed error first. + * - **`filter.*` echoes only caller-supplied inputs.** + * - **Regex-char heuristic on empty results (terminal-only).** If + * the pattern looks like unambiguous regex AND zero matches, + * the terminal appends a nudge. JSON never carries this hint. + */ + +import type { GrepFileResult, GrepMatch } from "../services/index.js"; +import { colorize, dim } from "./colors.js"; +import { looksLikeRegexAttempt } from "./grep-file-request.js"; + +export interface LeanGrepMatch { + lineNumber: number; + lineContent: string; + contextBefore?: string[]; + contextAfter?: string[]; +} + +export interface LeanGrepResolution { + requestedVersion?: string; + requestedRef?: string; + resolvedRef?: string; + commitSha?: string; +} + +export interface LeanGrepFilter { + contextLines?: number; + maxMatches?: number; +} + +export interface LeanGrepFileEnvelope { + registry?: string; + name?: string; + repoUrl?: string; + gitRef?: string; + pattern: string; + /** + * Resolved file path. Uses the backend's echoed path when + * available, falling back to the caller's input. Single `path` + * field (no `filePath` alongside) so the `list_files.files[].path` + * → `grep_file({path})` / `read_file({path})` chain stays + * mechanically consistent. + */ + path: string; + totalMatches: number; + hasMore: boolean; + language?: string; + totalLines?: number; + indexedVersion?: string; + resolution?: LeanGrepResolution; + matches: LeanGrepMatch[]; + hint?: string; + filter?: LeanGrepFilter; +} + +export interface BuildGrepFilePayloadOptions { + registry?: string; + name?: string; + repoUrl?: string; + gitRef?: string; + pattern: string; + path: string; + contextLinesExplicit: boolean; + maxMatchesExplicit: boolean; + contextLines: number; + maxMatches: number; +} + +export function buildGrepFileSuccessPayload( + result: GrepFileResult, + options: BuildGrepFilePayloadOptions, +): LeanGrepFileEnvelope { + const matches: LeanGrepMatch[] = result.matches.map((m) => projectMatch(m)); + + const envelope: LeanGrepFileEnvelope = { + pattern: options.pattern, + // Prefer the backend's echoed path (may be normalised); fall + // back to the caller's input so `path` is always present. + path: result.filePath ?? options.path, + totalMatches: result.totalMatches, + hasMore: result.hasMore, + matches, + }; + + if (options.registry) envelope.registry = options.registry; + if (options.name) envelope.name = options.name; + if (options.repoUrl) envelope.repoUrl = options.repoUrl; + if (options.gitRef) envelope.gitRef = options.gitRef; + if (result.language) envelope.language = result.language; + if (result.totalLines != null) envelope.totalLines = result.totalLines; + if (result.indexedVersion) envelope.indexedVersion = result.indexedVersion; + if (result.resolution) + envelope.resolution = projectResolution(result.resolution); + if (result.hint) envelope.hint = result.hint; + + const filter = buildFilterBlock(options); + if (filter) envelope.filter = filter; + + return envelope; +} + +function projectMatch(match: GrepMatch): LeanGrepMatch { + const lean: LeanGrepMatch = { + lineNumber: match.lineNumber, + lineContent: match.lineContent, + }; + if (match.contextBefore && match.contextBefore.length > 0) { + lean.contextBefore = match.contextBefore; + } + if (match.contextAfter && match.contextAfter.length > 0) { + lean.contextAfter = match.contextAfter; + } + return lean; +} + +function projectResolution( + resolution: GrepFileResult["resolution"], +): LeanGrepResolution | undefined { + if (!resolution) return undefined; + const lean: LeanGrepResolution = {}; + if (resolution.requestedVersion) + lean.requestedVersion = resolution.requestedVersion; + if (resolution.requestedRef) lean.requestedRef = resolution.requestedRef; + if (resolution.resolvedRef) lean.resolvedRef = resolution.resolvedRef; + if (resolution.commitSha) lean.commitSha = resolution.commitSha; + return Object.keys(lean).length > 0 ? lean : undefined; +} + +function buildFilterBlock( + options: BuildGrepFilePayloadOptions, +): LeanGrepFilter | undefined { + const filter: LeanGrepFilter = {}; + if (options.contextLinesExplicit) filter.contextLines = options.contextLines; + if (options.maxMatchesExplicit) filter.maxMatches = options.maxMatches; + return Object.keys(filter).length > 0 ? filter : undefined; +} + +// -------------------------------------------------------------------- +// Terminal formatter (CLI-only). +// -------------------------------------------------------------------- + +export interface FormatGrepFileTerminalOptions { + useColors: boolean; + /** + * When `true`, render a contextual header plus a line-number gutter + * (`>` marker on match lines). When `false` (default), emit matching + * lines only, no header, no line numbers — pipe-friendly like + * `grep` default output. + */ + verbose?: boolean; +} + +/** + * Render result split into stdout (clean, pipeable payload) and + * stderr (human-facing hints — truncation warnings, diagnostics). + * Callers write each stream independently so plain-mode pipes + * stay uncorrupted by informational text. + */ +export interface FormattedGrepFileTerminal { + stdout: string; + stderr?: string; +} + +/** + * Terminal rendering for `code grep`. + * + * Plain (default) mode mirrors `grep`'s default output: matching + * lines printed as raw content, one per line, no header, no line + * numbers. When `--context` is non-zero, context lines are included + * in-line (still no line numbers) and distinct blocks are separated + * by `--` in grep's convention. + * + * Verbose mode adds a contextual header, a right-aligned + * line-number gutter, and a `>` marker on match lines to + * distinguish them from context at a glance. + * + * Overlapping context blocks — two nearby matches whose contexts + * touch or overlap — are merged into a single block so no line is + * printed twice. This matches `grep -C` / `rg -C` behaviour. + */ +export function formatGrepFileTerminal( + envelope: LeanGrepFileEnvelope, + options: FormatGrepFileTerminalOptions, +): FormattedGrepFileTerminal { + const verbose = options.verbose ?? false; + + if (envelope.matches.length === 0) { + return formatNoMatches(envelope, options, verbose); + } + + const blocks = mergeMatchBlocks(envelope.matches); + + if (!verbose) { + return formatPlain(envelope, blocks, options); + } + return formatVerbose(envelope, blocks, options); +} + +// -------------------------------------------------------------------- +// Block merging. +// -------------------------------------------------------------------- + +interface RenderLine { + lineNumber: number; + content: string; + isMatch: boolean; +} + +/** + * Flatten the per-match `{lineContent, contextBefore, contextAfter}` + * shape into ordered, deduplicated blocks of lines. When two matches + * sit close enough that their contexts overlap or touch, the + * resulting lines merge into one block without duplicates — what + * `grep -C` calls "merging adjacent context lines". + */ +export function mergeMatchBlocks(matches: LeanGrepMatch[]): RenderLine[][] { + if (matches.length === 0) return []; + + const lineMap = new Map(); + for (const match of matches) { + const contextBefore = match.contextBefore ?? []; + const beforeStart = match.lineNumber - contextBefore.length; + for (let i = 0; i < contextBefore.length; i++) { + const ln = beforeStart + i; + if (!lineMap.has(ln)) { + lineMap.set(ln, { + lineNumber: ln, + content: contextBefore[i] ?? "", + isMatch: false, + }); + } + } + // The match line always wins over a context entry at the same + // line number (another match's contextBefore/contextAfter). + lineMap.set(match.lineNumber, { + lineNumber: match.lineNumber, + content: match.lineContent, + isMatch: true, + }); + const contextAfter = match.contextAfter ?? []; + for (let i = 0; i < contextAfter.length; i++) { + const ln = match.lineNumber + 1 + i; + if (!lineMap.has(ln)) { + lineMap.set(ln, { + lineNumber: ln, + content: contextAfter[i] ?? "", + isMatch: false, + }); + } + } + } + + const sorted = [...lineMap.values()].sort( + (a, b) => a.lineNumber - b.lineNumber, + ); + + const blocks: RenderLine[][] = []; + let current: RenderLine[] = []; + for (const line of sorted) { + const last = current[current.length - 1]; + if (!last || line.lineNumber === last.lineNumber + 1) { + current.push(line); + } else { + blocks.push(current); + current = [line]; + } + } + if (current.length > 0) blocks.push(current); + return blocks; +} + +// -------------------------------------------------------------------- +// Plain mode: matches only, optional merged context, no line numbers. +// -------------------------------------------------------------------- + +function formatPlain( + envelope: LeanGrepFileEnvelope, + blocks: RenderLine[][], + options: FormatGrepFileTerminalOptions, +): FormattedGrepFileTerminal { + const hasContext = blocks.some((block) => + block.some((line) => !line.isMatch), + ); + + const out: string[] = []; + blocks.forEach((block, i) => { + if (i > 0 && hasContext) { + // `grep -C` separator between distinct blocks. Without context, + // consecutive matches are printed as-is with no separator. + out.push(dim("--", options.useColors)); + } + for (const line of block) { + out.push(line.content); + } + }); + + // Trailing newline so output composes nicely in pipes / files. + out.push(""); + + const stderr: string[] = []; + if (envelope.hasMore) { + stderr.push( + dim( + "More matches available — pass --limit higher to fetch more.", + options.useColors, + ), + ); + } + + return { + stdout: out.join("\n"), + stderr: stderr.length > 0 ? `${stderr.join("\n")}\n` : undefined, + }; +} + +// -------------------------------------------------------------------- +// Verbose mode: header + gutter + block separators. +// -------------------------------------------------------------------- + +function formatVerbose( + envelope: LeanGrepFileEnvelope, + blocks: RenderLine[][], + options: FormatGrepFileTerminalOptions, +): FormattedGrepFileTerminal { + const lines: string[] = []; + lines.push(buildHeader(envelope, options)); + if (envelope.resolution || envelope.indexedVersion) { + lines.push(buildResolutionLine(envelope, options)); + } + lines.push(""); + + const gutterWidth = widestLineNumberInBlocks(blocks); + blocks.forEach((block, i) => { + if (i > 0) lines.push(dim("--", options.useColors)); + for (const line of block) { + lines.push(renderVerboseLine(line, gutterWidth, options)); + } + }); + + if (envelope.hasMore) { + lines.push(""); + lines.push( + dim( + "More matches available — pass --limit higher to fetch more.", + options.useColors, + ), + ); + } + + lines.push(""); + return { stdout: lines.join("\n") }; +} + +function renderVerboseLine( + line: RenderLine, + gutterWidth: number, + options: FormatGrepFileTerminalOptions, +): string { + const gutter = padLeft(String(line.lineNumber), gutterWidth); + if (line.isMatch) { + const marker = colorize(">", "bold", options.useColors); + return `${marker} ${gutter} ${colorize(line.content, "bold", options.useColors)}`; + } + return ` ${dim(gutter, options.useColors)} ${dim(line.content, options.useColors)}`; +} + +// -------------------------------------------------------------------- +// Zero-match path. +// -------------------------------------------------------------------- + +function formatNoMatches( + envelope: LeanGrepFileEnvelope, + options: FormatGrepFileTerminalOptions, + verbose: boolean, +): FormattedGrepFileTerminal { + // Plain mode: match `grep`'s behaviour — silent on stdout, exit + // code carries the "no match" signal. If the pattern looks like + // a regex, write a single-line nudge to stderr so humans piping + // see it without polluting the pipe. + if (!verbose) { + if (looksLikeRegexAttempt(envelope.pattern)) { + return { + stdout: "", + stderr: `${dim("Note: pattern matched literally — this tool does case-insensitive substring search, not regex.", options.useColors)}\n`, + }; + } + return { stdout: "" }; + } + + const lines: string[] = []; + lines.push(buildHeader(envelope, options)); + if (envelope.resolution || envelope.indexedVersion) { + lines.push(buildResolutionLine(envelope, options)); + } + lines.push(""); + + if (envelope.hint) { + lines.push(dim(envelope.hint, options.useColors)); + } else { + lines.push( + dim( + `No matches for '${envelope.pattern}' in ${envelope.path}.`, + options.useColors, + ), + ); + } + if (looksLikeRegexAttempt(envelope.pattern)) { + lines.push( + dim( + "If you intended regex syntax, note: this tool does literal substring matching.", + options.useColors, + ), + ); + } + lines.push(""); + return { stdout: lines.join("\n") }; +} + +// -------------------------------------------------------------------- +// Header / identity helpers. +// -------------------------------------------------------------------- + +function buildHeader( + envelope: LeanGrepFileEnvelope, + options: FormatGrepFileTerminalOptions, +): string { + const identity = buildIdentityLabel(envelope); + const countLabel = envelope.hasMore + ? `${envelope.matches.length}+ matches` + : `${envelope.totalMatches} ${plural("match", "matches", envelope.totalMatches)}`; + return colorize( + `${identity} · ${countLabel} in ${envelope.path}`, + "bold", + options.useColors, + ); +} + +function buildIdentityLabel(envelope: LeanGrepFileEnvelope): string { + if (envelope.registry && envelope.name) { + return `${envelope.name} · ${envelope.registry}`; + } + if (envelope.repoUrl) { + return envelope.gitRef + ? `${envelope.repoUrl} @ ${envelope.gitRef}` + : envelope.repoUrl; + } + return "(unknown)"; +} + +function buildResolutionLine( + envelope: LeanGrepFileEnvelope, + options: FormatGrepFileTerminalOptions, +): string { + const parts: string[] = []; + const ref = envelope.resolution?.resolvedRef ?? envelope.indexedVersion; + if (ref) parts.push(`indexed at ${ref}`); + const commit = envelope.resolution?.commitSha; + if (commit) parts.push(`commit ${commit.slice(0, 7)}`); + return dim(parts.join(" · "), options.useColors); +} + +function widestLineNumberInBlocks(blocks: RenderLine[][]): number { + let max = 0; + for (const block of blocks) { + for (const line of block) { + const w = String(line.lineNumber).length; + if (w > max) max = w; + } + } + return max; +} + +function plural(singular: string, pluralForm: string, count: number): string { + return count === 1 ? singular : pluralForm; +} + +function padLeft(text: string, width: number): string { + return text.length >= width ? text : " ".repeat(width - text.length) + text; +} diff --git a/src/shared/list-files-request.test.ts b/src/shared/list-files-request.test.ts new file mode 100644 index 00000000..1e65b88f --- /dev/null +++ b/src/shared/list-files-request.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "bun:test"; +import type { CodeNavigationTarget } from "../services/index.js"; +import { buildListFilesParams } from "./list-files-request.js"; + +const packageTarget: CodeNavigationTarget = { + registry: "NPM", + packageName: "express", +}; + +describe("buildListFilesParams — defaults + passthrough", () => { + it("substitutes the default limit (200) when omitted", () => { + const { params, effectiveLimit, limitExplicit } = buildListFilesParams({ + target: packageTarget, + }); + expect(params.limit).toBe(200); + expect(effectiveLimit).toBe(200); + expect(limitExplicit).toBe(false); + }); + + it("passes an explicit limit through and marks it explicit", () => { + const { params, effectiveLimit, limitExplicit } = buildListFilesParams({ + target: packageTarget, + limit: 50, + }); + expect(params.limit).toBe(50); + expect(effectiveLimit).toBe(50); + expect(limitExplicit).toBe(true); + }); + + it("passes waitTimeoutMs through when valid", () => { + const { params } = buildListFilesParams({ + target: packageTarget, + waitTimeoutMs: 5000, + }); + expect(params.waitTimeoutMs).toBe(5000); + }); + + it("substitutes the shared DEFAULT_WAIT_TIMEOUT_MS (20000) when omitted", () => { + const { params } = buildListFilesParams({ target: packageTarget }); + expect(params.waitTimeoutMs).toBe(20000); + }); + + it("passes a trimmed pathPrefix through and marks it explicit", () => { + const { params, pathPrefixExplicit } = buildListFilesParams({ + target: packageTarget, + pathPrefix: " src/ ", + }); + expect(params.pathPrefix).toBe("src/"); + expect(pathPrefixExplicit).toBe(true); + }); + + it("treats whitespace-only pathPrefix as absent", () => { + const { params, pathPrefixExplicit } = buildListFilesParams({ + target: packageTarget, + pathPrefix: " ", + }); + expect(params.pathPrefix).toBeUndefined(); + expect(pathPrefixExplicit).toBe(false); + }); +}); + +describe("buildListFilesParams — limit bounds", () => { + it.each([0, 1001, 3.5, -1])("rejects out-of-range limit %s", (limit) => { + expect(() => + buildListFilesParams({ target: packageTarget, limit }), + ).toThrow(/between 1 and 1000/); + }); + + it("accepts limits at the boundaries (1 and 1000)", () => { + expect( + buildListFilesParams({ target: packageTarget, limit: 1 }).params.limit, + ).toBe(1); + expect( + buildListFilesParams({ target: packageTarget, limit: 1000 }).params.limit, + ).toBe(1000); + }); +}); + +describe("buildListFilesParams — waitTimeoutMs bounds", () => { + it.each([ + -1, 60001, 3.5, + ])("rejects out-of-range waitTimeoutMs %s", (waitTimeoutMs) => { + expect(() => + buildListFilesParams({ target: packageTarget, waitTimeoutMs }), + ).toThrow(/between 0 and 60000/); + }); + + it("accepts 0 (fail-fast mode) at the lower boundary", () => { + expect( + buildListFilesParams({ target: packageTarget, waitTimeoutMs: 0 }).params + .waitTimeoutMs, + ).toBe(0); + }); + + it("accepts 60000 at the upper boundary", () => { + expect( + buildListFilesParams({ target: packageTarget, waitTimeoutMs: 60000 }) + .params.waitTimeoutMs, + ).toBe(60000); + }); +}); diff --git a/src/shared/list-files-request.ts b/src/shared/list-files-request.ts new file mode 100644 index 00000000..7a20b6b1 --- /dev/null +++ b/src/shared/list-files-request.ts @@ -0,0 +1,96 @@ +/** + * Shared request builder for the `list_files` tool. CLI and MCP + * normalise inputs here so the two surfaces cannot diverge on + * addressing or limit validation. Addressing XOR is delegated to + * the shipped `resolveCodeTarget` helper; this module owns the + * tool-specific bounds. + */ + +import type { + CodeNavigationTarget, + ListFilesParams, +} from "../services/index.js"; +import { DEFAULT_WAIT_TIMEOUT_MS } from "./code-navigation-defaults.js"; +import { InvalidPackageSpecError } from "./package-spec.js"; + +/** Mirrors the backend input bounds; callers stay below these. */ +const LIMIT_MIN = 1; +const LIMIT_MAX = 1000; +const LIMIT_DEFAULT = 200; + +const WAIT_MIN = 0; +const WAIT_MAX = 60_000; + +export interface ListFilesRequestInput { + target: CodeNavigationTarget; + pathPrefix?: string; + limit?: number; + waitTimeoutMs?: number; +} + +export interface ListFilesRequestBuildResult { + params: ListFilesParams; + /** + * Limit that was actually sent on the wire. Emitted in the + * envelope's `filter.limit` when the caller supplied one + * explicitly; when omitted, the builder substitutes the default + * and the envelope omits `filter.limit`. + */ + effectiveLimit: number; + /** + * True iff the caller explicitly supplied a `limit` (so the + * envelope knows to echo it under `filter.limit`). + */ + limitExplicit: boolean; + pathPrefixExplicit: boolean; +} + +export function buildListFilesParams( + input: ListFilesRequestInput, +): ListFilesRequestBuildResult { + const limitExplicit = input.limit !== undefined; + const limit = normaliseLimit(input.limit); + + const waitTimeoutMs = normaliseWaitTimeoutMs(input.waitTimeoutMs); + + const pathPrefix = normalisePathPrefix(input.pathPrefix); + const pathPrefixExplicit = pathPrefix !== undefined; + + return { + params: { + target: input.target, + pathPrefix, + limit, + waitTimeoutMs, + }, + effectiveLimit: limit, + limitExplicit, + pathPrefixExplicit, + }; +} + +function normaliseLimit(raw: number | undefined): number { + if (raw === undefined) return LIMIT_DEFAULT; + if (!Number.isInteger(raw) || raw < LIMIT_MIN || raw > LIMIT_MAX) { + throw new InvalidPackageSpecError( + `\`limit\` must be an integer between ${LIMIT_MIN} and ${LIMIT_MAX}. Got ${raw}.`, + ); + } + return raw; +} + +function normaliseWaitTimeoutMs(raw: number | undefined): number { + if (raw === undefined) return DEFAULT_WAIT_TIMEOUT_MS; + if (!Number.isInteger(raw) || raw < WAIT_MIN || raw > WAIT_MAX) { + throw new InvalidPackageSpecError( + `\`wait_timeout_ms\` must be an integer between ${WAIT_MIN} and ${WAIT_MAX}. Got ${raw}.`, + ); + } + return raw; +} + +function normalisePathPrefix(raw: string | undefined): string | undefined { + if (raw === undefined) return undefined; + const trimmed = raw.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} diff --git a/src/shared/list-files-response.test.ts b/src/shared/list-files-response.test.ts new file mode 100644 index 00000000..23af52d5 --- /dev/null +++ b/src/shared/list-files-response.test.ts @@ -0,0 +1,250 @@ +import { describe, expect, it } from "bun:test"; +import type { ListFilesResult } from "../services/index.js"; +import { + buildListFilesSuccessPayload, + formatListFilesTerminal, +} from "./list-files-response.js"; + +const baseResult: ListFilesResult = { + files: [ + { + path: "src/index.js", + name: "index.js", + language: "javascript", + fileType: "SOURCE", + byteSize: 1234, + }, + { + path: "src/lib/app.js", + name: "app.js", + language: "javascript", + fileType: "SOURCE", + byteSize: 8500, + }, + ], + total: 2, + hasMore: false, + indexedVersion: "v5.2.1", + resolution: { + requestedVersion: undefined, + requestedRef: undefined, + resolvedRef: "v5.2.1", + commitSha: "abc123def456", + }, + hint: undefined, +}; + +const baseOptions = { + registry: "npm", + name: "express", + limitExplicit: false, + pathPrefixExplicit: false, +}; + +describe("buildListFilesSuccessPayload", () => { + it("projects the basic envelope shape for spec addressing", () => { + const envelope = buildListFilesSuccessPayload(baseResult, baseOptions); + expect(envelope.registry).toBe("npm"); + expect(envelope.name).toBe("express"); + expect(envelope.repoUrl).toBeUndefined(); + expect(envelope.total).toBe(2); + expect(envelope.hasMore).toBe(false); + expect(envelope.files.length).toBe(2); + expect(envelope.files[0]).toEqual({ + path: "src/index.js", + name: "index.js", + language: "javascript", + fileType: "SOURCE", + byteSize: 1234, + }); + expect(envelope.indexedVersion).toBe("v5.2.1"); + expect(envelope.resolution).toEqual({ + resolvedRef: "v5.2.1", + commitSha: "abc123def456", + }); + expect(envelope.filter).toBeUndefined(); + expect(envelope.hint).toBeUndefined(); + }); + + it("emits filter.limit only when the caller supplied it explicitly", () => { + const withoutFilter = buildListFilesSuccessPayload(baseResult, baseOptions); + expect(withoutFilter.filter).toBeUndefined(); + + const withFilter = buildListFilesSuccessPayload(baseResult, { + ...baseOptions, + limit: 100, + limitExplicit: true, + }); + expect(withFilter.filter).toEqual({ limit: 100 }); + }); + + it("echoes pathPrefix in filter only when explicit", () => { + const envelope = buildListFilesSuccessPayload(baseResult, { + ...baseOptions, + pathPrefix: "src/", + pathPrefixExplicit: true, + }); + expect(envelope.filter).toEqual({ pathPrefix: "src/" }); + }); + + it("emits repoUrl + gitRef for repo-URL addressing", () => { + const envelope = buildListFilesSuccessPayload(baseResult, { + limitExplicit: false, + pathPrefixExplicit: false, + repoUrl: "https://github.com/expressjs/express", + gitRef: "main", + }); + expect(envelope.registry).toBeUndefined(); + expect(envelope.name).toBeUndefined(); + expect(envelope.repoUrl).toBe("https://github.com/expressjs/express"); + expect(envelope.gitRef).toBe("main"); + }); + + it("emits hint when the backend supplied one", () => { + const envelope = buildListFilesSuccessPayload( + { ...baseResult, files: [], total: 0, hint: "No files match src/foo/" }, + baseOptions, + ); + expect(envelope.files).toEqual([]); + expect(envelope.total).toBe(0); + expect(envelope.hint).toBe("No files match src/foo/"); + }); + + it("strips null per-entry fields", () => { + const envelope = buildListFilesSuccessPayload( + { + ...baseResult, + files: [ + { + path: "src/only-path.txt", + name: undefined, + language: undefined, + fileType: undefined, + byteSize: undefined, + }, + ], + }, + baseOptions, + ); + expect(envelope.files[0]).toEqual({ path: "src/only-path.txt" }); + }); +}); + +describe("formatListFilesTerminal", () => { + it("plain mode: stdout is bare paths only — no header, no classification", () => { + const envelope = buildListFilesSuccessPayload(baseResult, baseOptions); + const { stdout, stderr } = formatListFilesTerminal(envelope, { + useColors: false, + }); + expect(stdout).toContain("src/index.js"); + expect(stdout).toContain("src/lib/app.js"); + // Header and resolution context are verbose-only. + expect(stdout).not.toContain("express · npm"); + expect(stdout).not.toContain("indexed at v5.2.1"); + // Classification annotations are verbose-only. + expect(stdout).not.toContain("javascript"); + expect(stdout).not.toContain("SOURCE"); + expect(stdout).not.toContain("KB"); + // No stderr under happy-path plain mode. + expect(stderr).toBeUndefined(); + }); + + it("verbose mode: stdout carries header + classification annotations alongside paths", () => { + const envelope = buildListFilesSuccessPayload(baseResult, baseOptions); + const { stdout } = formatListFilesTerminal(envelope, { + verbose: true, + useColors: false, + }); + expect(stdout).toContain("express · npm"); + expect(stdout).toContain("2 files"); + expect(stdout).toContain("indexed at v5.2.1"); + expect(stdout).toContain("commit abc123d"); + expect(stdout).toContain("src/index.js"); + expect(stdout).toContain("javascript"); + expect(stdout).toContain("1.2 KB"); + expect(stdout).toContain("8.3 KB"); + }); + + it("verbose mode: uses the repo URL as identity for repo addressing", () => { + const envelope = buildListFilesSuccessPayload(baseResult, { + ...baseOptions, + registry: undefined, + name: undefined, + repoUrl: "https://github.com/expressjs/express", + gitRef: "main", + }); + const { stdout } = formatListFilesTerminal(envelope, { + verbose: true, + useColors: false, + }); + expect(stdout).toContain("https://github.com/expressjs/express @ main"); + }); + + it("plain mode hasMore: stdout stays clean; warning goes to stderr", () => { + const envelope = buildListFilesSuccessPayload( + { ...baseResult, total: 200, hasMore: true }, + { ...baseOptions, limit: 200, limitExplicit: true }, + ); + const { stdout, stderr } = formatListFilesTerminal(envelope, { + useColors: false, + }); + // stdout carries only paths — no truncation warning to poison pipes. + expect(stdout).not.toContain("More files available"); + expect(stdout).not.toContain("2+ files"); + // stderr carries the human-facing warning. + expect(stderr).toContain("More files available"); + expect(stderr).toContain("pass --limit higher"); + }); + + it("verbose mode hasMore: truncation warning included inline", () => { + const envelope = buildListFilesSuccessPayload( + { ...baseResult, total: 200, hasMore: true }, + { ...baseOptions, limit: 200, limitExplicit: true }, + ); + const { stdout } = formatListFilesTerminal(envelope, { + verbose: true, + useColors: false, + }); + // Backend `total` is capped to the returned count when hasMore is + // true — surface as "N+" so the truncation is obvious. + expect(stdout).toContain("2+ files"); + expect(stdout).toContain("More files available"); + }); + + it("plain mode empty: stdout silent; hint on stderr", () => { + const envelope = buildListFilesSuccessPayload( + { ...baseResult, files: [], total: 0, hint: "No files match src/foo/" }, + baseOptions, + ); + const { stdout, stderr } = formatListFilesTerminal(envelope, { + useColors: false, + }); + expect(stdout).toBe(""); + expect(stderr).toContain("No files match src/foo/"); + }); + + it("verbose mode empty: hint rendered inline under header", () => { + const envelope = buildListFilesSuccessPayload( + { ...baseResult, files: [], total: 0, hint: "No files match src/foo/" }, + baseOptions, + ); + const { stdout } = formatListFilesTerminal(envelope, { + verbose: true, + useColors: false, + }); + expect(stdout).toContain("express · npm"); + expect(stdout).toContain("No files match src/foo/"); + }); + + it("plain mode empty without hint: stderr carries fallback message", () => { + const envelope = buildListFilesSuccessPayload( + { ...baseResult, files: [], total: 0 }, + baseOptions, + ); + const { stdout, stderr } = formatListFilesTerminal(envelope, { + useColors: false, + }); + expect(stdout).toBe(""); + expect(stderr).toContain("No files match"); + }); +}); diff --git a/src/shared/list-files-response.ts b/src/shared/list-files-response.ts new file mode 100644 index 00000000..3b40a4b5 --- /dev/null +++ b/src/shared/list-files-response.ts @@ -0,0 +1,339 @@ +/** + * Response envelope for the `list_files` tool. Shared across CLI + * `--json` output and MCP `content[0].text`. Terminal formatter is + * CLI-only; both surfaces read the same envelope shape. + * + * Design commitments (match the shipped pkg-intel envelope playbook): + * + * - **Data-first.** `files` is always present (possibly empty); + * `resolution` appears whenever the backend returned one; `hint` + * appears when empty results carry a backend diagnostic. + * - **No indexing metadata in the success envelope.** The service + * layer promotes `indexingStatus: INDEXING` to a typed error + * before the envelope builder runs, so agents never branch on a + * data-path indexing flag. + * - **`filter.*` echoes only caller-supplied inputs.** The default + * limit (200) is not echoed; an explicit limit is. + */ + +import type { ListFilesResult, RepoFileEntry } from "../services/index.js"; +import { colorize, dim } from "./colors.js"; + +export interface LeanRepoFileEntry { + path: string; + name?: string; + language?: string; + fileType?: string; + byteSize?: number; +} + +export interface LeanListFilesResolution { + requestedVersion?: string; + requestedRef?: string; + resolvedRef?: string; + commitSha?: string; +} + +export interface LeanListFilesFilter { + pathPrefix?: string; + limit?: number; +} + +export interface LeanListFilesEnvelope { + /** Present for spec addressing. */ + registry?: string; + /** Present for spec addressing. */ + name?: string; + /** Present for repo-URL addressing. */ + repoUrl?: string; + gitRef?: string; + /** Resolved backend version tag / commit. Always present when the + * backend returned a resolution block. */ + indexedVersion?: string; + resolution?: LeanListFilesResolution; + total: number; + hasMore: boolean; + files: LeanRepoFileEntry[]; + /** Backend diagnostic (e.g. "No files match this path prefix.") + * when the result set is empty. */ + hint?: string; + /** Caller's explicit filter inputs; default values never echo. */ + filter?: LeanListFilesFilter; +} + +export interface BuildListFilesPayloadOptions { + /** Caller's addressing echo. */ + registry?: string; + name?: string; + repoUrl?: string; + gitRef?: string; + /** Whether the caller supplied an explicit `limit`. */ + limitExplicit: boolean; + /** Whether the caller supplied an explicit `path_prefix`. */ + pathPrefixExplicit: boolean; + /** Caller's raw inputs, echoed under `filter.*` when explicit. */ + pathPrefix?: string; + limit?: number; +} + +export function buildListFilesSuccessPayload( + result: ListFilesResult, + options: BuildListFilesPayloadOptions, +): LeanListFilesEnvelope { + const files: LeanRepoFileEntry[] = result.files.map((entry) => + projectEntry(entry), + ); + + const envelope: LeanListFilesEnvelope = { + total: result.total, + hasMore: result.hasMore, + files, + }; + + if (options.registry) envelope.registry = options.registry; + if (options.name) envelope.name = options.name; + if (options.repoUrl) envelope.repoUrl = options.repoUrl; + if (options.gitRef) envelope.gitRef = options.gitRef; + if (result.indexedVersion) envelope.indexedVersion = result.indexedVersion; + if (result.resolution) + envelope.resolution = projectResolution(result.resolution); + if (result.hint) envelope.hint = result.hint; + + const filter = buildFilterBlock(options); + if (filter) envelope.filter = filter; + + return envelope; +} + +function projectEntry(entry: RepoFileEntry): LeanRepoFileEntry { + const lean: LeanRepoFileEntry = { path: entry.path }; + if (entry.name != null) lean.name = entry.name; + if (entry.language != null) lean.language = entry.language; + if (entry.fileType != null) lean.fileType = entry.fileType; + if (entry.byteSize != null) lean.byteSize = entry.byteSize; + return lean; +} + +function projectResolution( + resolution: ListFilesResult["resolution"], +): LeanListFilesResolution | undefined { + if (!resolution) return undefined; + const lean: LeanListFilesResolution = {}; + if (resolution.requestedVersion) + lean.requestedVersion = resolution.requestedVersion; + if (resolution.requestedRef) lean.requestedRef = resolution.requestedRef; + if (resolution.resolvedRef) lean.resolvedRef = resolution.resolvedRef; + if (resolution.commitSha) lean.commitSha = resolution.commitSha; + return Object.keys(lean).length > 0 ? lean : undefined; +} + +function buildFilterBlock( + options: BuildListFilesPayloadOptions, +): LeanListFilesFilter | undefined { + const filter: LeanListFilesFilter = {}; + if (options.pathPrefixExplicit && options.pathPrefix) { + filter.pathPrefix = options.pathPrefix; + } + if (options.limitExplicit && options.limit !== undefined) { + filter.limit = options.limit; + } + return Object.keys(filter).length > 0 ? filter : undefined; +} + +// -------------------------------------------------------------------- +// Terminal formatter (CLI-only). +// -------------------------------------------------------------------- + +export interface FormatListFilesTerminalOptions { + verbose?: boolean; + useColors: boolean; +} + +/** + * Render result split into stdout (clean, pipeable payload) and + * stderr (human-facing hints — truncation warnings, diagnostics). + * Callers write each stream independently so plain-mode pipes + * stay uncorrupted by informational text. + */ +export interface FormattedListFilesTerminal { + stdout: string; + stderr?: string; +} + +export function formatListFilesTerminal( + envelope: LeanListFilesEnvelope, + options: FormatListFilesTerminalOptions, +): FormattedListFilesTerminal { + const verbose = options.verbose ?? false; + + if (envelope.files.length === 0) { + return formatEmpty(envelope, options, verbose); + } + + if (verbose) { + return formatVerbose(envelope, options); + } + return formatPlain(envelope, options); +} + +function formatPlain( + envelope: LeanListFilesEnvelope, + options: FormatListFilesTerminalOptions, +): FormattedListFilesTerminal { + const stdoutLines: string[] = []; + for (const file of envelope.files) { + stdoutLines.push(file.path); + } + stdoutLines.push(""); + + const stderrLines: string[] = []; + if (envelope.hasMore) { + stderrLines.push( + dim( + "More files available — pass --limit higher to fetch more.", + options.useColors, + ), + ); + } + + return { + stdout: stdoutLines.join("\n"), + stderr: stderrLines.length > 0 ? `${stderrLines.join("\n")}\n` : undefined, + }; +} + +function formatVerbose( + envelope: LeanListFilesEnvelope, + options: FormatListFilesTerminalOptions, +): FormattedListFilesTerminal { + const lines: string[] = []; + lines.push(buildSummaryHeader(envelope, options)); + if (envelope.resolution || envelope.indexedVersion) { + lines.push(buildResolutionLine(envelope, options)); + } + lines.push(""); + + const pathWidth = longestPathLength(envelope.files); + for (const file of envelope.files) { + lines.push(formatVerboseFileRow(file, pathWidth, options)); + } + + if (envelope.hasMore) { + lines.push(""); + lines.push( + dim( + "More files available — pass --limit higher to fetch more.", + options.useColors, + ), + ); + } + + if (envelope.hint) { + lines.push(""); + lines.push(dim(envelope.hint, options.useColors)); + } + + lines.push(""); + return { stdout: lines.join("\n") }; +} + +function formatEmpty( + envelope: LeanListFilesEnvelope, + options: FormatListFilesTerminalOptions, + verbose: boolean, +): FormattedListFilesTerminal { + const hint = envelope.hint ?? "No files match the requested path prefix."; + if (!verbose) { + // Plain-mode stdout stays empty so pipes don't see hint text. + // The hint goes to stderr — humans still see it, downstream + // tools don't. + return { stdout: "", stderr: `${dim(hint, options.useColors)}\n` }; + } + const lines: string[] = []; + lines.push(buildSummaryHeader(envelope, options)); + if (envelope.resolution || envelope.indexedVersion) { + lines.push(buildResolutionLine(envelope, options)); + } + lines.push(""); + lines.push(dim(hint, options.useColors)); + lines.push(""); + return { stdout: lines.join("\n") }; +} + +function buildSummaryHeader( + envelope: LeanListFilesEnvelope, + options: FormatListFilesTerminalOptions, +): string { + const identity = buildIdentityLabel(envelope); + // Backend reports `total` as the count actually returned (capped + // by `limit`), not the true matching count. When `hasMore: true` + // we can't surface a real total, so render as `N+ files` to make + // the truncation obvious. + const countValue = envelope.hasMore + ? `${envelope.files.length}+` + : String(envelope.total); + const counts = `${countValue} ${plural("file", "files", envelope.files.length)}`; + return colorize(`${identity} · ${counts}`, "bold", options.useColors); +} + +function buildResolutionLine( + envelope: LeanListFilesEnvelope, + options: FormatListFilesTerminalOptions, +): string { + const parts: string[] = []; + const ref = envelope.resolution?.resolvedRef ?? envelope.indexedVersion; + if (ref) parts.push(`indexed at ${ref}`); + const commit = envelope.resolution?.commitSha; + if (commit) parts.push(`commit ${commit.slice(0, 7)}`); + return dim(parts.join(" · "), options.useColors); +} + +function buildIdentityLabel(envelope: LeanListFilesEnvelope): string { + if (envelope.registry && envelope.name) { + return `${envelope.name} · ${envelope.registry}`; + } + if (envelope.repoUrl) { + return envelope.gitRef + ? `${envelope.repoUrl} @ ${envelope.gitRef}` + : envelope.repoUrl; + } + return "(unknown)"; +} + +function formatVerboseFileRow( + file: LeanRepoFileEntry, + pathWidth: number, + options: FormatListFilesTerminalOptions, +): string { + const annotations: string[] = []; + if (file.language) annotations.push(file.language); + if (file.fileType) annotations.push(file.fileType); + if (file.byteSize != null) annotations.push(humanBytes(file.byteSize)); + const annotation = annotations.length + ? dim(`· ${annotations.join(" · ")}`, options.useColors) + : ""; + const paddedPath = padRight(file.path, pathWidth); + return `${paddedPath} ${annotation}`.trimEnd(); +} + +function humanBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function plural(singular: string, pluralForm: string, count: number): string { + return count === 1 ? singular : pluralForm; +} + +function longestPathLength(entries: LeanRepoFileEntry[]): number { + let max = 0; + for (const entry of entries) { + if (entry.path.length > max) max = entry.path.length; + } + return max; +} + +function padRight(text: string, width: number): string { + return text.length >= width ? text : text + " ".repeat(width - text.length); +} diff --git a/src/shared/package-dependencies-response.ts b/src/shared/package-dependencies-response.ts index 4e610fbf..4b3462ec 100644 --- a/src/shared/package-dependencies-response.ts +++ b/src/shared/package-dependencies-response.ts @@ -37,8 +37,8 @@ * for graph visualisation. `uniqueDependencies` is subsumed by * `packages[]`. `groups.environmentConstraints` remains raw * `GenericJSON[]` pending a live observation to type it against. - * - **No v-prefix normalisation.** Inherited from P2; tag-style - * inputs are rejected in the request builder before we get here. + * - **No v-prefix normalisation.** Tag-style inputs are rejected in + * the request builder before we get here. * - **Terminal-only dedup.** JSON preserves every tuple the backend * sent (including Crates target-cfg duplicates). Terminal * rendering strips duplicates inside each group for scannability. @@ -1126,8 +1126,8 @@ function sortAlphabetically( // v: number // format version marker // } // -// The decoder also tolerates the object-shape documented by -// `pkgseer-cli` (`n: { id: { n, v?, l? } }`) so that if backend +// The decoder also tolerates an alternative object-shape +// variant (`n: { id: { n, v?, l? } }`) so that if backend // formats diverge we don't break the terminal — provenance just // silently stops rendering. // -------------------------------------------------------------------- diff --git a/src/shared/read-file-request.test.ts b/src/shared/read-file-request.test.ts new file mode 100644 index 00000000..a8828518 --- /dev/null +++ b/src/shared/read-file-request.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "bun:test"; +import type { CodeNavigationTarget } from "../services/index.js"; +import { buildReadFileParams } from "./read-file-request.js"; + +const target: CodeNavigationTarget = { + registry: "NPM", + packageName: "express", +}; + +describe("buildReadFileParams — defaults and validation", () => { + it("accepts a minimal request and defaults wait to 20000", () => { + const { params, startLineExplicit, endLineExplicit } = buildReadFileParams({ + target, + filePath: "src/index.js", + }); + expect(params.filePath).toBe("src/index.js"); + expect(params.startLine).toBeUndefined(); + expect(params.endLine).toBeUndefined(); + expect(params.waitTimeoutMs).toBe(20000); + expect(startLineExplicit).toBe(false); + expect(endLineExplicit).toBe(false); + }); + + it("trims whitespace around filePath", () => { + const { params } = buildReadFileParams({ + target, + filePath: " src/index.js ", + }); + expect(params.filePath).toBe("src/index.js"); + }); + + it("rejects an empty filePath", () => { + expect(() => buildReadFileParams({ target, filePath: " " })).toThrow( + /required/, + ); + }); + + it("passes line range through", () => { + const { params, startLineExplicit, endLineExplicit } = buildReadFileParams({ + target, + filePath: "src/index.js", + startLine: 10, + endLine: 40, + }); + expect(params.startLine).toBe(10); + expect(params.endLine).toBe(40); + expect(startLineExplicit).toBe(true); + expect(endLineExplicit).toBe(true); + }); + + it("accepts open-ended start (end omitted)", () => { + const { params } = buildReadFileParams({ + target, + filePath: "src/index.js", + startLine: 10, + }); + expect(params.startLine).toBe(10); + expect(params.endLine).toBeUndefined(); + }); + + it("accepts open-ended end (start omitted)", () => { + const { params } = buildReadFileParams({ + target, + filePath: "src/index.js", + endLine: 40, + }); + expect(params.startLine).toBeUndefined(); + expect(params.endLine).toBe(40); + }); +}); + +describe("buildReadFileParams — rejection cases", () => { + it.each([ + 0, -1, 3.5, + ])("rejects non-positive/fractional startLine %s", (raw) => { + expect(() => + buildReadFileParams({ target, filePath: "src/index.js", startLine: raw }), + ).toThrow(/start_line.*positive integer/); + }); + + it("rejects a reversed range", () => { + expect(() => + buildReadFileParams({ + target, + filePath: "src/index.js", + startLine: 40, + endLine: 10, + }), + ).toThrow(/reversed/); + }); + + it.each([-1, 60001, 3.5])("rejects out-of-range waitTimeoutMs %s", (wait) => { + expect(() => + buildReadFileParams({ + target, + filePath: "src/index.js", + waitTimeoutMs: wait, + }), + ).toThrow(/between 0 and 60000/); + }); +}); diff --git a/src/shared/read-file-request.ts b/src/shared/read-file-request.ts new file mode 100644 index 00000000..792b8333 --- /dev/null +++ b/src/shared/read-file-request.ts @@ -0,0 +1,85 @@ +/** + * Shared request builder for the `read_file` tool. CLI and MCP + * normalise inputs here so the two surfaces cannot diverge on + * line-range validation or wait-timeout handling. + */ + +import type { + CodeNavigationTarget, + ReadFileParams, +} from "../services/index.js"; +import { DEFAULT_WAIT_TIMEOUT_MS } from "./code-navigation-defaults.js"; +import { InvalidPackageSpecError } from "./package-spec.js"; + +const WAIT_MIN = 0; +const WAIT_MAX = 60_000; + +export interface ReadFileRequestInput { + target: CodeNavigationTarget; + filePath: string; + startLine?: number; + endLine?: number; + waitTimeoutMs?: number; +} + +export interface ReadFileRequestBuildResult { + params: ReadFileParams; + startLineExplicit: boolean; + endLineExplicit: boolean; +} + +export function buildReadFileParams( + input: ReadFileRequestInput, +): ReadFileRequestBuildResult { + const filePath = input.filePath?.trim() ?? ""; + if (!filePath) { + throw new InvalidPackageSpecError( + "`file_path` is required — pass the path to the file within the package or repo.", + ); + } + + const startLine = normaliseLine(input.startLine, "start_line"); + const endLine = normaliseLine(input.endLine, "end_line"); + if (startLine !== undefined && endLine !== undefined && startLine > endLine) { + throw new InvalidPackageSpecError( + `Line range is reversed: start_line (${startLine}) must be ≤ end_line (${endLine}).`, + ); + } + + const waitTimeoutMs = normaliseWaitTimeoutMs(input.waitTimeoutMs); + + return { + params: { + target: input.target, + filePath, + startLine, + endLine, + waitTimeoutMs, + }, + startLineExplicit: input.startLine !== undefined, + endLineExplicit: input.endLine !== undefined, + }; +} + +function normaliseLine( + raw: number | undefined, + name: string, +): number | undefined { + if (raw === undefined) return undefined; + if (!Number.isInteger(raw) || raw < 1) { + throw new InvalidPackageSpecError( + `\`${name}\` must be a positive integer (lines are 1-indexed). Got ${raw}.`, + ); + } + return raw; +} + +function normaliseWaitTimeoutMs(raw: number | undefined): number { + if (raw === undefined) return DEFAULT_WAIT_TIMEOUT_MS; + if (!Number.isInteger(raw) || raw < WAIT_MIN || raw > WAIT_MAX) { + throw new InvalidPackageSpecError( + `\`wait_timeout_ms\` must be an integer between ${WAIT_MIN} and ${WAIT_MAX}. Got ${raw}.`, + ); + } + return raw; +} diff --git a/src/shared/read-file-response.test.ts b/src/shared/read-file-response.test.ts new file mode 100644 index 00000000..c2647698 --- /dev/null +++ b/src/shared/read-file-response.test.ts @@ -0,0 +1,239 @@ +import { describe, expect, it } from "bun:test"; +import type { ReadFileResult } from "../services/index.js"; +import { + buildReadFileSuccessPayload, + formatReadFileTerminal, +} from "./read-file-response.js"; + +const baseResult: ReadFileResult = { + filePath: "src/index.js", + language: "javascript", + totalLines: 5, + startLine: 1, + endLine: 5, + content: + "// Express entry point\n'use strict';\n\nmodule.exports = require('./lib/express');\n", + isBinary: false, +}; + +const baseOptions = { + registry: "npm", + name: "express", + requestedFilePath: "src/index.js", +}; + +describe("buildReadFileSuccessPayload", () => { + it("projects basic envelope shape", () => { + const envelope = buildReadFileSuccessPayload(baseResult, baseOptions); + expect(envelope.registry).toBe("npm"); + expect(envelope.name).toBe("express"); + expect(envelope.path).toBe("src/index.js"); + expect(envelope.language).toBe("javascript"); + expect(envelope.totalLines).toBe(5); + expect(envelope.startLine).toBe(1); + expect(envelope.endLine).toBe(5); + expect(envelope.content).toContain("Express entry point"); + expect(envelope.isBinary).toBeUndefined(); + }); + + it("falls back to requestedFilePath when the backend omits filePath (result-level)", () => { + const envelope = buildReadFileSuccessPayload( + { ...baseResult, filePath: undefined }, + baseOptions, + ); + expect(envelope.path).toBe("src/index.js"); + }); + + it("sets isBinary and omits content for binary files", () => { + const envelope = buildReadFileSuccessPayload( + { + filePath: "assets/logo.png", + language: undefined, + totalLines: undefined, + startLine: undefined, + endLine: undefined, + content: undefined, + isBinary: true, + }, + { ...baseOptions, requestedFilePath: "assets/logo.png" }, + ); + expect(envelope.isBinary).toBe(true); + expect(envelope.content).toBeUndefined(); + }); + + it("surfaces repo-URL addressing when spec is absent", () => { + const envelope = buildReadFileSuccessPayload(baseResult, { + repoUrl: "https://github.com/expressjs/express", + gitRef: "main", + requestedFilePath: "src/index.js", + }); + expect(envelope.registry).toBeUndefined(); + expect(envelope.name).toBeUndefined(); + expect(envelope.repoUrl).toBe("https://github.com/expressjs/express"); + expect(envelope.gitRef).toBe("main"); + }); + + it("strips per-field nulls", () => { + const envelope = buildReadFileSuccessPayload( + { + filePath: "src/index.js", + language: undefined, + totalLines: undefined, + startLine: undefined, + endLine: undefined, + content: undefined, + isBinary: false, + }, + baseOptions, + ); + expect(envelope.language).toBeUndefined(); + expect(envelope.totalLines).toBeUndefined(); + expect(envelope.startLine).toBeUndefined(); + expect(envelope.endLine).toBeUndefined(); + expect(envelope.content).toBeUndefined(); + expect(envelope.isBinary).toBeUndefined(); + }); + + it("preserves empty-string content as distinct from absent", () => { + const envelope = buildReadFileSuccessPayload( + { ...baseResult, content: "" }, + baseOptions, + ); + expect(envelope.content).toBe(""); + }); +}); + +describe("formatReadFileTerminal", () => { + it("plain mode: emits raw content only (no header, no gutter)", () => { + const envelope = buildReadFileSuccessPayload(baseResult, baseOptions); + const output = formatReadFileTerminal(envelope, { useColors: false }); + // Content is verbatim — no path header, no line numbers. + expect(output).toBe(baseResult.content as string); + expect(output).not.toContain("src/index.js · javascript"); + expect(output).not.toMatch(/^\s*1\s+\/\//m); + }); + + it("verbose mode: renders header + gutter + content", () => { + const envelope = buildReadFileSuccessPayload(baseResult, baseOptions); + const output = formatReadFileTerminal(envelope, { + useColors: false, + verbose: true, + }); + expect(output).toContain("src/index.js · javascript · lines 1-5 of 5"); + expect(output).toContain("1 // Express entry point"); + expect(output).toContain("2 'use strict';"); + }); + + it("plain mode: binary sentinel only — no header", () => { + const envelope = buildReadFileSuccessPayload( + { + filePath: "assets/logo.png", + isBinary: true, + content: undefined, + language: undefined, + totalLines: undefined, + startLine: undefined, + endLine: undefined, + }, + { ...baseOptions, requestedFilePath: "assets/logo.png" }, + ); + const output = formatReadFileTerminal(envelope, { useColors: false }); + expect(output).toContain("Binary file — cannot display as text."); + expect(output).not.toContain("assets/logo.png"); + }); + + it("verbose mode: binary sentinel includes the header", () => { + const envelope = buildReadFileSuccessPayload( + { + filePath: "assets/logo.png", + isBinary: true, + content: undefined, + language: undefined, + totalLines: undefined, + startLine: undefined, + endLine: undefined, + }, + { ...baseOptions, requestedFilePath: "assets/logo.png" }, + ); + const output = formatReadFileTerminal(envelope, { + useColors: false, + verbose: true, + }); + expect(output).toContain("assets/logo.png"); + expect(output).toContain("Binary file — cannot display as text."); + }); + + it("verbose mode: omits language from header when missing", () => { + const envelope = buildReadFileSuccessPayload( + { ...baseResult, language: undefined }, + baseOptions, + ); + const output = formatReadFileTerminal(envelope, { + useColors: false, + verbose: true, + }); + expect(output).not.toContain("· undefined"); + expect(output).not.toContain("· null"); + expect(output).toContain("src/index.js"); + }); + + it("verbose mode: renders a line-only range label when totalLines is absent", () => { + const envelope = buildReadFileSuccessPayload( + { ...baseResult, totalLines: undefined }, + baseOptions, + ); + const output = formatReadFileTerminal(envelope, { + useColors: false, + verbose: true, + }); + expect(output).toContain("lines 1-5"); + expect(output).not.toContain("of undefined"); + }); + + it("verbose mode: pads the gutter to the widest line number in the slice", () => { + const envelope = buildReadFileSuccessPayload( + { + filePath: "src/big.js", + language: "javascript", + totalLines: 120, + startLine: 95, + endLine: 105, + content: Array.from({ length: 11 }, (_, i) => `line ${i + 95}`).join( + "\n", + ), + isBinary: false, + }, + baseOptions, + ); + const output = formatReadFileTerminal(envelope, { + useColors: false, + verbose: true, + }); + // Line numbers 95, 96, ..., 105 — widest is 3 digits. Expect + // right-aligned width. + expect(output).toContain(" 95 line 95"); + expect(output).toContain("105 line 105"); + }); + + it("plain mode: preserves empty-string content verbatim", () => { + const envelope = buildReadFileSuccessPayload( + { ...baseResult, content: "" }, + baseOptions, + ); + const output = formatReadFileTerminal(envelope, { useColors: false }); + expect(output).toBe(""); + }); + + it("verbose mode: empty content renders header with no gutter rows", () => { + const envelope = buildReadFileSuccessPayload( + { ...baseResult, content: "" }, + baseOptions, + ); + const output = formatReadFileTerminal(envelope, { + useColors: false, + verbose: true, + }); + expect(output).toContain("src/index.js"); + expect(output).not.toContain("1 "); + }); +}); diff --git a/src/shared/read-file-response.ts b/src/shared/read-file-response.ts new file mode 100644 index 00000000..1aa369fd --- /dev/null +++ b/src/shared/read-file-response.ts @@ -0,0 +1,192 @@ +/** + * Response envelope for `read_file`. Shared across CLI `--json` and + * MCP `content[0].text`; terminal formatter is CLI-only. + * + * Binary file handling: backend returns `isBinary: true` + + * `content: null`. The envelope keeps `isBinary: true` and omits + * `content` entirely — agents discriminate on the flag rather than + * checking a null content field. + */ + +import type { ReadFileResult } from "../services/index.js"; +import { colorize, dim } from "./colors.js"; + +export interface LeanReadFileEnvelope { + registry?: string; + name?: string; + repoUrl?: string; + gitRef?: string; + /** + * File path. Named `path` (not `filePath`) so the envelope key + * matches `list_files.files[].path` and `grep_file`'s `path` + * input — keeps the `list_files` → `read_file` / `grep_file` + * chain free of rename friction. + */ + path: string; + language?: string; + totalLines?: number; + startLine?: number; + endLine?: number; + content?: string; + /** Present and `true` when the file is binary; absent otherwise. */ + isBinary?: boolean; +} + +export interface BuildReadFilePayloadOptions { + registry?: string; + name?: string; + repoUrl?: string; + gitRef?: string; + /** The file_path the caller asked for; used as envelope fallback + * when the backend didn't echo it (rare). */ + requestedFilePath: string; +} + +export function buildReadFileSuccessPayload( + result: ReadFileResult, + options: BuildReadFilePayloadOptions, +): LeanReadFileEnvelope { + const envelope: LeanReadFileEnvelope = { + path: result.filePath ?? options.requestedFilePath, + }; + if (options.registry) envelope.registry = options.registry; + if (options.name) envelope.name = options.name; + if (options.repoUrl) envelope.repoUrl = options.repoUrl; + if (options.gitRef) envelope.gitRef = options.gitRef; + if (result.language != null) envelope.language = result.language; + if (result.totalLines != null) envelope.totalLines = result.totalLines; + if (result.startLine != null) envelope.startLine = result.startLine; + if (result.endLine != null) envelope.endLine = result.endLine; + if (result.isBinary) { + envelope.isBinary = true; + } else if (result.content != null) { + envelope.content = result.content; + } + return envelope; +} + +// -------------------------------------------------------------------- +// Terminal formatter (CLI-only). +// -------------------------------------------------------------------- + +export interface FormatReadFileTerminalOptions { + useColors: boolean; + /** + * When `true`, render the contextual header and a line-number + * gutter. When `false` (default), emit the raw file content so the + * output is pipe-friendly (`code read … | grep …`, `| wc -l`, etc). + */ + verbose?: boolean; +} + +/** + * Render a `read_file` envelope for human terminal output. + * + * Default (plain) mode emits file content verbatim — no header, + * no gutter. Verbose mode adds a contextual header and a + * right-aligned line-number gutter. Binary files render a one-line + * sentinel in both modes (to stderr-ish stdout) instead of bytes. + */ +export function formatReadFileTerminal( + envelope: LeanReadFileEnvelope, + options: FormatReadFileTerminalOptions, +): string { + const verbose = options.verbose ?? false; + + if (envelope.isBinary) { + return formatBinary(envelope, options, verbose); + } + + if (envelope.content == null) { + return formatNoContent(envelope, options, verbose); + } + + if (!verbose) { + // Emit content verbatim — preserves trailing newline if the + // backend included one, so `code read … > file` round-trips. + return envelope.content; + } + + return formatVerboseBody(envelope, options); +} + +function formatBinary( + envelope: LeanReadFileEnvelope, + options: FormatReadFileTerminalOptions, + verbose: boolean, +): string { + const sentinel = dim( + "Binary file — cannot display as text.", + options.useColors, + ); + if (verbose) { + return `${buildHeader(envelope, options)}\n\n${sentinel}\n`; + } + return `${sentinel}\n`; +} + +function formatNoContent( + envelope: LeanReadFileEnvelope, + options: FormatReadFileTerminalOptions, + verbose: boolean, +): string { + const sentinel = dim("(no content returned)", options.useColors); + if (verbose) { + return `${buildHeader(envelope, options)}\n\n${sentinel}\n`; + } + return `${sentinel}\n`; +} + +function formatVerboseBody( + envelope: LeanReadFileEnvelope, + options: FormatReadFileTerminalOptions, +): string { + const lines: string[] = []; + lines.push(buildHeader(envelope, options)); + lines.push(""); + + const content = envelope.content ?? ""; + const bodyLines = content.split("\n"); + // If the backend trailing-newline-terminates, drop the trailing + // empty line so the gutter doesn't render a ghost line. + if (bodyLines.length > 0 && bodyLines[bodyLines.length - 1] === "") { + bodyLines.pop(); + } + const startLine = envelope.startLine ?? 1; + const endLine = startLine + bodyLines.length - 1; + const gutterWidth = String(endLine).length; + for (let i = 0; i < bodyLines.length; i++) { + const lineNumber = startLine + i; + const gutter = dim( + String(lineNumber).padStart(gutterWidth, " "), + options.useColors, + ); + lines.push(`${gutter} ${bodyLines[i]}`); + } + lines.push(""); + return lines.join("\n"); +} + +function buildHeader( + envelope: LeanReadFileEnvelope, + options: FormatReadFileTerminalOptions, +): string { + const parts: string[] = [envelope.path]; + if (envelope.language) parts.push(envelope.language); + const rangeLabel = buildRangeLabel(envelope); + if (rangeLabel) parts.push(rangeLabel); + return colorize(parts.join(" · "), "bold", options.useColors); +} + +function buildRangeLabel(envelope: LeanReadFileEnvelope): string | undefined { + const { startLine, endLine, totalLines } = envelope; + if (startLine != null && endLine != null) { + return totalLines != null + ? `lines ${startLine}-${endLine} of ${totalLines}` + : `lines ${startLine}-${endLine}`; + } + if (totalLines != null) { + return `${totalLines} lines`; + } + return undefined; +} diff --git a/src/tools/code-navigation-shared.ts b/src/tools/code-navigation-shared.ts index 8a9dae43..5c08eb6c 100644 --- a/src/tools/code-navigation-shared.ts +++ b/src/tools/code-navigation-shared.ts @@ -119,6 +119,10 @@ export function resolveCodeTarget( function invalidTargetResult(message: string): ToolResult { return errorResult( - JSON.stringify({ error: message, code: "INVALID_ARGUMENT" }), + JSON.stringify({ + error: message, + code: "INVALID_ARGUMENT", + retryable: false, + }), ); } diff --git a/src/tools/grep-file-parity.test.ts b/src/tools/grep-file-parity.test.ts new file mode 100644 index 00000000..311edaba --- /dev/null +++ b/src/tools/grep-file-parity.test.ts @@ -0,0 +1,263 @@ +// PARITY TEST — enforces: +// 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, retryable, details? }. + +import { describe, expect, it, mock, spyOn } from "bun:test"; +import { + type PkgGrepCommandDependencies, + pkgGrepAction, +} from "../commands/code/grep.js"; +import { + CodeNavigationIndexingError, + CodeNavigationTargetNotFoundError, + type GrepFileResult, +} from "../services/index.js"; +import { + createMockCodeNavigationService, + defaultGrepFileResult, +} from "../services/test-helpers.js"; +import { createGrepFileTool } from "./grep-file.js"; + +function cliDeps( + overrides: Partial = {}, +): PkgGrepCommandDependencies { + return { + codeNavigationService: createMockCodeNavigationService(), + codeNavigationUrl: "https://pkgseer.dev", + hasValidToken: true, + mcpUrl: "https://mcp.example.com", + ...overrides, + }; +} + +async function cliJson( + first: string | undefined, + second: string | undefined, + third: string | undefined, + options: Parameters[3] = {}, + deps: PkgGrepCommandDependencies = 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 pkgGrepAction( + first, + second, + third, + { ...options, json: true }, + deps, + ); + } catch { + /* error paths call process.exit — caught */ + } + const raw = + (logSpy.mock.calls[0]?.[0] as string | undefined) ?? + (errSpy.mock.calls[0]?.[0] as string | undefined); + return raw ? JSON.parse(raw) : undefined; + } finally { + logSpy.mockRestore(); + errSpy.mockRestore(); + exitSpy.mockRestore(); + } +} + +interface McpArgs { + target: { + registry?: + | "npm" + | "pypi" + | "hex" + | "crates" + | "nuget" + | "maven" + | "zig" + | "vcpkg" + | "packagist"; + package_name?: string; + version?: string; + repo_url?: string; + git_ref?: string; + }; + path: string; + pattern: string; + context_lines?: number; + max_matches?: number; + wait_timeout_ms?: number; +} + +async function mcpJson( + args: McpArgs, + grepFileMock?: () => Promise, +): Promise { + const service = createMockCodeNavigationService( + grepFileMock ? { grepFile: grepFileMock as never } : {}, + ); + const tool = createGrepFileTool(service); + const result = await tool.handler(args, {}); + return JSON.parse(result.content[0]?.text ?? ""); +} + +describe("grep_file parity", () => { + it("PARITY-JSON-KEYS: happy package grep CLI === MCP", async () => { + const fn = mock(() => Promise.resolve(defaultGrepFileResult)); + const cli = await cliJson( + "npm:express", + "middleware", + "src/index.js", + {}, + cliDeps({ + codeNavigationService: createMockCodeNavigationService({ + grepFile: fn as never, + }), + }), + ); + const mcp = await mcpJson( + { + target: { registry: "npm", package_name: "express" }, + pattern: "middleware", + path: "src/index.js", + }, + fn as never, + ); + expect(cli).toEqual(mcp); + }); + + it("PARITY-JSON-KEYS: filter echoes context + max_matches on both surfaces", async () => { + const fn = mock(() => Promise.resolve(defaultGrepFileResult)); + const cli = await cliJson( + "npm:express", + "middleware", + "src/index.js", + { context: "5", limit: "100" }, + cliDeps({ + codeNavigationService: createMockCodeNavigationService({ + grepFile: fn as never, + }), + }), + ); + const mcp = await mcpJson( + { + target: { registry: "npm", package_name: "express" }, + pattern: "middleware", + path: "src/index.js", + context_lines: 5, + max_matches: 100, + }, + fn as never, + ); + expect(cli).toEqual(mcp); + expect( + (cli as { filter?: { contextLines?: number; maxMatches?: number } }) + .filter, + ).toEqual({ + contextLines: 5, + maxMatches: 100, + }); + }); + + it("PARITY-JSON-KEYS: repo-URL addressing CLI === MCP", async () => { + const fn = mock(() => Promise.resolve(defaultGrepFileResult)); + const cli = await cliJson( + "middleware", + "src/index.js", + undefined, + { + repoUrl: "https://github.com/expressjs/express", + gitRef: "main", + }, + cliDeps({ + codeNavigationService: createMockCodeNavigationService({ + grepFile: fn as never, + }), + }), + ); + const mcp = await mcpJson( + { + target: { + repo_url: "https://github.com/expressjs/express", + git_ref: "main", + }, + pattern: "middleware", + path: "src/index.js", + }, + fn as never, + ); + expect(cli).toEqual(mcp); + }); + + it("PARITY-ERROR-ENVELOPE: INDEXING identical on both surfaces", async () => { + const fn = mock(() => + Promise.reject( + new CodeNavigationIndexingError("Indexing...", "ref_abc", [ + { version: "4.21.0", ref: "v4.21.0" }, + ]), + ), + ); + const cli = await cliJson( + "npm:express", + "middleware", + "src/index.js", + {}, + cliDeps({ + codeNavigationService: createMockCodeNavigationService({ + grepFile: fn as never, + }), + }), + ); + const mcp = await mcpJson( + { + target: { registry: "npm", package_name: "express" }, + pattern: "middleware", + path: "src/index.js", + }, + fn as never, + ); + expect(cli).toEqual(mcp); + expect((cli as { code: string }).code).toBe("INDEXING"); + }); + + it("PARITY-ERROR-ENVELOPE: NOT_FOUND identical on both surfaces", async () => { + const fn = mock(() => + Promise.reject( + new CodeNavigationTargetNotFoundError("File not found in repository"), + ), + ); + const cli = await cliJson( + "npm:express", + "middleware", + "nope.js", + {}, + cliDeps({ + codeNavigationService: createMockCodeNavigationService({ + grepFile: fn as never, + }), + }), + ); + const mcp = await mcpJson( + { + target: { registry: "npm", package_name: "express" }, + pattern: "middleware", + path: "nope.js", + }, + fn as never, + ); + expect(cli).toEqual(mcp); + expect((cli as { code: string }).code).toBe("NOT_FOUND"); + }); + + it("PARITY-ERROR-ENVELOPE: INVALID_ARGUMENT for empty pattern on both surfaces", async () => { + const cli = await cliJson("npm:express", "", "src/index.js", {}); + const mcp = await mcpJson({ + target: { registry: "npm", package_name: "express" }, + pattern: "", + path: "src/index.js", + }); + expect(cli).toMatchObject({ code: "INVALID_ARGUMENT" }); + expect(mcp).toMatchObject({ code: "INVALID_ARGUMENT" }); + }); +}); diff --git a/src/tools/grep-file.test.ts b/src/tools/grep-file.test.ts new file mode 100644 index 00000000..a6588570 --- /dev/null +++ b/src/tools/grep-file.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, it, mock } from "bun:test"; +import { + CodeNavigationIndexingError, + CodeNavigationTargetNotFoundError, +} from "../services/index.js"; +import { + createMockCodeNavigationService, + defaultGrepFileResult, +} from "../services/test-helpers.js"; +import { createGrepFileTool } from "./grep-file.js"; + +function parseText(result: { content: Array<{ text: string }> }): unknown { + return JSON.parse(result.content[0]?.text ?? ""); +} + +describe("createGrepFileTool — metadata", () => { + it("registers the correct tool name, description, and schema keys", () => { + const tool = createGrepFileTool(createMockCodeNavigationService()); + expect(tool.name).toBe("grep_file"); + expect(tool.description).toContain("case-insensitive substring"); + expect(tool.description).toContain("not regex"); + expect(Object.keys(tool.schema).sort()).toEqual([ + "context_lines", + "max_matches", + "path", + "pattern", + "target", + "wait_timeout_ms", + ]); + expect(tool.annotations?.readOnlyHint).toBe(true); + }); +}); + +describe("createGrepFileTool — happy path", () => { + it("calls grepFile with the resolved target, path, pattern", async () => { + const grepFile = mock(() => Promise.resolve(defaultGrepFileResult)); + const service = createMockCodeNavigationService({ grepFile }); + const tool = createGrepFileTool(service); + + await tool.handler( + { + target: { registry: "npm", package_name: "express" }, + path: "src/index.js", + pattern: "middleware", + }, + {}, + ); + + const calls = grepFile.mock.calls as unknown as Array< + [{ target: { registry?: string }; path: string; pattern: string }] + >; + expect(calls[0]?.[0]?.target?.registry).toBe("NPM"); + expect(calls[0]?.[0]?.path).toBe("src/index.js"); + expect(calls[0]?.[0]?.pattern).toBe("middleware"); + }); + + it("emits envelope with matches + metadata", async () => { + const tool = createGrepFileTool(createMockCodeNavigationService()); + const result = await tool.handler( + { + target: { registry: "npm", package_name: "express" }, + path: "src/index.js", + pattern: "express();", + }, + {}, + ); + const payload = parseText(result) as { + registry: string; + pattern: string; + path: string; + totalMatches: number; + matches: Array<{ lineNumber: number }>; + }; + expect(payload.registry).toBe("npm"); + expect(payload.pattern).toBe("express();"); + expect(payload.path).toBe("src/index.js"); + expect(payload.totalMatches).toBe(1); + expect(payload.matches[0]?.lineNumber).toBe(4); + }); + + it("emits filter.contextLines + filter.maxMatches when explicit", async () => { + const tool = createGrepFileTool(createMockCodeNavigationService()); + const result = await tool.handler( + { + target: { registry: "npm", package_name: "express" }, + path: "src/index.js", + pattern: "middleware", + context_lines: 5, + max_matches: 100, + }, + {}, + ); + const payload = parseText(result) as { + filter?: { contextLines?: number; maxMatches?: number }; + }; + expect(payload.filter).toEqual({ contextLines: 5, maxMatches: 100 }); + }); +}); + +describe("createGrepFileTool — validation errors", () => { + it("returns INVALID_ARGUMENT for empty pattern", async () => { + const tool = createGrepFileTool(createMockCodeNavigationService()); + const result = await tool.handler( + { + target: { registry: "npm", package_name: "express" }, + path: "src/index.js", + pattern: "", + }, + {}, + ); + expect(result.isError).toBe(true); + expect((parseText(result) as { code: string }).code).toBe( + "INVALID_ARGUMENT", + ); + }); + + it("returns INVALID_ARGUMENT for empty path", async () => { + const tool = createGrepFileTool(createMockCodeNavigationService()); + const result = await tool.handler( + { + target: { registry: "npm", package_name: "express" }, + path: " ", + pattern: "middleware", + }, + {}, + ); + expect(result.isError).toBe(true); + expect((parseText(result) as { code: string }).code).toBe( + "INVALID_ARGUMENT", + ); + }); + + it("returns INVALID_ARGUMENT for pattern > 200 chars", async () => { + const tool = createGrepFileTool(createMockCodeNavigationService()); + const result = await tool.handler( + { + target: { registry: "npm", package_name: "express" }, + path: "src/index.js", + pattern: "a".repeat(201), + }, + {}, + ); + expect(result.isError).toBe(true); + const payload = parseText(result) as { code: string; error: string }; + expect(payload.code).toBe("INVALID_ARGUMENT"); + expect(payload.error).toContain("200"); + }); + + it("returns INVALID_ARGUMENT for context_lines out of range", async () => { + const tool = createGrepFileTool(createMockCodeNavigationService()); + const result = await tool.handler( + { + target: { registry: "npm", package_name: "express" }, + path: "src/index.js", + pattern: "middleware", + context_lines: 11, + }, + {}, + ); + expect(result.isError).toBe(true); + expect((parseText(result) as { code: string }).code).toBe( + "INVALID_ARGUMENT", + ); + }); +}); + +describe("createGrepFileTool — service errors", () => { + it("classifies CodeNavigationIndexingError as INDEXING", async () => { + const service = createMockCodeNavigationService({ + grepFile: mock(() => + Promise.reject( + new CodeNavigationIndexingError("Indexing...", "ref_abc"), + ), + ), + }); + const tool = createGrepFileTool(service); + const result = await tool.handler( + { + target: { registry: "npm", package_name: "express" }, + path: "src/index.js", + pattern: "middleware", + }, + {}, + ); + expect(result.isError).toBe(true); + expect((parseText(result) as { code: string }).code).toBe("INDEXING"); + }); + + it("classifies CodeNavigationTargetNotFoundError as NOT_FOUND", async () => { + const service = createMockCodeNavigationService({ + grepFile: mock(() => + Promise.reject( + new CodeNavigationTargetNotFoundError("Package not found"), + ), + ), + }); + const tool = createGrepFileTool(service); + const result = await tool.handler( + { + target: { registry: "npm", package_name: "ghost" }, + path: "src/index.js", + pattern: "middleware", + }, + {}, + ); + expect(result.isError).toBe(true); + expect((parseText(result) as { code: string }).code).toBe("NOT_FOUND"); + }); +}); diff --git a/src/tools/grep-file.ts b/src/tools/grep-file.ts new file mode 100644 index 00000000..0338afda --- /dev/null +++ b/src/tools/grep-file.ts @@ -0,0 +1,123 @@ +import { z } from "zod"; +import type { CodeNavigationService } from "../services/index.js"; +import { mapCodeNavigationError } from "../shared/code-navigation-error-map.js"; +import { + buildGrepFileParams, + GREP_PATTERN_SEMANTICS_NOTE, +} from "../shared/grep-file-request.js"; +import { buildGrepFileSuccessPayload } from "../shared/grep-file-response.js"; +import { toPkgseerRegistryLowercase } from "../shared/pkgseer-registry.js"; +import { + type CodeTargetArg, + codeTargetSchema, + resolveCodeTarget, +} from "./code-navigation-shared.js"; +import { errorResult, type ToolDefinition, textResult } from "./types.js"; + +export interface GrepFileArgs { + target: CodeTargetArg; + path: string; + pattern: string; + context_lines?: number; + max_matches?: number; + wait_timeout_ms?: number; +} + +const schema = { + target: codeTargetSchema, + path: z + .string() + .describe( + "Path to the file to search. Package addressing: package-relative. Repo addressing: repo-relative.", + ), + pattern: z + .string() + .describe( + `${GREP_PATTERN_SEMANTICS_NOTE} For symbol-shaped searches use \`search_symbols\` instead.`, + ), + context_lines: z + .number() + .optional() + .describe( + "Lines of context before and after each match (0–10, default 0 — matches only). Set explicitly when you need surrounding lines; nearby matches with overlapping context are returned unmerged (each match carries its own `contextBefore` / `contextAfter`).", + ), + max_matches: z + .number() + .optional() + .describe("Max matches to return (1–200, default 50)."), + wait_timeout_ms: z + .number() + .optional() + .describe( + "Max milliseconds to wait for indexing (0–60000, default 20000). On an `INDEXING` error envelope, retry with a longer timeout or pass a version from `details.availableVersions`.", + ), +}; + +const DESCRIPTION = + "Search within a single file for a case-insensitive substring " + + "(not regex). Returns matches only by default — pass " + + "`context_lines` for surrounding lines (0–10, default 0). " + + `${GREP_PATTERN_SEMANTICS_NOTE} ` + + "Response: `{pattern, path, totalMatches, hasMore, matches: " + + "[{lineNumber, lineContent, contextBefore, contextAfter}], " + + "language, totalLines}`. The `path` field matches `list_files`' " + + "entry `path` and `read_file`'s `path` input, so chaining tools " + + "needs no renames. Address via `target.registry` + " + + "`target.package_name` (package scope) or `target.repo_url` + " + + "`target.git_ref` (repo scope), mutually exclusive. For " + + "symbol-shaped searches use `search_symbols`. When the path " + + "doesn't resolve the response is a `NOT_FOUND` (or " + + "`FILE_NOT_FOUND`) error — call `list_files` to check the " + + "actual paths."; + +export function createGrepFileTool( + service: CodeNavigationService, +): ToolDefinition { + return { + name: "grep_file", + description: DESCRIPTION, + schema, + annotations: { readOnlyHint: true }, + handler: async (args) => { + const target = resolveCodeTarget(args.target); + if ("content" in target) return target; + + try { + const build = buildGrepFileParams({ + target, + path: args.path, + pattern: args.pattern, + contextLines: args.context_lines, + maxMatches: args.max_matches, + waitTimeoutMs: args.wait_timeout_ms, + }); + const result = await service.grepFile(build.params); + const payload = buildGrepFileSuccessPayload(result, { + registry: target.registry + ? toPkgseerRegistryLowercase(target.registry) + : undefined, + name: target.packageName, + repoUrl: target.repoUrl, + gitRef: target.gitRef, + pattern: build.params.pattern, + path: build.params.path, + contextLinesExplicit: build.contextLinesExplicit, + maxMatchesExplicit: build.maxMatchesExplicit, + contextLines: build.params.contextLines ?? 0, + maxMatches: build.params.maxMatches ?? 50, + }); + return textResult(JSON.stringify(payload)); + } catch (error) { + const mapped = mapCodeNavigationError(error); + return errorResult( + JSON.stringify({ + error: mapped.message, + code: mapped.code, + retryable: mapped.retryable ?? false, + ...(mapped.details ? { details: mapped.details } : {}), + }), + ); + } + }, + }; +} diff --git a/src/tools/index.ts b/src/tools/index.ts index 09548470..18d25b05 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -1,8 +1,11 @@ export { createFeedbackTool } from "./feedback.js"; +export { createGrepFileTool } from "./grep-file.js"; +export { createListFilesTool } from "./list-files.js"; export { createPackageChangelogTool } from "./package-changelog.js"; export { createPackageDependenciesTool } from "./package-dependencies.js"; export { createPackageSummaryTool } from "./package-summary.js"; export { createPackageVulnerabilitiesTool } from "./package-vulnerabilities.js"; +export { createReadFileTool } from "./read-file.js"; export { createSearchTool } from "./search.js"; export { createSearchLanguageTool } from "./search-language.js"; export { createSearchSymbolsTool } from "./search-symbols.js"; diff --git a/src/tools/list-files-parity.test.ts b/src/tools/list-files-parity.test.ts new file mode 100644 index 00000000..3eb4d944 --- /dev/null +++ b/src/tools/list-files-parity.test.ts @@ -0,0 +1,279 @@ +// PARITY TEST — enforces: +// 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, retryable, details? }. + +import { describe, expect, it, mock, spyOn } from "bun:test"; +import { + type PkgFilesCommandDependencies, + pkgFilesAction, +} from "../commands/code/files.js"; +import { + CodeNavigationIndexingError, + CodeNavigationTargetNotFoundError, + type ListFilesResult, +} from "../services/index.js"; +import { + createMockCodeNavigationService, + defaultListFilesResult, +} from "../services/test-helpers.js"; +import { createListFilesTool } from "./list-files.js"; + +function cliDeps( + overrides: Partial = {}, +): PkgFilesCommandDependencies { + return { + codeNavigationService: createMockCodeNavigationService(), + codeNavigationUrl: "https://pkgseer.dev", + hasValidToken: true, + mcpUrl: "https://mcp.example.com", + ...overrides, + }; +} + +async function cliJson( + spec: string | undefined, + pathPrefix: string | undefined, + options: Parameters[2] = {}, + deps: PkgFilesCommandDependencies = 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 { + // In spec mode the CLI takes (spec, path-prefix) positionals; + // in repo-URL mode it takes (path-prefix, undefined). + const hasRepoUrl = Boolean(options.repoUrl); + const first = hasRepoUrl ? pathPrefix : spec; + const second = hasRepoUrl ? undefined : pathPrefix; + await pkgFilesAction(first, second, { ...options, json: true }, deps); + } catch { + /* error paths call process.exit — caught */ + } + const raw = + (logSpy.mock.calls[0]?.[0] as string | undefined) ?? + (errSpy.mock.calls[0]?.[0] as string | undefined); + return raw ? JSON.parse(raw) : undefined; + } finally { + logSpy.mockRestore(); + errSpy.mockRestore(); + exitSpy.mockRestore(); + } +} + +interface McpArgs { + target: { + registry?: + | "npm" + | "pypi" + | "hex" + | "crates" + | "nuget" + | "maven" + | "zig" + | "vcpkg" + | "packagist"; + package_name?: string; + version?: string; + repo_url?: string; + git_ref?: string; + }; + path_prefix?: string; + limit?: number; + wait_timeout_ms?: number; +} + +async function mcpJson( + args: McpArgs, + listFilesMock?: () => Promise, +): Promise { + const service = createMockCodeNavigationService( + listFilesMock ? { listFiles: listFilesMock as never } : {}, + ); + const tool = createListFilesTool(service); + const result = await tool.handler(args, {}); + return JSON.parse(result.content[0]?.text ?? ""); +} + +describe("list_files parity", () => { + it("PARITY-JSON-KEYS: happy package addressing CLI === MCP", async () => { + const fn = mock(() => Promise.resolve(defaultListFilesResult)); + const cli = await cliJson( + "npm:express", + undefined, + {}, + cliDeps({ + codeNavigationService: createMockCodeNavigationService({ + listFiles: fn as never, + }), + }), + ); + const mcp = await mcpJson( + { + target: { registry: "npm", package_name: "express" }, + }, + fn as never, + ); + expect(cli).toEqual(mcp); + const envelope = cli as { registry: string; total: number }; + expect(envelope.registry).toBe("npm"); + expect(envelope.total).toBe(2); + }); + + it("PARITY-JSON-KEYS: repo-URL addressing CLI === MCP", async () => { + const fn = mock(() => Promise.resolve(defaultListFilesResult)); + const cli = await cliJson( + undefined, + undefined, + { + repoUrl: "https://github.com/expressjs/express", + gitRef: "main", + }, + cliDeps({ + codeNavigationService: createMockCodeNavigationService({ + listFiles: fn as never, + }), + }), + ); + const mcp = await mcpJson( + { + target: { + repo_url: "https://github.com/expressjs/express", + git_ref: "main", + }, + }, + fn as never, + ); + expect(cli).toEqual(mcp); + }); + + it("PARITY-JSON-KEYS: path_prefix echoes in filter block on both surfaces", async () => { + const fn = mock(() => Promise.resolve(defaultListFilesResult)); + const cli = await cliJson( + "npm:express", + "src/", + {}, + cliDeps({ + codeNavigationService: createMockCodeNavigationService({ + listFiles: fn as never, + }), + }), + ); + const mcp = await mcpJson( + { + target: { registry: "npm", package_name: "express" }, + path_prefix: "src/", + }, + fn as never, + ); + expect(cli).toEqual(mcp); + expect( + (cli as { filter?: { pathPrefix?: string } }).filter?.pathPrefix, + ).toBe("src/"); + }); + + it("PARITY-JSON-KEYS: explicit limit echoes in filter block on both surfaces", async () => { + const fn = mock(() => Promise.resolve(defaultListFilesResult)); + const cli = await cliJson( + "npm:express", + undefined, + { limit: "50" }, + cliDeps({ + codeNavigationService: createMockCodeNavigationService({ + listFiles: fn as never, + }), + }), + ); + const mcp = await mcpJson( + { + target: { registry: "npm", package_name: "express" }, + limit: 50, + }, + fn as never, + ); + expect(cli).toEqual(mcp); + expect((cli as { filter?: { limit?: number } }).filter?.limit).toBe(50); + }); + + it("PARITY-ERROR-ENVELOPE: INDEXING identical on both surfaces", async () => { + const fn = mock(() => + Promise.reject( + new CodeNavigationIndexingError( + "Target is still indexing.", + "ref_abc", + [{ version: "4.21.0", ref: "v4.21.0" }], + ), + ), + ); + const cli = await cliJson( + "npm:express", + undefined, + {}, + cliDeps({ + codeNavigationService: createMockCodeNavigationService({ + listFiles: fn as never, + }), + }), + ); + const mcp = await mcpJson( + { + target: { registry: "npm", package_name: "express" }, + }, + fn as never, + ); + expect(cli).toEqual(mcp); + expect((cli as { code: string; retryable: boolean }).code).toBe("INDEXING"); + expect((cli as { code: string; retryable: boolean }).retryable).toBe(true); + }); + + it("PARITY-ERROR-ENVELOPE: NOT_FOUND identical on both surfaces", async () => { + const fn = mock(() => + Promise.reject( + new CodeNavigationTargetNotFoundError("Package not found"), + ), + ); + const cli = await cliJson( + "npm:ghost", + undefined, + {}, + cliDeps({ + codeNavigationService: createMockCodeNavigationService({ + listFiles: fn as never, + }), + }), + ); + const mcp = await mcpJson( + { + target: { registry: "npm", package_name: "ghost" }, + }, + fn as never, + ); + expect(cli).toEqual(mcp); + expect((cli as { code: string }).code).toBe("NOT_FOUND"); + }); + + it("PARITY-ERROR-ENVELOPE: INVALID_ARGUMENT on both surfaces carries `retryable: false` (full shape)", async () => { + const cli = await cliJson(undefined, undefined, {}); + const mcp = await mcpJson({ target: {} }); + // Assert the exact shape (including retryable) so future drift + // surfaces here rather than in a production agent's envelope. + // Message text differs by surface; that's acceptable. + expect(cli).toMatchObject({ + code: "INVALID_ARGUMENT", + retryable: false, + error: expect.any(String), + }); + expect(mcp).toMatchObject({ + code: "INVALID_ARGUMENT", + retryable: false, + error: expect.any(String), + }); + // Both surfaces must have the same set of keys. + expect(Object.keys(cli as object).sort()).toEqual( + Object.keys(mcp as object).sort(), + ); + }); +}); diff --git a/src/tools/list-files.test.ts b/src/tools/list-files.test.ts new file mode 100644 index 00000000..5c0b1606 --- /dev/null +++ b/src/tools/list-files.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, it, mock } from "bun:test"; +import { + CodeNavigationIndexingError, + CodeNavigationTargetNotFoundError, +} from "../services/index.js"; +import { + createMockCodeNavigationService, + defaultListFilesResult, +} from "../services/test-helpers.js"; +import { createListFilesTool } from "./list-files.js"; + +function parseText(result: { content: Array<{ text: string }> }): unknown { + return JSON.parse(result.content[0]?.text ?? ""); +} + +describe("createListFilesTool — metadata", () => { + it("registers the correct tool name, description, and schema keys", () => { + const tool = createListFilesTool(createMockCodeNavigationService()); + expect(tool.name).toBe("list_files"); + expect(tool.description).toContain("List files in an indexed dependency"); + expect(Object.keys(tool.schema).sort()).toEqual([ + "limit", + "path_prefix", + "target", + "wait_timeout_ms", + ]); + expect(tool.annotations?.readOnlyHint).toBe(true); + }); +}); + +describe("createListFilesTool — happy path", () => { + it("calls listFiles with the resolved package target", async () => { + const listFiles = mock(() => Promise.resolve(defaultListFilesResult)); + const service = createMockCodeNavigationService({ listFiles }); + const tool = createListFilesTool(service); + + await tool.handler( + { + target: { registry: "npm", package_name: "express" }, + }, + {}, + ); + + const calls = listFiles.mock.calls as unknown as Array< + [{ target: { registry?: string; packageName?: string } }] + >; + expect(calls[0]?.[0]?.target?.registry).toBe("NPM"); + expect(calls[0]?.[0]?.target?.packageName).toBe("express"); + }); + + it("emits the envelope with files, total, hasMore, resolution, indexedVersion", async () => { + const tool = createListFilesTool(createMockCodeNavigationService()); + const result = await tool.handler( + { target: { registry: "npm", package_name: "express" } }, + {}, + ); + expect(result.isError).toBeUndefined(); + const payload = parseText(result) as { + registry: string; + name: string; + total: number; + hasMore: boolean; + files: Array<{ path: string }>; + indexedVersion?: string; + resolution?: { resolvedRef?: string }; + }; + expect(payload.registry).toBe("npm"); + expect(payload.name).toBe("express"); + expect(payload.total).toBe(2); + expect(payload.hasMore).toBe(false); + expect(payload.files[0]?.path).toBe("src/index.js"); + expect(payload.indexedVersion).toBe("v5.2.1"); + expect(payload.resolution?.resolvedRef).toBe("v5.2.1"); + }); + + it("emits repo-URL addressing envelope", async () => { + const tool = createListFilesTool(createMockCodeNavigationService()); + const result = await tool.handler( + { + target: { + repo_url: "https://github.com/expressjs/express", + git_ref: "main", + }, + }, + {}, + ); + const payload = parseText(result) as { + registry?: string; + name?: string; + repoUrl?: string; + gitRef?: string; + }; + expect(payload.registry).toBeUndefined(); + expect(payload.name).toBeUndefined(); + expect(payload.repoUrl).toBe("https://github.com/expressjs/express"); + expect(payload.gitRef).toBe("main"); + }); + + it("emits filter.pathPrefix when caller set one", async () => { + const tool = createListFilesTool(createMockCodeNavigationService()); + const result = await tool.handler( + { + target: { registry: "npm", package_name: "express" }, + path_prefix: "src/", + }, + {}, + ); + const payload = parseText(result) as { + filter?: { pathPrefix?: string }; + }; + expect(payload.filter?.pathPrefix).toBe("src/"); + }); + + it("omits filter when caller only used defaults", async () => { + const tool = createListFilesTool(createMockCodeNavigationService()); + const result = await tool.handler( + { target: { registry: "npm", package_name: "express" } }, + {}, + ); + const payload = parseText(result) as { filter?: unknown }; + expect(payload.filter).toBeUndefined(); + }); +}); + +describe("createListFilesTool — validation errors", () => { + it("returns INVALID_ARGUMENT for both target forms (not both)", async () => { + const tool = createListFilesTool(createMockCodeNavigationService()); + const result = await tool.handler( + { + target: { + registry: "npm", + package_name: "express", + repo_url: "https://github.com/x", + git_ref: "main", + }, + }, + {}, + ); + expect(result.isError).toBe(true); + const payload = parseText(result) as { code: string; error: string }; + expect(payload.code).toBe("INVALID_ARGUMENT"); + expect(payload.error).toContain("not both"); + }); + + it("returns INVALID_ARGUMENT for out-of-range limit via envelope (not raw Zod)", async () => { + const tool = createListFilesTool(createMockCodeNavigationService()); + const result = await tool.handler( + { target: { registry: "npm", package_name: "express" }, limit: 1001 }, + {}, + ); + expect(result.isError).toBe(true); + const payload = parseText(result) as { code: string }; + expect(payload.code).toBe("INVALID_ARGUMENT"); + }); + + it("returns INVALID_ARGUMENT for missing repo_url pair (only git_ref)", async () => { + const tool = createListFilesTool(createMockCodeNavigationService()); + const result = await tool.handler({ target: { git_ref: "main" } }, {}); + expect(result.isError).toBe(true); + const payload = parseText(result) as { code: string }; + expect(payload.code).toBe("INVALID_ARGUMENT"); + }); +}); + +describe("createListFilesTool — service errors", () => { + it("classifies CodeNavigationIndexingError as INDEXING with retryable + details", async () => { + const service = createMockCodeNavigationService({ + listFiles: mock(() => + Promise.reject( + new CodeNavigationIndexingError( + "Target is still indexing.", + "ref_abc", + [{ version: "4.21.0", ref: "v4.21.0" }], + ), + ), + ), + }); + const tool = createListFilesTool(service); + const result = await tool.handler( + { target: { registry: "npm", package_name: "express" } }, + {}, + ); + expect(result.isError).toBe(true); + const payload = parseText(result) as { + code: string; + retryable: boolean; + details?: { indexingRef?: string; availableVersions?: unknown }; + }; + expect(payload.code).toBe("INDEXING"); + expect(payload.retryable).toBe(true); + expect(payload.details?.indexingRef).toBe("ref_abc"); + expect(payload.details?.availableVersions).toBeTruthy(); + }); + + it("classifies CodeNavigationTargetNotFoundError as NOT_FOUND", async () => { + const service = createMockCodeNavigationService({ + listFiles: mock(() => + Promise.reject( + new CodeNavigationTargetNotFoundError("Package not found"), + ), + ), + }); + const tool = createListFilesTool(service); + const result = await tool.handler( + { target: { registry: "npm", package_name: "ghost" } }, + {}, + ); + expect(result.isError).toBe(true); + const payload = parseText(result) as { code: string }; + expect(payload.code).toBe("NOT_FOUND"); + }); +}); diff --git a/src/tools/list-files.ts b/src/tools/list-files.ts new file mode 100644 index 00000000..46ccb5e8 --- /dev/null +++ b/src/tools/list-files.ts @@ -0,0 +1,101 @@ +import { z } from "zod"; +import type { CodeNavigationService } from "../services/index.js"; +import { mapCodeNavigationError } from "../shared/code-navigation-error-map.js"; +import { buildListFilesParams } from "../shared/list-files-request.js"; +import { buildListFilesSuccessPayload } from "../shared/list-files-response.js"; +import { toPkgseerRegistryLowercase } from "../shared/pkgseer-registry.js"; +import { + type CodeTargetArg, + codeTargetSchema, + resolveCodeTarget, +} from "./code-navigation-shared.js"; +import { errorResult, type ToolDefinition, textResult } from "./types.js"; + +export interface ListFilesArgs { + target: CodeTargetArg; + path_prefix?: string; + limit?: number; + wait_timeout_ms?: number; +} + +const schema = { + target: codeTargetSchema, + path_prefix: z + .string() + .optional() + .describe( + "Literal directory prefix to filter by (e.g. `src/` or `lib/parser`). NOT a glob — `*.ts` and similar patterns won't match. Omit to list from the repository root.", + ), + limit: z + .number() + .optional() + .describe( + "Max entries to return (1–1000, default 200). Out-of-range values return an `INVALID_ARGUMENT` envelope.", + ), + wait_timeout_ms: z + .number() + .optional() + .describe( + "Max milliseconds to wait for indexing (0–60000, default 20000). On an `INDEXING` error envelope, retry with a longer timeout or pass a version from `details.availableVersions`.", + ), +}; + +const DESCRIPTION = + "List files in an indexed dependency. Response: " + + "`{total, hasMore, files: [{path, name, language, fileType, byteSize}], " + + "resolution, indexedVersion}`. Address via `target.registry` + " + + "`target.package_name` (package scope) or `target.repo_url` + " + + "`target.git_ref` (repo scope), mutually exclusive. `path_prefix` " + + "is a literal directory prefix — it does NOT accept globs " + + "(`*.ts`) or extension filters. Returns an `INDEXING` error " + + "envelope when the dependency is being indexed on-demand — retry " + + "with a longer `wait_timeout_ms` or use a version from " + + "`details.availableVersions`."; + +export function createListFilesTool( + service: CodeNavigationService, +): ToolDefinition { + return { + name: "list_files", + description: DESCRIPTION, + schema, + annotations: { readOnlyHint: true }, + handler: async (args) => { + const target = resolveCodeTarget(args.target); + if ("content" in target) return target; + + try { + const build = buildListFilesParams({ + target, + pathPrefix: args.path_prefix, + limit: args.limit, + waitTimeoutMs: args.wait_timeout_ms, + }); + const result = await service.listFiles(build.params); + const payload = buildListFilesSuccessPayload(result, { + registry: target.registry + ? toPkgseerRegistryLowercase(target.registry) + : undefined, + name: target.packageName, + repoUrl: target.repoUrl, + gitRef: target.gitRef, + limitExplicit: build.limitExplicit, + pathPrefixExplicit: build.pathPrefixExplicit, + pathPrefix: build.params.pathPrefix, + limit: build.params.limit, + }); + return textResult(JSON.stringify(payload)); + } catch (error) { + const mapped = mapCodeNavigationError(error); + return errorResult( + JSON.stringify({ + error: mapped.message, + code: mapped.code, + retryable: mapped.retryable ?? false, + ...(mapped.details ? { details: mapped.details } : {}), + }), + ); + } + }, + }; +} diff --git a/src/tools/package-changelog-parity.test.ts b/src/tools/package-changelog-parity.test.ts index da2467e5..f2a41131 100644 --- a/src/tools/package-changelog-parity.test.ts +++ b/src/tools/package-changelog-parity.test.ts @@ -5,7 +5,7 @@ // details? } on every error path; MCP error text is // always valid JSON. // -// Assertion policy (matches P1–P3 precedent): +// Assertion policy (matches the other pkg-intel parity tests): // - Service-sourced success / error fixtures use `toEqual`: both // surfaces route through the same request builder and envelope // shaper, so envelopes are byte-identical. diff --git a/src/tools/package-changelog.ts b/src/tools/package-changelog.ts index d2fa528e..c5a039ca 100644 --- a/src/tools/package-changelog.ts +++ b/src/tools/package-changelog.ts @@ -20,15 +20,17 @@ export interface PackageChangelogArgs { /** * Permissive schema — the shared `buildPackageChangelogParams` builder * is the single validation path. Raw Zod errors never surface to - * agents. Matches the pattern established by P1 / P2 / P3. + * agents. Matches the pattern established by the other pkg-intel + * tools (`package_summary`, `package_vulnerabilities`, + * `package_dependencies`). * * `package_changelog` is the first pkg-intel MCP tool with dual * addressing (`registry` + `package_name` XOR `repo_url`). The * underlying `packageChangelog` query is intrinsically repo-level * (sources: GitHub Releases / CHANGELOG.md / HexDocs), so exposing * `repo_url` isn't a bolt-on — it's a peer addressing mode on the - * schema. P1 / P2 / P3 omit it because their queries are registry- - * metadata APIs with no repo-URL alternative. + * schema. The other pkg-intel tools omit it because their queries + * are registry-metadata APIs with no repo-URL alternative. */ const schema = { registry: z diff --git a/src/tools/package-dependencies-parity.test.ts b/src/tools/package-dependencies-parity.test.ts index 63d33261..c13e2221 100644 --- a/src/tools/package-dependencies-parity.test.ts +++ b/src/tools/package-dependencies-parity.test.ts @@ -5,9 +5,8 @@ // details? } on every error path; MCP error text is // always valid JSON. // -// Assertion policy (locked in the P3 plan; matches shipped -// search_symbols / package_summary / package_vulnerabilities -// precedent): +// Assertion policy (matches shipped search_symbols / package_summary / +// package_vulnerabilities precedent): // - Service-sourced success and error fixtures use `toEqual`: both // surfaces route through the same request builder and envelope // shaper, so envelopes are byte-identical. diff --git a/src/tools/package-vulnerabilities-parity.test.ts b/src/tools/package-vulnerabilities-parity.test.ts index 3260dff3..67a52cac 100644 --- a/src/tools/package-vulnerabilities-parity.test.ts +++ b/src/tools/package-vulnerabilities-parity.test.ts @@ -5,8 +5,8 @@ // details? } on every error path; MCP error text is // always valid JSON. // -// Assertion policy (locked in the P2 plan; matches shipped -// search_symbols / package_summary precedent): +// Assertion policy (matches shipped search_symbols / package_summary +// precedent): // - Service-sourced success and error fixtures use `toEqual`: both // surfaces route through the same classifier / envelope builder, // so envelopes are byte-identical. diff --git a/src/tools/read-file-parity.test.ts b/src/tools/read-file-parity.test.ts new file mode 100644 index 00000000..ba1faaff --- /dev/null +++ b/src/tools/read-file-parity.test.ts @@ -0,0 +1,280 @@ +// PARITY TEST — enforces: +// 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, retryable, details? }. + +import { describe, expect, it, mock, spyOn } from "bun:test"; +import { + type PkgReadCommandDependencies, + pkgReadAction, +} from "../commands/code/read.js"; +import { + CodeNavigationFileNotFoundError, + CodeNavigationIndexingError, + type ReadFileResult, +} from "../services/index.js"; +import { + createMockCodeNavigationService, + defaultReadFileResult, +} from "../services/test-helpers.js"; +import { createReadFileTool } from "./read-file.js"; + +function cliDeps( + overrides: Partial = {}, +): PkgReadCommandDependencies { + return { + codeNavigationService: createMockCodeNavigationService(), + codeNavigationUrl: "https://pkgseer.dev", + hasValidToken: true, + mcpUrl: "https://mcp.example.com", + ...overrides, + }; +} + +async function cliJson( + spec: string | undefined, + path: string | undefined, + options: Parameters[2] = {}, + deps: PkgReadCommandDependencies = 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 pkgReadAction(spec, path, { ...options, json: true }, deps); + } catch { + /* error paths call process.exit — caught */ + } + const raw = + (logSpy.mock.calls[0]?.[0] as string | undefined) ?? + (errSpy.mock.calls[0]?.[0] as string | undefined); + return raw ? JSON.parse(raw) : undefined; + } finally { + logSpy.mockRestore(); + errSpy.mockRestore(); + exitSpy.mockRestore(); + } +} + +interface McpArgs { + target: { + registry?: + | "npm" + | "pypi" + | "hex" + | "crates" + | "nuget" + | "maven" + | "zig" + | "vcpkg" + | "packagist"; + package_name?: string; + version?: string; + repo_url?: string; + git_ref?: string; + }; + path: string; + start_line?: number; + end_line?: number; + wait_timeout_ms?: number; +} + +async function mcpJson( + args: McpArgs, + readFileMock?: () => Promise, +): Promise { + const service = createMockCodeNavigationService( + readFileMock ? { readFile: readFileMock as never } : {}, + ); + const tool = createReadFileTool(service); + const result = await tool.handler(args, {}); + return JSON.parse(result.content[0]?.text ?? ""); +} + +describe("read_file parity", () => { + it("PARITY-JSON-KEYS: happy package read CLI === MCP", async () => { + const fn = mock(() => Promise.resolve(defaultReadFileResult)); + const cli = await cliJson( + "npm:express", + "src/index.js", + {}, + cliDeps({ + codeNavigationService: createMockCodeNavigationService({ + readFile: fn as never, + }), + }), + ); + const mcp = await mcpJson( + { + target: { registry: "npm", package_name: "express" }, + path: "src/index.js", + }, + fn as never, + ); + expect(cli).toEqual(mcp); + }); + + it("PARITY-JSON-KEYS: line range CLI === MCP", async () => { + const fn = mock(() => Promise.resolve(defaultReadFileResult)); + const cli = await cliJson( + "npm:express", + "src/index.js", + { start: "10", end: "40" }, + cliDeps({ + codeNavigationService: createMockCodeNavigationService({ + readFile: fn as never, + }), + }), + ); + const mcp = await mcpJson( + { + target: { registry: "npm", package_name: "express" }, + path: "src/index.js", + start_line: 10, + end_line: 40, + }, + fn as never, + ); + expect(cli).toEqual(mcp); + }); + + it("PARITY-JSON-KEYS: binary file fixture — both surfaces omit `content` and set `isBinary: true`", async () => { + const binaryResult: ReadFileResult = { + filePath: "assets/logo.png", + isBinary: true, + // content intentionally undefined (backend returns null) + }; + const fn = mock(() => Promise.resolve(binaryResult)); + const cli = await cliJson( + "npm:express", + "assets/logo.png", + {}, + cliDeps({ + codeNavigationService: createMockCodeNavigationService({ + readFile: fn as never, + }), + }), + ); + const mcp = await mcpJson( + { + target: { registry: "npm", package_name: "express" }, + path: "assets/logo.png", + }, + fn as never, + ); + expect(cli).toEqual(mcp); + const envelope = cli as { + isBinary?: boolean; + content?: string; + }; + expect(envelope.isBinary).toBe(true); + expect(envelope.content).toBeUndefined(); + }); + + it("PARITY-JSON-KEYS: repo-URL addressing CLI === MCP", async () => { + const fn = mock(() => Promise.resolve(defaultReadFileResult)); + // Commander binds the sole positional to the first argument in + // repo-URL mode; action interprets it as the path. + const cli = await cliJson( + "src/index.js", + undefined, + { + repoUrl: "https://github.com/expressjs/express", + gitRef: "main", + }, + cliDeps({ + codeNavigationService: createMockCodeNavigationService({ + readFile: fn as never, + }), + }), + ); + const mcp = await mcpJson( + { + target: { + repo_url: "https://github.com/expressjs/express", + git_ref: "main", + }, + path: "src/index.js", + }, + fn as never, + ); + expect(cli).toEqual(mcp); + }); + + it("PARITY-ERROR-ENVELOPE: FILE_NOT_FOUND identical on both surfaces", async () => { + const fn = mock(() => + Promise.reject( + new CodeNavigationFileNotFoundError( + "File not found: nope.js", + "nope.js", + ), + ), + ); + const cli = await cliJson( + "npm:express", + "nope.js", + {}, + cliDeps({ + codeNavigationService: createMockCodeNavigationService({ + readFile: fn as never, + }), + }), + ); + const mcp = await mcpJson( + { + target: { registry: "npm", package_name: "express" }, + path: "nope.js", + }, + fn as never, + ); + expect(cli).toEqual(mcp); + expect((cli as { code: string }).code).toBe("FILE_NOT_FOUND"); + }); + + it("PARITY-ERROR-ENVELOPE: INDEXING identical on both surfaces", async () => { + const fn = mock(() => + Promise.reject( + new CodeNavigationIndexingError("Indexing...", "ref_abc", [ + { version: "4.21.0", ref: "v4.21.0" }, + ]), + ), + ); + const cli = await cliJson( + "npm:express", + "src/index.js", + {}, + cliDeps({ + codeNavigationService: createMockCodeNavigationService({ + readFile: fn as never, + }), + }), + ); + const mcp = await mcpJson( + { + target: { registry: "npm", package_name: "express" }, + path: "src/index.js", + }, + fn as never, + ); + expect(cli).toEqual(mcp); + expect((cli as { code: string; retryable: boolean }).code).toBe("INDEXING"); + }); + + it("PARITY-ERROR-ENVELOPE: INVALID_ARGUMENT on reversed range", async () => { + const cli = await cliJson("npm:express", "src/index.js", { + start: "40", + end: "10", + }); + const mcp = await mcpJson({ + target: { registry: "npm", package_name: "express" }, + path: "src/index.js", + start_line: 40, + end_line: 10, + }); + expect(cli).toMatchObject({ code: "INVALID_ARGUMENT" }); + expect(mcp).toMatchObject({ code: "INVALID_ARGUMENT" }); + }); +}); diff --git a/src/tools/read-file.test.ts b/src/tools/read-file.test.ts new file mode 100644 index 00000000..160b8535 --- /dev/null +++ b/src/tools/read-file.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, it, mock } from "bun:test"; +import { + CodeNavigationFileNotFoundError, + CodeNavigationIndexingError, +} from "../services/index.js"; +import { + createMockCodeNavigationService, + defaultReadFileResult, +} from "../services/test-helpers.js"; +import { createReadFileTool } from "./read-file.js"; + +function parseText(result: { content: Array<{ text: string }> }): unknown { + return JSON.parse(result.content[0]?.text ?? ""); +} + +describe("createReadFileTool — metadata", () => { + it("registers the correct tool name, description, and schema keys", () => { + const tool = createReadFileTool(createMockCodeNavigationService()); + expect(tool.name).toBe("read_file"); + expect(tool.description).toContain( + "Read a file from an indexed dependency", + ); + expect(tool.description).toContain("NOT_FOUND"); + expect(Object.keys(tool.schema).sort()).toEqual([ + "end_line", + "path", + "start_line", + "target", + "wait_timeout_ms", + ]); + expect(tool.annotations?.readOnlyHint).toBe(true); + }); +}); + +describe("createReadFileTool — happy path", () => { + it("calls readFile with the resolved target and file_path", async () => { + const readFile = mock(() => Promise.resolve(defaultReadFileResult)); + const service = createMockCodeNavigationService({ readFile }); + const tool = createReadFileTool(service); + + await tool.handler( + { + target: { registry: "npm", package_name: "express" }, + path: "src/index.js", + }, + {}, + ); + + const calls = readFile.mock.calls as unknown as Array< + [{ target: { registry?: string }; filePath: string }] + >; + expect(calls[0]?.[0]?.target?.registry).toBe("NPM"); + expect(calls[0]?.[0]?.filePath).toBe("src/index.js"); + }); + + it("emits the envelope with content + line range", async () => { + const tool = createReadFileTool(createMockCodeNavigationService()); + const result = await tool.handler( + { + target: { registry: "npm", package_name: "express" }, + path: "src/index.js", + }, + {}, + ); + expect(result.isError).toBeUndefined(); + const payload = parseText(result) as { + path: string; + language: string; + totalLines: number; + startLine: number; + endLine: number; + content: string; + isBinary?: boolean; + }; + expect(payload.path).toBe("src/index.js"); + expect(payload.language).toBe("javascript"); + expect(payload.totalLines).toBe(5); + expect(payload.content).toContain("Express entry point"); + expect(payload.isBinary).toBeUndefined(); + }); + + it("passes start_line / end_line through to the wire", async () => { + const readFile = mock(() => Promise.resolve(defaultReadFileResult)); + const service = createMockCodeNavigationService({ readFile }); + const tool = createReadFileTool(service); + + await tool.handler( + { + target: { registry: "npm", package_name: "express" }, + path: "src/index.js", + start_line: 10, + end_line: 20, + }, + {}, + ); + + const calls = readFile.mock.calls as unknown as Array< + [{ startLine?: number; endLine?: number }] + >; + expect(calls[0]?.[0]?.startLine).toBe(10); + expect(calls[0]?.[0]?.endLine).toBe(20); + }); + + it("emits isBinary + omits content for binary files", async () => { + const tool = createReadFileTool( + createMockCodeNavigationService({ + readFile: mock(() => + Promise.resolve({ + filePath: "assets/logo.png", + isBinary: true, + }), + ), + }), + ); + const result = await tool.handler( + { + target: { registry: "npm", package_name: "express" }, + path: "assets/logo.png", + }, + {}, + ); + const payload = parseText(result) as { + isBinary?: boolean; + content?: string; + }; + expect(payload.isBinary).toBe(true); + expect(payload.content).toBeUndefined(); + }); +}); + +describe("createReadFileTool — validation errors", () => { + it("returns INVALID_ARGUMENT when file_path is missing", async () => { + const tool = createReadFileTool(createMockCodeNavigationService()); + const result = await tool.handler( + { + target: { registry: "npm", package_name: "express" }, + path: " ", + }, + {}, + ); + expect(result.isError).toBe(true); + expect((parseText(result) as { code: string }).code).toBe( + "INVALID_ARGUMENT", + ); + }); + + it("returns INVALID_ARGUMENT for a reversed range", async () => { + const tool = createReadFileTool(createMockCodeNavigationService()); + const result = await tool.handler( + { + target: { registry: "npm", package_name: "express" }, + path: "src/index.js", + start_line: 40, + end_line: 10, + }, + {}, + ); + expect(result.isError).toBe(true); + const payload = parseText(result) as { code: string; error: string }; + expect(payload.code).toBe("INVALID_ARGUMENT"); + expect(payload.error).toContain("reversed"); + }); + + it("returns INVALID_ARGUMENT for start_line=0 via envelope (not raw Zod)", async () => { + const tool = createReadFileTool(createMockCodeNavigationService()); + const result = await tool.handler( + { + target: { registry: "npm", package_name: "express" }, + path: "src/index.js", + start_line: 0, + }, + {}, + ); + expect(result.isError).toBe(true); + expect((parseText(result) as { code: string }).code).toBe( + "INVALID_ARGUMENT", + ); + }); +}); + +describe("createReadFileTool — service errors", () => { + it("classifies CodeNavigationFileNotFoundError as FILE_NOT_FOUND", async () => { + const service = createMockCodeNavigationService({ + readFile: mock(() => + Promise.reject( + new CodeNavigationFileNotFoundError( + "File not found: nope.js", + "nope.js", + ), + ), + ), + }); + const tool = createReadFileTool(service); + const result = await tool.handler( + { + target: { registry: "npm", package_name: "express" }, + path: "nope.js", + }, + {}, + ); + expect(result.isError).toBe(true); + const payload = parseText(result) as { + code: string; + details?: { filePath?: string }; + }; + expect(payload.code).toBe("FILE_NOT_FOUND"); + expect(payload.details?.filePath).toBe("nope.js"); + }); + + it("classifies CodeNavigationIndexingError as INDEXING", async () => { + const service = createMockCodeNavigationService({ + readFile: mock(() => + Promise.reject( + new CodeNavigationIndexingError("Indexing...", "ref_abc"), + ), + ), + }); + const tool = createReadFileTool(service); + const result = await tool.handler( + { + target: { registry: "npm", package_name: "express" }, + path: "src/index.js", + }, + {}, + ); + expect(result.isError).toBe(true); + expect((parseText(result) as { code: string }).code).toBe("INDEXING"); + }); +}); diff --git a/src/tools/read-file.ts b/src/tools/read-file.ts new file mode 100644 index 00000000..1951de9c --- /dev/null +++ b/src/tools/read-file.ts @@ -0,0 +1,105 @@ +import { z } from "zod"; +import type { CodeNavigationService } from "../services/index.js"; +import { mapCodeNavigationError } from "../shared/code-navigation-error-map.js"; +import { toPkgseerRegistryLowercase } from "../shared/pkgseer-registry.js"; +import { buildReadFileParams } from "../shared/read-file-request.js"; +import { buildReadFileSuccessPayload } from "../shared/read-file-response.js"; +import { + type CodeTargetArg, + codeTargetSchema, + resolveCodeTarget, +} from "./code-navigation-shared.js"; +import { errorResult, type ToolDefinition, textResult } from "./types.js"; + +export interface ReadFileArgs { + target: CodeTargetArg; + path: string; + start_line?: number; + end_line?: number; + wait_timeout_ms?: number; +} + +const schema = { + target: codeTargetSchema, + path: z + .string() + .describe( + "Path to the file. Package addressing: package-relative. Repo addressing: repo-relative. This is the same `path` key that `list_files` emits for each entry, so the `list_files` → `read_file` chain needs no renaming.", + ), + start_line: z + .number() + .optional() + .describe("Starting line (1-indexed). Omit for the full file from line 1."), + end_line: z + .number() + .optional() + .describe( + "Ending line (inclusive). Omit for end of file. Must be ≥ `start_line` when both are set.", + ), + wait_timeout_ms: z + .number() + .optional() + .describe( + "Max milliseconds to wait for indexing (0–60000, default 20000). On an `INDEXING` error envelope, retry with a longer timeout or pass a version from `details.availableVersions`.", + ), +}; + +const DESCRIPTION = + "Read a file from an indexed dependency. Default returns the full " + + "file; use `start_line` / `end_line` for a bounded range. Response: " + + "`{path, language, totalLines, startLine, endLine, content, " + + "isBinary}`. Binary files set `isBinary: true` and omit `content` — " + + "agents branch on the flag rather than checking null. Address via " + + "`target.registry` + `target.package_name` (package scope) or " + + "`target.repo_url` + `target.git_ref` (repo scope), mutually " + + "exclusive. On `INDEXING` retry with a longer `wait_timeout_ms` " + + "(note: `fetchCodeContext` doesn't emit `availableVersions` in " + + "details, only `indexingRef`). When the path doesn't resolve the " + + "response is a `NOT_FOUND` (or `FILE_NOT_FOUND`) error — call " + + "`list_files` to discover the actual paths."; + +export function createReadFileTool( + service: CodeNavigationService, +): ToolDefinition { + return { + name: "read_file", + description: DESCRIPTION, + schema, + annotations: { readOnlyHint: true }, + handler: async (args) => { + const target = resolveCodeTarget(args.target); + if ("content" in target) return target; + + try { + const build = buildReadFileParams({ + target, + filePath: args.path, + startLine: args.start_line, + endLine: args.end_line, + waitTimeoutMs: args.wait_timeout_ms, + }); + const result = await service.readFile(build.params); + const payload = buildReadFileSuccessPayload(result, { + registry: target.registry + ? toPkgseerRegistryLowercase(target.registry) + : undefined, + name: target.packageName, + repoUrl: target.repoUrl, + gitRef: target.gitRef, + requestedFilePath: build.params.filePath, + }); + return textResult(JSON.stringify(payload)); + } catch (error) { + const mapped = mapCodeNavigationError(error); + return errorResult( + JSON.stringify({ + error: mapped.message, + code: mapped.code, + retryable: mapped.retryable ?? false, + ...(mapped.details ? { details: mapped.details } : {}), + }), + ); + } + }, + }; +}