diff --git a/codegraph-skill/codegraph/SKILL.md b/codegraph-skill/codegraph/SKILL.md index f3badc3c..4fe21909 100644 --- a/codegraph-skill/codegraph/SKILL.md +++ b/codegraph-skill/codegraph/SKILL.md @@ -29,6 +29,7 @@ Use `doctor` only when install, native-runtime, or artifact health is the task. Then choose the smallest useful follow-up: - explore: `codegraph explore "how does auth reach db?" --pretty` +- live file: `codegraph file --offset 1 --limit 200 --pretty` - packet: `codegraph packet get --pretty` - search: `codegraph search "auth user" --json` - explain: `codegraph explain ` @@ -66,15 +67,33 @@ Current high-value surfaces: - `review --summary`: compact reviewer handoff - `duplicates --profile cleanup`: refactor ROI ordering - `duplicates --json`: full grouped duplicate data +- `file`: live bounded file bytes with JSON default, exact numbered lines, and explicit pagination Treat duplicate leads and call-compatibility hints as review leads, not proof. +## Live File Views + +Use `codegraph file ` for current disk content; use `--pretty` for a readable view or the default JSON for exact fields. Returned pages use a 1-based `--offset`, a `--limit` default/cap of 2000/10000 lines, and a `--max-bytes` default/cap of 80000/500000 unnumbered text bytes. A separate 16 MiB hard input-size limit rejects larger raw reads and structural text-config summaries before unbounded I/O, bounding complete-stream binary/UTF-8 validation and total-line counting. + +```bash +codegraph file src/auth.ts --offset 201 --limit 200 --max-bytes 80000 +codegraph file src/auth.ts --include-graph-context --pretty +``` + +`content` is exact `numberline` text with no line-number padding, while `text` omits number prefixes. A trailing newline becomes a final numbered empty line. Follow `page.nextOffset`; beyond EOF, JSON `content` and `text` are empty and pretty output says `Lines: none at offset of `. + +Graph context is never automatic. Add `--include-graph-context` only when direct `usedBy` paths, imports, and symbols are worth an index/freshness check; ordinary file bytes stay live and independent of index freshness. + +Within the 16 MiB input limit, recognized environment, authentication, and credential text configs return structural summaries after the full raw stream is validated. Default key-material summaries use metadata, may report size, and do not read raw secret bytes. Use `--allow-sensitive` only for deliberate raw access; it does not bypass the input-size, binary, NUL, or UTF-8 guards, so `.p12` and `.pfx` bundles summarize by default and reject raw access. + +An exact project-relative file-path query such as `codegraph explore src/auth.ts --json` adds the same response as `fileView`; `--no-source` suppresses it. `--include-graph-context` and `--allow-sensitive` pass through only when explicitly present. + ## MCP If MCP tools are available, prefer them over repeated CLI invocations. Use MCP `explore`, `orient`, `search`, `get_file`, `packet_get`, `goto`, `refs`, `deps`, `rdeps`, `path`, `impact`, `review`, and `query_sqlite` first. -Use `get_file` for bounded live file reads; its content uses exact `numberline` formatting. Set `includeGraphContext: true` to opt into direct graph context capped at 100 importers, imports, and symbols each. -After edits, check MCP response `freshness`: `refreshed` means Codegraph rebuilt before answering, and `stale` includes a reason plus bounded changed-file metadata before indexed context is trusted. +Use `get_file` for bounded live reads with `offset`, `limit`, and `maxBytes`; continue at `page.nextOffset`, and set `allowSensitive: true` only for intentional raw sensitive reads. Set `includeGraphContext: true` to opt into freshness-backed direct context capped at 100 importers, imports, and symbols each. +For plain `get_file` reads, `freshness` does not gate the live bytes. After indexed calls or graph-context reads, check it before trusting indexed data: `refreshed` means Codegraph rebuilt, and `stale` includes a reason plus bounded changed-file metadata. Run `refresh_index` before `artifact_build` when MCP reports a stale index; artifact writes refuse stale snapshots. Fall back to CLI when MCP is unavailable. diff --git a/docs/agent-workflows.md b/docs/agent-workflows.md index 05f7091b..5738965e 100644 --- a/docs/agent-workflows.md +++ b/docs/agent-workflows.md @@ -55,6 +55,29 @@ codegraph explore src/auth.ts --json --limit 5 --max-packets 3 Explore orchestrates existing search, packet, path, reverse-dependency, and candidate-test surfaces. It returns `schemaVersion: 1`, the query, analysis metadata, summary bullets, anchors, bounded packets, dependency paths, blast radius, candidate tests, follow-ups, flat limits, and omission counts. Use `--no-source` when the caller only needs anchors, paths, and follow-up commands. +## Live file reads + +Use `file` when the target path is known and the agent needs current source rather than an indexed explanation packet. Its JSON default is best for tool chaining; `--pretty` keeps the exact `numberline` source format while adding a readable header and next-page command. + +```bash +codegraph file src/auth.ts --offset 1 --limit 200 --pretty +codegraph file src/auth.ts --offset 201 --limit 200 --max-bytes 80000 +codegraph file src/auth.ts --include-graph-context --json +``` + +The live bytes, exact whole-file `totalLines`, and `page.nextOffset` do not depend on a fresh index. Graph context is never automatic: opt in with `--include-graph-context`, then treat `freshness` as the state of that indexed context rather than the file content. + +The 16 MiB hard input-size limit is separate from the `--max-bytes` output-page cap. It rejects larger raw reads and structural text-config summaries before unbounded I/O, bounding complete-stream binary/UTF-8 validation and total-line counting. At an offset beyond EOF, JSON `content` and `text` are empty; pretty output says `Lines: none at offset of `. + +If the `explore` query is only an exact indexed file path, JSON adds `fileView` with the same file-read contract and pretty output renders a `File view` section. An agent can continue from `fileView.page.nextOffset`; `--no-source` disables it. + +```bash +codegraph explore src/auth.ts --json +codegraph explore src/auth.ts --include-graph-context --pretty +``` + +Secret-prone text configs return structural keys instead of raw values unless `--allow-sensitive` is passed intentionally; key material defaults to metadata, may report file size, and does not read raw secret bytes. Intentional raw access remains subject to the 16 MiB input limit and still rejects known binary extensions, NUL bytes, and malformed or incomplete UTF-8, so `.p12` and `.pfx` bundles remain metadata-only in practice. See [CLI reference](./cli.md#live-file-views) for exact fields, limits, trailing-newline behavior, and sensitive kinds. + ## Orientation packets Start with `orient` when an agent needs compact repo context without flooding the first prompt: diff --git a/docs/cli.md b/docs/cli.md index 2328a6a9..0f0e9b9f 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -148,6 +148,13 @@ codegraph explore src/auth.ts --json codegraph search "build review report" --json codegraph explain src/review.ts --json codegraph packet get src/cli.ts --pretty +# Read a live file; JSON is the default +codegraph file src/cli.ts +codegraph file src/cli.ts --offset 201 --limit 100 --max-bytes 40000 --pretty + +# Add direct importers, imports, and symbols only when needed +codegraph file src/cli.ts --include-graph-context --json + codegraph search "public users" --mode sql --json codegraph search "handle login" --from src/auth.ts --mode graph --depth 1 --json codegraph search --help @@ -271,6 +278,39 @@ Short JSON shape: - Use `packet get` with file paths, symbol names, SQL object names, file/symbol/chunk/SQL/graph handles, or review handles to retrieve bounded evidence plus follow-up commands. - Agent commands reuse the incremental index path and default to disk cache. Use shared index flags such as `--cache`, `--cache-strict`, `--cache-verify`, `--threads`, `--native`, `--workers`, `--include-glob`, `--ignore-glob`, and `--no-gitignore` when the packet should match a specific scan mode. +#### Live file views + +`file ` reads the current file bytes from disk, independent of index freshness. JSON is the default; pass `--pretty` for a header, optional graph summary, exact numbered lines, and a copyable next-page command. + +- `--offset ` is the 1-based first line and defaults to `1`. `--limit ` is the maximum line count, defaults to `2000`, and is capped at `10000`. +- For raw file pages, `--max-bytes ` bounds unnumbered text including line separators before numbering; it defaults to `80000` and is capped at `500000`. A raw page can therefore end before `--limit`, and `truncated` is true when the boundary cuts a selected line. +- A separate 16 MiB hard input-size limit rejects larger raw reads and structural text-config summaries before unbounded I/O. It bounds complete-stream binary/UTF-8 validation and total-line counting, not the returned page size. +- `totalLines` counts the complete live file, not only the returned prefix. Follow `page.nextOffset` when present; an offset beyond the end returns empty `content` and `text` while retaining the exact total, and pretty output says `Lines: none at offset of `. +- `content` uses `lineFormat: "number-tab-line"`: an unpadded decimal line number, one tab, then the source line. `text` contains the same selected source lines without number prefixes. +- A trailing newline creates a final numbered empty line. For example, `alpha\nbeta\n` has `totalLines: 3`, and its last `content` line is `3\t`. +- `--include-graph-context` is explicit opt-in and adds up to 100 direct `usedBy` paths, imports, and symbols. Plain file reads do not build or consult the index; `freshness` describes indexed context separately and never changes the live bytes returned. +- Within the 16 MiB input limit, ordinary file reads and structural summaries for recognized environment, authentication, and credential text configs validate the full raw stream, rejecting known binary extensions, NUL bytes, and malformed or incomplete UTF-8 before returning bounded content or extracting bounded keys. Default key-material summaries instead use file metadata, may report size, and do not read raw secret bytes; `--allow-sensitive` requests raw values but does not bypass the input-size, binary, NUL, or UTF-8 guards, so `.p12` and `.pfx` bundles summarize by default and reject raw access. For text-config summaries, `truncated` reports an incomplete bounded structural scan. + +The JSON response fields are `schemaVersion`, `file`, effective `offset` and `limit`, exact `totalLines`, numbered `content`, `lineFormat`, unnumbered `text`, `truncated`, and `freshness`. Optional fields are `page: { nextOffset }`, `graphContext: { usedBy, imports, symbols }`, and `sensitive: { kind, redacted, allowSensitiveRequired }`. + +```json +{ + "schemaVersion": 1, + "file": "src/cli.ts", + "offset": 201, + "limit": 2, + "totalLines": 487, + "content": "201\texport function run(): void {\n202\t return;", + "lineFormat": "number-tab-line", + "text": "export function run(): void {\n return;", + "truncated": false, + "freshness": { "state": "fresh" }, + "page": { "nextOffset": 203 } +} +``` + +An `explore` query that is only an indexed project-relative file path, such as `codegraph explore src/auth.ts --json`, adds this same live response as top-level `fileView`. A uniquely matching basename also resolves; `--no-source` suppresses the file view, while `--include-graph-context` and `--allow-sensitive` pass through explicitly. + `search` is deterministic and vectorless. Hybrid search is code-first by default: source symbols and implementation files outrank docs unless `--mode text` is explicit or docs are the strongest remaining evidence. Search JSON now includes top-level `analysis` metadata plus per-result `provenance` so mixed or reduced runs stay visible. `explain` resolves file paths, symbol names, SQL object names, and search handles into bounded packets with symbols, graph context, references, snippets, duplicate context, SQL facts, review tasks, candidate tests, analysis metadata, limits, omissions, and follow-ups. Use `--max-duplicates` to tune duplicate context in `explain` and `packet get`; duplicate context also uses an internal pair budget and reports skipped duplicate work through omission counts. `explore` is a facade over existing primitives, not a second search engine. It returns `schemaVersion: 1`, the original query, `analysis`, summary bullets, anchors, packets, dependency paths, blast radius with per-entry omitted lower bounds, candidate tests, follow-ups, flat limits, and omission counts; path and blast-radius omissions may be lower bounds after bounded scans reach their caps. diff --git a/docs/library-api.md b/docs/library-api.md index dbd77383..a0acf07a 100644 --- a/docs/library-api.md +++ b/docs/library-api.md @@ -1,6 +1,6 @@ # Library API -Programmatic APIs for indexing, graph building, agent search/explain/artifacts, MCP handlers, chunking, SQL artifact facts, read-only SQLite inspection, and impact analysis. +Programmatic APIs for indexing, graph building, live file views, agent search/explain/artifacts, MCP handlers, chunking, SQL artifact facts, read-only SQLite inspection, and impact analysis. For sessions, streaming workflows, tool wrappers, and review-oriented recipes, see [docs/agent-workflows.md](./agent-workflows.md). @@ -45,8 +45,8 @@ const index = await buildProjectIndex(root, { The npm package exposes these supported entry points: - `@lzehrung/codegraph` for the compatibility root surface. -- `@lzehrung/codegraph/agent` for agent sessions, orient/search/explain, - artifacts, and MCP handler helpers. +- `@lzehrung/codegraph/agent` for agent sessions, live file views, + orient/search/explain, artifacts, and MCP handler helpers. - `@lzehrung/codegraph/graphs` for graph builders, graph queries, renderers, symbol graphs, grep, hotspots, cycles, and unresolved-import helpers. - `@lzehrung/codegraph/indexer` for project indexing, navigation, references, @@ -66,7 +66,7 @@ as three groups: navigation (`buildProjectIndex`, `buildProjectIndexIncremental`, `goToDefinition`, `findReferences`, symbol handles, graph builders and renderers), impact and review reports, sessions, agent search/explain/artifact - helpers, MCP handlers, SQLite helpers, SQL artifact APIs, chunking, config, + and file-view helpers, MCP handlers, SQLite helpers, SQL artifact APIs, chunking, config, language metadata, and native runtime capability checks. - Public-legacy APIs remain exported for existing callers but are lower-level building blocks. This includes parser-facing helpers such as `parseFile`, @@ -83,6 +83,57 @@ Future API narrowing should happen by first documenting replacements on these subpath facades, then adding deprecation notes before removing root compatibility exports. +## Live file views + +`getCodegraphFileView()` reads a confined project file directly from disk. `getCodegraphFileViewWithSession()` accepts an existing `AgentSession` for optional graph reuse, and `formatAgentFileViewResponse()` renders the same response as stable pretty text. + +```ts +import { + createAgentSession, + formatAgentFileViewResponse, + getCodegraphFileView, + getCodegraphFileViewWithSession, +} from "@lzehrung/codegraph"; + +const page = await getCodegraphFileView({ + root: process.cwd(), + file: "src/auth.ts", + offset: 1, + limit: 200, + maxBytes: 80_000, +}); + +if (page.page?.nextOffset) { + const next = await getCodegraphFileView({ + root: process.cwd(), + file: page.file, + offset: page.page.nextOffset, + limit: page.limit, + }); + console.log(next.content); +} + +const session = createAgentSession({ root: process.cwd() }); +const contextual = await getCodegraphFileViewWithSession(session, { + root: process.cwd(), + file: "src/auth.ts", + includeGraphContext: true, +}); +console.log(formatAgentFileViewResponse(contextual)); +``` + +`AgentFileViewRequest` has `root`, `file`, optional 1-based `offset`, `limit`, `maxBytes`, `includeGraphContext`, `allowSensitive`, and `buildOptions`. Line and output-page byte defaults are `2000` and `80000`, capped at `10000` and `500000`; the page byte budget applies to unnumbered raw text, including line separators. A separate 16 MiB hard input-size limit rejects larger raw reads and structural text-config summaries before unbounded I/O, bounding complete-stream binary/UTF-8 validation and total-line counting. + +`AgentFileViewResponse` always has `schemaVersion`, normalized project-relative `file`, effective `offset` and `limit`, exact whole-file `totalLines`, numbered `content`, `lineFormat: "number-tab-line"`, unnumbered `text`, `truncated`, and `freshness`. Optional `page.nextOffset`, `graphContext`, and `sensitive` fields describe remaining lines, requested indexed context, and sensitive-file handling respectively. + +The numbered format is exactly unpadded decimal line number, tab, source line. A trailing newline contributes a final empty line, and pagination should resume from `page.nextOffset`; for raw pages, `maxBytes` may return fewer than `limit` lines and sets `truncated` when it cuts a selected line. An offset beyond EOF returns empty `content` and `text`, while `formatAgentFileViewResponse()` says `Lines: none at offset of `. + +Graph context defaults off, so a plain call neither creates nor consults an index and its live bytes are independent of session freshness. Set `includeGraphContext: true` to request at most 100 direct `usedBy` files, imports, and `{ name, kind, line }` symbols; only this indexed context is governed by `freshness`. + +Within the 16 MiB input limit, ordinary reads and structural summaries for recognized environment, authentication, and credential text configs validate the full raw stream before returning bounded content or extracting bounded keys; known binary extensions, NUL bytes, and malformed or incomplete UTF-8 are rejected. Default key-material summaries use file metadata, may report size, and do not read raw secret bytes; `allowSensitive: true` requests raw values but does not bypass the input-size, binary, NUL, or UTF-8 guards, so `.p12` and `.pfx` bundles summarize by default and reject raw access. For text-config summaries, `truncated` reports an incomplete bounded structural scan. + +The root package and `@lzehrung/codegraph/agent` export these functions, constants, `AgentFileViewRequest`, `AgentFileViewResponse`, `AgentFileGraphContext`, `AgentFileViewSensitiveInfo`, and `AgentFileViewSensitiveKind`. + ## Agent packets `orientCodegraph()` returns compact first-turn context for an agent, and `getCodegraphPacket()` retrieves bounded evidence by file path, symbol name, SQL object name, or stable target: @@ -136,9 +187,17 @@ const explored = await exploreCodegraph({ }); console.log(explored.summary, explored.paths, explored.followUps); + +const exactFile = await exploreCodegraph({ + root: process.cwd(), + query: "src/auth.ts", + includeGraphContext: false, +}); +console.log(exactFile.fileView?.content, exactFile.fileView?.page?.nextOffset); ``` Use `exploreCodegraph()` when the caller has a broad question and needs one bounded response over the existing search, packet, path, reverse-dependency, and candidate-test surfaces. The response has `schemaVersion: 1`, the original query, `analysis`, summary bullets, anchors, packets, paths, blast radius with per-entry omitted lower bounds, candidate tests, follow-ups, flat limits, and omission counts. Path and blast-radius omissions may be lower bounds after bounded scans reach their caps. +When the entire query resolves to an indexed project-relative file path, or to one uniquely matching basename, the response also includes the live `fileView` described above. `includeSource: false` suppresses it; `includeGraphContext` and `allowSensitive` remain explicit request options and are never enabled automatically. Use `mode: "sql"` for SQL objects, or pass `from` plus `depth` with `mode: "graph"` to boost matches near a file path, file/chunk/graph handle, symbol handle, SQL handle, or symbol name. diff --git a/docs/mcp.md b/docs/mcp.md index 577ca780..5f7d839f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -52,9 +52,58 @@ The server exposes the same bounded primitives as the CLI and library session la MCP keeps one Codegraph session warm for the configured root. That makes follow-up calls cheaper than separate CLI invocations. Startup is lazy unless `--warmup` or `--warmup-symbols` is passed. Before index-backed tool calls, MCP checks whether discovered files changed since the warm snapshot. Small changes refresh the session automatically, and responses include `freshness.state` as `fresh`, `refreshed`, or `stale`; stale responses also include `changedFileCount`, `omittedChangedFileCount`, and a bounded changed-file sample. Use `refresh_index` when you need to force a rebuild, reset SQLite artifact state, or refresh after a change burst that exceeds the automatic refresh limits. `query_sqlite` refreshes Codegraph-owned SQLite artifacts after small edits when write access is enabled; otherwise it refuses to serve stale artifact rows. `artifact_build` refuses to write outputs from a stale MCP index; run `refresh_index` first after large change bursts. -`get_file` reads live bytes from disk after path confinement. Its `content` field uses `lineFormat: "number-tab-line"`: no padding, decimal line number, tab, then source line; a file-ending newline produces a final numbered empty line. Set `includeGraphContext: true` to opt into freshness checking and direct graph context for indexed files, capped at 100 importers, imports, and symbols each. +`get_file` reads live bytes from disk after path confinement. It does not require a fresh index; only an explicit `includeGraphContext: true` checks indexed freshness and adds direct graph context, so returned file bytes and `totalLines` remain live even when `freshness` reports stale context. Tool schemas are flat JSON objects for broad client compatibility; argument combinations such as `refs` handle-vs-position mode are validated by the server. +### `get_file` + +Call `get_file` with a project-relative `file`. `offset` is the 1-based first line, `limit` is the maximum returned lines, and `maxBytes` bounds unnumbered raw page text including its line separators; defaults are `1`, `2000`, and `80000`, with output-page caps of `10000` lines and `500000` bytes. A separate 16 MiB hard input-size limit rejects larger raw reads and structural text-config summaries before unbounded I/O, bounding complete-stream binary/UTF-8 validation and total-line counting rather than returned page size. + +```json +{ + "file": "src/auth.ts", + "offset": 41, + "limit": 2, + "maxBytes": 80000, + "includeGraphContext": false, + "allowSensitive": false +} +``` + +The response always has `schemaVersion`, `file`, effective `offset` and `limit`, exact whole-file `totalLines`, numbered `content`, `lineFormat`, unnumbered `text`, `truncated`, and `freshness`. It adds `page: { nextOffset }` when more lines remain, `graphContext: { usedBy, imports, symbols }` when requested and indexed, and `sensitive: { kind, redacted, allowSensitiveRequired }` for recognized sensitive paths. + +```json +{ + "schemaVersion": 1, + "file": "src/auth.ts", + "offset": 41, + "limit": 2, + "totalLines": 126, + "content": "41\texport function authenticate(request) {\n42\t return verify(request);", + "lineFormat": "number-tab-line", + "text": "export function authenticate(request) {\n return verify(request);", + "truncated": false, + "freshness": { "state": "fresh" }, + "page": { "nextOffset": 43 } +} +``` + +Each `content` row is an unpadded decimal line number, one tab, and the source line. A file-ending newline is a final numbered empty line, `totalLines` counts it, and clients should continue at `page.nextOffset` rather than deriving the next page from byte length. + +For raw pages, `maxBytes` can end a page before `limit`; `truncated` is true when the byte boundary cuts the selected line. Number prefixes are not part of that raw-text byte budget, so bounded `text` and formatted `content` have different byte sizes. + +`includeGraphContext` defaults to `false` to avoid an index build, unnecessary repository disclosure, and stale graph data on ordinary reads. When true, `graphContext` contains at most 100 sorted direct `usedBy` paths, resolved or external `imports`, and `{ name, kind, line }` symbols; `freshness` applies to this context, never to the live file page. + +Within the 16 MiB input limit, ordinary reads and structural summaries for recognized environment, authentication-config, and credential-config text files validate the full raw stream before returning bounded content or extracting bounded keys; known binary extensions, NUL bytes, and malformed or incomplete UTF-8 are rejected. Default key-material summaries use file metadata, may report size, and do not read raw secret bytes while marking `sensitive.redacted: true`; `allowSensitive: true` requests raw values but does not bypass the input-size, binary, NUL, or UTF-8 guards, so `.p12` and `.pfx` bundles summarize by default and reject raw access. For text-config summaries, `truncated` reports an incomplete bounded structural scan. + +### Exact-path `explore` + +An MCP `explore` request whose entire query resolves to an indexed project-relative file path, or one uniquely matching basename, includes the same live response under `fileView`. Set `includeSource: false` to suppress it; use `get_file` when pagination, graph context, or an intentional raw sensitive read needs explicit controls. + +```json +{ "query": "src/auth.ts", "includeSource": true } +``` + ## Safety - File and artifact paths are confined to `--root` after realpath resolution. @@ -62,7 +111,7 @@ Tool schemas are flat JSON objects for broad client compatibility; argument comb - Tools are read-only by default. - `artifact_build` requires `--allow-build` and a fresh or auto-refreshed MCP index. - `query_sqlite` rejects mutating SQL, recursive queries, synthetic payload functions, and stale artifact queries it cannot refresh safely. -- `get_file` reads are byte- and line-bounded with `maxBytes`, `offset`, and `limit`. +- `get_file` rejects raw reads and structural text-config summaries over the 16 MiB input limit. Accepted reads use separate output-page bounds from `maxBytes`, `offset`, and `limit`; binary input is rejected, and sensitive formats require `allowSensitive: true` for raw values. - SQLite responses are row- and byte-bounded. ## Installer diff --git a/docs/plans/2026-07-03-07-read-parity-file-view.md b/docs/plans/2026-07-03-07-read-parity-file-view.md index 3bbc1588..ae5824c9 100644 --- a/docs/plans/2026-07-03-07-read-parity-file-view.md +++ b/docs/plans/2026-07-03-07-read-parity-file-view.md @@ -2,43 +2,47 @@ ## Goal -Make Codegraph's file-read surface a practical replacement for raw file reads in agent workflows, while adding graph context that normal reads cannot provide. +Make Codegraph's live file-read surface a practical replacement for raw file reads in agent workflows without making indexed graph access implicit. -Target surfaces: +Implemented surfaces: - MCP `get_file` -- CLI `codegraph file ` or `codegraph packet get ` enhancement -- `explore` file-path mode if implemented +- CLI `codegraph file ` +- exact file-path `explore` responses through `fileView` +- root and agent-subpath library exports ## Design -Enhance `get_file` to return live file content from disk with predictable pagination and optional graph context. - -Input: +The shared reader returns current disk bytes with predictable line and byte pagination. Graph context and raw sensitive values are separate explicit choices. ```ts -type GetFileInput = { +type AgentFileViewRequest = { + root: string; file: string; offset?: number; limit?: number; maxBytes?: number; includeGraphContext?: boolean; + allowSensitive?: boolean; + buildOptions?: BuildOptions; }; ``` -Defaults: +Defaults and caps: -- `offset`: 1 -- `limit`: 2000 lines -- `maxBytes`: existing bounded default -- `includeGraphContext`: true for source files, false for non-source or sensitive formats +- `offset`: 1-based, default `1` +- `limit`: default `2000` lines, cap `10000` +- `maxBytes`: default `80000`, cap `500000`, applied to unnumbered raw page text including line separators +- input size: hard 16 MiB for raw reads and structural text-config summaries, separate from `maxBytes`; larger inputs reject before unbounded I/O +- `includeGraphContext`: `false` +- `allowSensitive`: `false` -## Output +The graph default is intentionally false. Ordinary reads should not pay for an index build, disclose dependency neighborhoods, or risk conflating stale indexed context with live file content; callers opt in when that extra context is useful. -JSON: +## Output ```ts -type FileViewResponse = { +type AgentFileViewResponse = { schemaVersion: 1; file: string; offset: number; @@ -46,60 +50,76 @@ type FileViewResponse = { totalLines: number; content: string; lineFormat: "number-tab-line"; + text: string; + truncated: boolean; + freshness: AgentFreshnessResult; graphContext?: { usedBy: string[]; imports: string[]; symbols: Array<{ name: string; kind: string; line: number }>; }; + sensitive?: { + kind: "environment" | "authentication-config" | "credential-config" | "key-material"; + redacted: boolean; + allowSensitiveRequired: true; + }; page?: { nextOffset?: number }; - freshness: FreshnessResult; }; ``` -Text format should be stable and easy for agents to use: +For accepted raw reads, `totalLines` is counted across the complete live file even when only one page is returned. The 16 MiB input limit bounds that counting and the complete-stream binary/UTF-8 validation used for raw reads and structural text-config summaries. `page.nextOffset` is present when another line remains, and a file-ending newline contributes a final empty line. + +`content` is exact unpadded decimal line number, one tab, then source line; `text` is the same selected source without prefixes. For raw pages, the byte budget applies to `text`, so numbered `content` can be larger and a byte boundary can return fewer than `limit` lines. ```text File: src/auth.ts -Used by 4 files: src/server.ts, src/routes.ts, ... -Lines 1-120 of 120 -1 import ... -2 ... +Lines 41-42 of 126 +41 export function authenticate(request) { +42 return verify(request); +Next page: codegraph file src/auth.ts --offset 43 --limit 2 --pretty ``` -Line format must be exact and documented: no padding, number, tab, line. +At an offset beyond EOF, JSON `content` and `text` are empty, while pretty output says `Lines: none at offset of `. + +`graphContext` appears only when requested and available in the index. It contains at most 100 sorted direct importers, imports, and symbols; `freshness` describes that indexed context separately from the always-live file page. + +An `explore` query consisting only of an indexed project-relative path, or a uniquely matching basename, adds the same response under `fileView`. Disabling source with `includeSource: false` or `--no-source` suppresses it; CLI and library callers pass graph/sensitive options through only when explicit. ## Safety -- Constrain paths to `--root` after realpath resolution. -- Respect existing binary/large-file guards. -- For known secret-prone config formats, default to structural summary unless caller explicitly passes an allow flag. Do not add broad secret scanning in this PR. -- Live file bytes should not require fresh index state. +- Constrain paths to `root` or `--root` after final realpath resolution. +- Reject raw reads and structural text-config summaries over the separate 16 MiB hard input-size limit. Within it, validate the complete stream and reject known binary extensions, NUL bytes, and malformed or incomplete UTF-8 before returning a bounded page or extracting bounded keys. +- For default key-material reads, return metadata that may report file size without reading raw secret bytes. Explicit `allowSensitive: true` or `--allow-sensitive` raw access remains subject to the input-size, binary, NUL, and UTF-8 guards, so `.p12` and `.pfx` bundles summarize by default and reject raw access. +- Read live bytes without requiring fresh index state; check freshness only for requested graph context. -## Files likely touched +## Implementation surface -- `src/mcp/tools.ts` -- `src/mcp/server.ts` -- `src/agent/packet.ts` if packet file output is aligned -- `docs/mcp.md` -- `docs/agent-workflows.md` -- tests under `tests/mcp-server.test.ts` or new `tests/file-view.test.ts` +- `src/agent/fileView.ts` +- `src/agent/explore.ts` +- `src/cli/file.ts` and CLI routing/help/options +- `src/mcp/server.ts` and `src/mcp/tools.ts` +- root and agent facade exports +- canonical CLI, workflow, MCP, library, and skill documentation ## Tests -- line-number format is exactly `1\ttext`. -- `offset` and `limit` paginate correctly. -- trailing empty line behavior is stable and documented. -- large file returns next page guidance. -- file outside root is rejected. -- graph context includes direct importers for source files. -- file content reflects live disk changes even when session index is stale. +- Exact `1\ttext` number-tab-line format and unnumbered `text`. +- 1-based offset, line limit, byte limit, exact whole-file `totalLines`, and `nextOffset` pagination beyond the former prefix. +- 16 MiB input rejection independently of the 500000-byte output-page cap. +- Beyond-EOF offsets return empty JSON content/text and explicit pretty no-lines output. +- Stable trailing empty line for a file-ending newline. +- Root confinement, binary rejection, text-config structural summaries, key-material metadata summaries, and guarded explicit raw-sensitive access. +- Live disk changes remain visible independently of stale index state. +- Graph context is absent by default and bounded when explicitly requested. +- Exact file-path explore responses include `fileView`; broad queries and no-source mode do not. ## Acceptance -- Agents can use `get_file` where they would normally read a source file. -- The response includes enough context to reduce follow-up graph queries. -- Output remains bounded and safe. +- Agents can use CLI `file`, MCP `get_file`, library helpers, or exact-path `explore` where they would normally read source. +- Every surface returns the same bounded line-page contract and clear continuation offset. +- Graph context remains explicit opt-in, and live-byte correctness remains separate from index freshness. +- Binary and sensitive formats remain safe by default. ## Review pass -Checked scope: this plan improves the existing file tool instead of adding a redundant tool. It preserves root confinement and separates live file bytes from indexed graph freshness. +The implementation adds one shared file-view contract rather than another packet format. It preserves root confinement, keeps live bytes independent of the index, and intentionally defaults graph context off for safety and predictable read cost. diff --git a/src/agent.ts b/src/agent.ts index 3d0608a5..ee402d42 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -1,3 +1,20 @@ +export { + DEFAULT_FILE_VIEW_BYTES, + DEFAULT_FILE_VIEW_LINES, + FILE_VIEW_GRAPH_CONTEXT_LIMIT, + formatAgentFileViewResponse, + getCodegraphFileView, + getCodegraphFileViewWithSession, + MAX_FILE_VIEW_BYTES, + MAX_FILE_VIEW_LINES, +} from "./agent/fileView.js"; +export type { + AgentFileGraphContext, + AgentFileViewRequest, + AgentFileViewResponse, + AgentFileViewSensitiveInfo, + AgentFileViewSensitiveKind, +} from "./agent/fileView.js"; export { createAgentSession } from "./agent/session.js"; export type { AgentFreshnessPolicy, diff --git a/src/agent/explore.ts b/src/agent/explore.ts index 9814df8e..f11f9ebf 100644 --- a/src/agent/explore.ts +++ b/src/agent/explore.ts @@ -3,6 +3,11 @@ import type { AnalysisSummary } from "../analysisSummary.js"; import { getReverseDependencies, getShortestPath, type DependencyNode } from "../graphs/traversal.js"; import type { BuildOptions } from "../indexer/types.js"; import { toProjectDisplayPath } from "../util/paths.js"; +import { + formatAgentFileViewResponse, + getCodegraphFileViewWithSession, + type AgentFileViewResponse, +} from "./fileView.js"; import { getCodegraphPacketWithSession, type AgentPacketResponse } from "./packet.js"; import { searchCodegraphWithSession, type AgentSearchResponse, type AgentSearchResult } from "./search.js"; import { createAgentSession, type AgentProjectSnapshot, type AgentSession } from "./session.js"; @@ -16,6 +21,8 @@ export type AgentExploreRequest = { maxPackets?: number; maxPaths?: number; includeSource?: boolean; + includeGraphContext?: boolean; + allowSensitive?: boolean; }; export type AgentExplorePacketSummary = AgentPacketResponse; @@ -57,6 +64,7 @@ export type AgentExploreResponse = { summary: string[]; anchors: AgentSearchResult[]; packets: AgentExplorePacketSummary[]; + fileView?: AgentFileViewResponse; paths: AgentExploreDependencyPathSummary[]; blastRadius: AgentExploreBlastRadiusSummary[]; candidateTests: string[]; @@ -108,6 +116,17 @@ export async function exploreCodegraphWithSession( const anchorFiles = collectAnchorFiles(snapshot, request.query, anchors); const packetTargets = includeSource ? collectPacketTargets(anchors, effectivePacketLimit) : []; const packets = await collectPackets(session, request.root, packetTargets); + const exactFile = includeSource ? resolveExactFileTarget(snapshot, request.query) : undefined; + let fileView: AgentFileViewResponse | undefined; + if (exactFile) { + fileView = await getCodegraphFileViewWithSession(session, { + root: request.root, + file: toProjectDisplayPath(snapshot.root, exactFile), + ...(request.includeGraphContext !== undefined ? { includeGraphContext: request.includeGraphContext } : {}), + ...(request.allowSensitive !== undefined ? { allowSensitive: request.allowSensitive } : {}), + ...(request.buildOptions ? { buildOptions: request.buildOptions } : {}), + }); + } const pathResult = collectDependencyPaths(snapshot, request.query, anchorFiles, maxPaths); const paths = pathResult.items; const blastRadius = collectBlastRadius( @@ -124,9 +143,10 @@ export async function exploreCodegraphWithSession( schemaVersion: 1, query: request.query, analysis: search.analysis, - summary: buildSummary(search, packets, paths, blastRadius, candidateTests), + summary: buildSummary(search, packets, paths, blastRadius, candidateTests, fileView), anchors, packets, + ...(fileView ? { fileView } : {}), paths, blastRadius, candidateTests, @@ -169,6 +189,10 @@ export function formatAgentExploreResponse(response: AgentExploreResponse): stri lines.push("- No anchors found."); } + if (response.fileView) { + lines.push("", "File view", formatAgentFileViewResponse(response.fileView)); + } + lines.push("", "Relevant source"); if (response.packets.length) { for (const packet of response.packets) { @@ -309,6 +333,19 @@ function extractFileMentions(snapshot: AgentProjectSnapshot, query: string): str return uniqueFiles(explicitFiles); } +function resolveExactFileTarget(snapshot: AgentProjectSnapshot, query: string): string | undefined { + const normalizedQuery = normalizeQueryPathText(query.trim()); + if (!normalizedQuery) return undefined; + const containsWhitespace = /\s/.test(normalizedQuery); + const basenameMatches: string[] = []; + for (const file of snapshot.fileGraph.nodes) { + const relative = normalizeQueryPathText(toProjectDisplayPath(snapshot.root, file)); + if (relative === normalizedQuery) return file; + if (!containsWhitespace && path.basename(relative).toLowerCase() === normalizedQuery) basenameMatches.push(file); + } + return basenameMatches.length === 1 ? basenameMatches[0] : undefined; +} + function normalizeQueryPathText(input: string): string { return input.replace(/\\/g, "/").replace(/^\.\//, "").toLowerCase(); } @@ -470,6 +507,10 @@ function collectFollowUps( includeSource: boolean, ): string[] { const followUps: string[] = []; + for (const file of anchorFiles.slice(0, 3)) { + const relative = toProjectDisplayPath(root, file); + followUps.push(`codegraph file ${quoteShellArg(relative)} --pretty`); + } for (const file of anchorFiles.slice(0, 3)) { const relative = toProjectDisplayPath(root, file); followUps.push(`codegraph packet get ${quoteShellArg(relative)} --pretty`); @@ -511,11 +552,15 @@ function buildSummary( paths: readonly AgentExploreDependencyPathSummary[], blastRadius: readonly AgentExploreBlastRadiusSummary[], candidateTests: readonly string[], + fileView: AgentFileViewResponse | undefined, ): string[] { if (!search.results.length) { return [`No anchors matched "${search.query}".`, "Use follow-ups to broaden the search or orient the repository."]; } const summary = [`Found ${search.results.length} anchor(s) for "${search.query}".`]; + if (fileView) { + summary.push(`Included live file view for ${fileView.file}.`); + } if (packets.length) { summary.push(`Included ${packets.length} bounded source packet(s).`); } diff --git a/src/agent/fileView.ts b/src/agent/fileView.ts new file mode 100644 index 00000000..81ea00d5 --- /dev/null +++ b/src/agent/fileView.ts @@ -0,0 +1,616 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +import type { BuildOptions } from "../indexer/types.js"; +import { resolveProjectFile, resolveReadableFile } from "../util/confinedFile.js"; +import { toProjectDisplayPath } from "../util/paths.js"; +import { + createAgentSession, + type AgentFreshnessResult, + type AgentProjectSnapshot, + type AgentSession, +} from "./session.js"; +import { quoteShellArg } from "./shell.js"; + +export const DEFAULT_FILE_VIEW_BYTES = 80_000; +export const MAX_FILE_VIEW_BYTES = 500_000; +export const DEFAULT_FILE_VIEW_LINES = 2_000; +export const MAX_FILE_VIEW_LINES = 10_000; +export const FILE_VIEW_GRAPH_CONTEXT_LIMIT = 100; + +export type AgentFileGraphContext = { + usedBy: string[]; + imports: string[]; + symbols: Array<{ name: string; kind: string; line: number }>; +}; + +export type AgentFileViewSensitiveKind = "environment" | "authentication-config" | "credential-config" | "key-material"; + +export type AgentFileViewSensitiveInfo = { + kind: AgentFileViewSensitiveKind; + redacted: boolean; + allowSensitiveRequired: true; +}; + +export type AgentFileViewRequest = { + root: string; + file: string; + offset?: number; + limit?: number; + maxBytes?: number; + includeGraphContext?: boolean; + allowSensitive?: boolean; + buildOptions?: BuildOptions; +}; + +export type AgentFileViewResponse = { + schemaVersion: 1; + file: string; + offset: number; + limit: number; + totalLines: number; + content: string; + lineFormat: "number-tab-line"; + text: string; + truncated: boolean; + freshness: AgentFreshnessResult; + graphContext?: AgentFileGraphContext; + sensitive?: AgentFileViewSensitiveInfo; + page?: { nextOffset?: number }; +}; + +type FilePage = { + lines: string[]; + text: string; + totalLines: number; + truncated: boolean; + nextOffset?: number; +}; + +type SensitiveSummary = { + text: string; + scanTruncated: boolean; +}; + +type Utf8ValidationState = { + remainingContinuationBytes: number; + nextByteMin: number; + nextByteMax: number; +}; + +const READ_BUFFER_BYTES = 64 * 1024; +const SENSITIVE_SCAN_BYTES = 64 * 1024; +const SENSITIVE_KEY_LIMIT = 100; +const MAX_FILE_VIEW_SOURCE_BYTES = 16 * 1024 * 1024; +const BINARY_EXTENSIONS = new Set([ + ".7z", + ".avi", + ".bmp", + ".class", + ".dll", + ".dylib", + ".eot", + ".exe", + ".gif", + ".gz", + ".ico", + ".jar", + ".jpeg", + ".jpg", + ".mov", + ".mp3", + ".mp4", + ".o", + ".otf", + ".pdf", + ".p12", + ".pfx", + ".png", + ".so", + ".tar", + ".tgz", + ".ttf", + ".wav", + ".webp", + ".woff", + ".woff2", + ".zip", +]); +const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true }); + +export async function getCodegraphFileView(request: AgentFileViewRequest): Promise { + let session: AgentSession | undefined; + if (request.includeGraphContext) { + session = createAgentSession({ + root: request.root, + ...(request.buildOptions ? { buildOptions: request.buildOptions } : {}), + }); + } + return await getCodegraphFileViewWithSession(session, request); +} + +export async function getCodegraphFileViewWithSession( + session: AgentSession | undefined, + request: AgentFileViewRequest, +): Promise { + const root = path.resolve(request.root); + const realRoot = await fs.realpath(root); + const resolvedFile = await resolveReadableFile(realRoot, root, request.file); + const offset = boundedPositiveInteger(request.offset, 1, Number.MAX_SAFE_INTEGER); + const limit = boundedPositiveInteger(request.limit, DEFAULT_FILE_VIEW_LINES, MAX_FILE_VIEW_LINES); + const maxBytes = boundedPositiveInteger(request.maxBytes, DEFAULT_FILE_VIEW_BYTES, MAX_FILE_VIEW_BYTES); + const displaySensitiveKind = classifySensitiveFile(resolvedFile.displayPath); + let sensitiveKind = classifySensitiveFile(resolvedFile.realPath); + const keyMaterialAlias = displaySensitiveKind === "key-material" || sensitiveKind === "key-material"; + if (keyMaterialAlias) sensitiveKind = "key-material"; + else if (!sensitiveKind) sensitiveKind = displaySensitiveKind; + + let page: FilePage; + let truncated: boolean; + let sensitive: AgentFileViewSensitiveInfo | undefined; + if (sensitiveKind && !request.allowSensitive) { + const summary = await buildSensitiveSummary(resolvedFile.realPath, sensitiveKind); + page = paginateText(summary.text, offset, limit); + truncated = summary.scanTruncated; + sensitive = { kind: sensitiveKind, redacted: true, allowSensitiveRequired: true }; + } else { + page = await readTextFilePage(resolvedFile.realPath, offset, limit, maxBytes); + truncated = page.truncated; + if (sensitiveKind) { + sensitive = { kind: sensitiveKind, redacted: false, allowSensitiveRequired: true }; + } + } + + let freshness: AgentFreshnessResult = { state: "fresh" }; + let graphContext: AgentFileGraphContext | undefined; + if (request.includeGraphContext) { + const activeSession = + session ?? + createAgentSession({ + root, + ...(request.buildOptions ? { buildOptions: request.buildOptions } : {}), + }); + if (activeSession.checkFreshness) { + freshness = await activeSession.checkFreshness(); + } + const projectFile = await resolveProjectFile(realRoot, root, request.file); + const snapshot = await activeSession.loadProject({ symbolGraph: "skip" }); + graphContext = buildFileGraphContext(snapshot, projectFile); + } + + return buildResponse({ + file: resolvedFile.displayPath, + offset, + limit, + page, + truncated, + freshness, + ...(graphContext ? { graphContext } : {}), + ...(sensitive ? { sensitive } : {}), + }); +} + +export function formatAgentFileViewResponse(response: AgentFileViewResponse): string { + const lines = [`File: ${response.file}`]; + if (response.sensitive?.redacted) { + lines.push(`Sensitive ${response.sensitive.kind}: values omitted; pass --allow-sensitive to read raw content.`); + } + if (response.graphContext) { + lines.push(formatUsedByLine(response.graphContext.usedBy)); + lines.push(formatContextLine("Imports", response.graphContext.imports)); + const symbols = response.graphContext.symbols.map( + (symbol) => `${symbol.name} (${symbol.kind}, line ${symbol.line})`, + ); + lines.push(formatContextLine("Symbols", symbols)); + } + + const returnedLineCount = response.content ? response.content.split("\n").length : 0; + if (returnedLineCount) { + const endLine = Math.min(response.totalLines, response.offset + returnedLineCount - 1); + lines.push(`Lines ${response.offset}-${endLine} of ${response.totalLines}`); + } else { + lines.push(`Lines: none at offset ${response.offset} of ${response.totalLines}`); + } + if (response.content) lines.push(response.content); + if (response.truncated) { + lines.push(`Content was truncated by the ${MAX_FILE_VIEW_BYTES}-byte hard limit or a smaller requested maxBytes.`); + } + const nextOffset = response.page?.nextOffset; + if (nextOffset !== undefined) { + lines.push( + `Next page: codegraph file ${quoteShellArg(response.file)} --offset ${nextOffset} --limit ${response.limit} --pretty`, + ); + } + return lines.join("\n"); +} + +export function buildFileGraphContext(snapshot: AgentProjectSnapshot, file: string): AgentFileGraphContext | undefined { + const moduleIndex = snapshot.index.byFile.get(file); + if (!moduleIndex) return undefined; + + const usedBy = uniqueSorted( + snapshot.fileGraph.edges + .filter((edge) => edge.to.type === "file" && edge.to.path === file) + .map((edge) => toProjectDisplayPath(snapshot.root, edge.from)), + ).slice(0, FILE_VIEW_GRAPH_CONTEXT_LIMIT); + const imports = uniqueSorted( + moduleIndex.imports.map((importBinding) => { + const resolved = importBinding.resolved; + if (typeof resolved === "string") return toProjectDisplayPath(snapshot.root, resolved); + if (resolved) return resolved.external; + return importBinding.from; + }), + ).slice(0, FILE_VIEW_GRAPH_CONTEXT_LIMIT); + const symbols = moduleIndex.locals + .map((symbol) => ({ name: symbol.localName, kind: symbol.kind, line: symbol.range.start.line })) + .sort((left, right) => left.line - right.line || left.name.localeCompare(right.name)) + .slice(0, FILE_VIEW_GRAPH_CONTEXT_LIMIT); + + return { usedBy, imports, symbols }; +} + +function buildResponse(args: { + file: string; + offset: number; + limit: number; + page: FilePage; + truncated: boolean; + freshness: AgentFreshnessResult; + graphContext?: AgentFileGraphContext; + sensitive?: AgentFileViewSensitiveInfo; +}): AgentFileViewResponse { + return { + schemaVersion: 1, + file: args.file, + offset: args.offset, + limit: args.limit, + totalLines: args.page.totalLines, + content: args.page.lines.map((line, index) => `${args.offset + index}\t${line}`).join("\n"), + lineFormat: "number-tab-line", + text: args.page.text, + truncated: args.truncated, + freshness: args.freshness, + ...(args.graphContext ? { graphContext: args.graphContext } : {}), + ...(args.sensitive ? { sensitive: args.sensitive } : {}), + ...(args.page.nextOffset !== undefined ? { page: { nextOffset: args.page.nextOffset } } : {}), + }; +} + +async function readTextFilePage(filePath: string, offset: number, limit: number, maxBytes: number): Promise { + await assertReadableTextFile(filePath); + + const handle = await fs.open(filePath, "r"); + const selectedLines: string[] = []; + let lineNumber = 1; + let remainingBytes = maxBytes; + let byteBudgetExhausted = false; + let pageTruncated = false; + let currentLineInitialized = false; + let currentLineSelected = false; + let currentLineTruncated = false; + let currentLineChunks: Buffer[] = []; + let lastReturnedLine: number | undefined; + const utf8State = createUtf8ValidationState(); + let totalBytes = 0; + + const initializeLine = (): void => { + if (currentLineInitialized) return; + currentLineInitialized = true; + if (lineNumber < offset || selectedLines.length >= limit || byteBudgetExhausted) return; + if (selectedLines.length) { + if (!remainingBytes) { + byteBudgetExhausted = true; + return; + } + remainingBytes -= 1; + } + currentLineSelected = true; + }; + + const consumeSegment = (segment: Buffer): void => { + initializeLine(); + if (!currentLineSelected || !segment.length) return; + const bytesToKeep = Math.min(segment.length, remainingBytes); + if (bytesToKeep) { + currentLineChunks.push(Buffer.from(segment.subarray(0, bytesToKeep))); + remainingBytes -= bytesToKeep; + } + if (bytesToKeep < segment.length) { + currentLineTruncated = true; + byteBudgetExhausted = true; + } + }; + + const finishLine = (): void => { + initializeLine(); + if (currentLineSelected) { + const lineBuffer = currentLineChunks.length === 1 ? currentLineChunks[0]! : Buffer.concat(currentLineChunks); + selectedLines.push(decodeLineBuffer(lineBuffer, currentLineTruncated, filePath)); + lastReturnedLine = lineNumber; + if (currentLineTruncated) pageTruncated = true; + } + currentLineInitialized = false; + currentLineSelected = false; + currentLineTruncated = false; + currentLineChunks = []; + }; + + try { + const buffer = Buffer.allocUnsafe(READ_BUFFER_BYTES); + while (true) { + const { bytesRead } = await handle.read(buffer, 0, buffer.length, null); + if (!bytesRead) break; + const chunk = buffer.subarray(0, bytesRead); + totalBytes += bytesRead; + assertFileViewSourceBytes(totalBytes, filePath); + if (chunk.includes(0)) { + throw new Error(`Binary files are not supported: ${filePath}`); + } + validateUtf8Chunk(chunk, utf8State, filePath); + let segmentStart = 0; + for (let index = 0; index < chunk.length; index += 1) { + if (chunk[index] !== 0x0a) continue; + consumeSegment(chunk.subarray(segmentStart, index)); + finishLine(); + lineNumber += 1; + segmentStart = index + 1; + } + consumeSegment(chunk.subarray(segmentStart)); + } + assertUtf8Complete(utf8State, filePath); + finishLine(); + } finally { + await handle.close(); + } + + let nextOffset: number | undefined; + if (lastReturnedLine !== undefined && lastReturnedLine < lineNumber) { + nextOffset = lastReturnedLine + 1; + } + return { + lines: selectedLines, + text: selectedLines.join("\n"), + totalLines: lineNumber, + truncated: pageTruncated, + ...(nextOffset !== undefined ? { nextOffset } : {}), + }; +} + +function validateUtf8Chunk(buffer: Buffer, state: Utf8ValidationState, filePath: string): void { + for (const byte of buffer) { + if (state.remainingContinuationBytes) { + if (byte < state.nextByteMin || byte > state.nextByteMax) { + throw new Error(`Binary or non-UTF-8 files are not supported: ${filePath}`); + } + state.remainingContinuationBytes -= 1; + state.nextByteMin = 0x80; + state.nextByteMax = 0xbf; + continue; + } + if (byte <= 0x7f) continue; + if (byte >= 0xc2 && byte <= 0xdf) { + startUtf8Sequence(state, 1, 0x80, 0xbf); + continue; + } + if (byte === 0xe0) { + startUtf8Sequence(state, 2, 0xa0, 0xbf); + continue; + } + if ((byte >= 0xe1 && byte <= 0xec) || (byte >= 0xee && byte <= 0xef)) { + startUtf8Sequence(state, 2, 0x80, 0xbf); + continue; + } + if (byte === 0xed) { + startUtf8Sequence(state, 2, 0x80, 0x9f); + continue; + } + if (byte === 0xf0) { + startUtf8Sequence(state, 3, 0x90, 0xbf); + continue; + } + if (byte >= 0xf1 && byte <= 0xf3) { + startUtf8Sequence(state, 3, 0x80, 0xbf); + continue; + } + if (byte === 0xf4) { + startUtf8Sequence(state, 3, 0x80, 0x8f); + continue; + } + throw new Error(`Binary or non-UTF-8 files are not supported: ${filePath}`); + } +} + +function startUtf8Sequence(state: Utf8ValidationState, continuationBytes: number, min: number, max: number): void { + state.remainingContinuationBytes = continuationBytes; + state.nextByteMin = min; + state.nextByteMax = max; +} + +function assertUtf8Complete(state: Utf8ValidationState, filePath: string): void { + if (!state.remainingContinuationBytes) return; + throw new Error(`Binary or non-UTF-8 files are not supported: ${filePath}`); +} + +function decodeLineBuffer(buffer: Buffer, truncated: boolean, filePath: string): string { + const candidate = truncated ? trimToUtf8Boundary(buffer) : buffer; + try { + return UTF8_DECODER.decode(candidate); + } catch { + throw new Error(`Binary or non-UTF-8 files are not supported: ${filePath}`); + } +} + +function trimToUtf8Boundary(buffer: Buffer): Buffer { + if (!buffer.length) return buffer; + let leadIndex = buffer.length - 1; + while (leadIndex >= 0) { + const byte = buffer[leadIndex]; + if (byte === undefined || (byte & 0xc0) !== 0x80) break; + leadIndex -= 1; + } + if (leadIndex < 0) return buffer.subarray(0, 0); + const leadByte = buffer[leadIndex]; + if (leadByte === undefined) return buffer.subarray(0, 0); + const continuationBytes = buffer.length - leadIndex - 1; + const expectedContinuationBytes = expectedUtf8ContinuationBytes(leadByte); + if (expectedContinuationBytes === null) return buffer.subarray(0, leadIndex); + if (continuationBytes < expectedContinuationBytes) return buffer.subarray(0, leadIndex); + return buffer; +} + +function expectedUtf8ContinuationBytes(byte: number): number | null { + if ((byte & 0x80) === 0) return 0; + if ((byte & 0xe0) === 0xc0) return 1; + if ((byte & 0xf0) === 0xe0) return 2; + if ((byte & 0xf8) === 0xf0) return 3; + return null; +} + +function paginateText(text: string, offset: number, limit: number): FilePage { + const lines = text.split("\n"); + const start = Math.max(0, offset - 1); + const selectedLines = lines.slice(start, start + limit); + const lastReturnedLine = start + selectedLines.length; + let nextOffset: number | undefined; + if (lastReturnedLine < lines.length) nextOffset = lastReturnedLine + 1; + return { + lines: selectedLines, + text: selectedLines.join("\n"), + totalLines: lines.length, + truncated: false, + ...(nextOffset !== undefined ? { nextOffset } : {}), + }; +} + +function classifySensitiveFile(filePath: string): AgentFileViewSensitiveKind | undefined { + const basename = path.basename(filePath).toLowerCase(); + if (basename === ".env" || basename.startsWith(".env.")) return "environment"; + if (basename === ".npmrc" || basename === ".pypirc" || basename === ".netrc") { + return "authentication-config"; + } + if (/^(?:credentials?|secrets?|service-account)(?:\.[^.]+)*\.(?:json|ya?ml|toml|ini)$/i.test(basename)) { + return "credential-config"; + } + const customSshKey = /^id_[a-z0-9_-]+$/i.test(basename); + if (/\.(?:key|pem|p12|pfx)$/i.test(basename) || customSshKey) return "key-material"; + return undefined; +} + +async function buildSensitiveSummary(filePath: string, kind: AgentFileViewSensitiveKind): Promise { + if (kind === "key-material") { + const stat = await fs.stat(filePath); + if (!stat.isFile()) throw new Error(`File view target is not a file: ${filePath}`); + return { + text: `Sensitive key material omitted.\nSize: ${stat.size} bytes.`, + scanTruncated: false, + }; + } + const scan = await scanTextFilePrefix(filePath, SENSITIVE_SCAN_BYTES); + + const text = UTF8_DECODER.decode(trimToUtf8Boundary(scan.prefix)); + const keys = extractSensitiveKeys(text).slice(0, SENSITIVE_KEY_LIMIT); + const keySummary = keys.length ? keys.join(", ") : "No keys detected in bounded structural scan."; + return { + text: `Sensitive ${kind} values omitted.\nKeys: ${keySummary}`, + scanTruncated: scan.totalBytes > scan.prefix.length || keys.length >= SENSITIVE_KEY_LIMIT, + }; +} + +async function scanTextFilePrefix( + filePath: string, + prefixLimit: number, +): Promise<{ prefix: Buffer; totalBytes: number }> { + await assertReadableTextFile(filePath); + const handle = await fs.open(filePath, "r"); + const prefixChunks: Buffer[] = []; + let prefixBytes = 0; + let totalBytes = 0; + const utf8State = createUtf8ValidationState(); + try { + const buffer = Buffer.allocUnsafe(READ_BUFFER_BYTES); + while (true) { + const { bytesRead } = await handle.read(buffer, 0, buffer.length, null); + if (!bytesRead) break; + const chunk = buffer.subarray(0, bytesRead); + if (chunk.includes(0)) { + throw new Error(`Binary files are not supported: ${filePath}`); + } + validateUtf8Chunk(chunk, utf8State, filePath); + if (prefixBytes < prefixLimit) { + const bytesToKeep = Math.min(chunk.length, prefixLimit - prefixBytes); + prefixChunks.push(Buffer.from(chunk.subarray(0, bytesToKeep))); + prefixBytes += bytesToKeep; + } + totalBytes += chunk.length; + assertFileViewSourceBytes(totalBytes, filePath); + } + assertUtf8Complete(utf8State, filePath); + } finally { + await handle.close(); + } + return { + prefix: prefixChunks.length === 1 ? prefixChunks[0]! : Buffer.concat(prefixChunks), + totalBytes, + }; +} + +async function assertReadableTextFile(filePath: string): Promise { + assertTextFileExtension(filePath); + const stat = await fs.stat(filePath); + if (!stat.isFile()) throw new Error(`File view target is not a file: ${filePath}`); + assertFileViewSourceBytes(stat.size, filePath); +} + +function assertFileViewSourceBytes(totalBytes: number, filePath: string): void { + if (totalBytes <= MAX_FILE_VIEW_SOURCE_BYTES) return; + throw new Error(`File exceeds the ${MAX_FILE_VIEW_SOURCE_BYTES}-byte file view input limit: ${filePath}`); +} + +function assertTextFileExtension(filePath: string): void { + if (!BINARY_EXTENSIONS.has(path.extname(filePath).toLowerCase())) return; + throw new Error(`Binary files are not supported: ${filePath}`); +} + +function createUtf8ValidationState(): Utf8ValidationState { + return { + remainingContinuationBytes: 0, + nextByteMin: 0x80, + nextByteMax: 0xbf, + }; +} + +function extractSensitiveKeys(text: string): string[] { + const keys: string[] = []; + const patterns = [ + /(?:^|\n)\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_.:-]*)\s*(?:=|:)/g, + /"([^"\n]+)"\s*:/g, + /(?:^|\s)(machine|login|password|account)\s+/g, + ]; + for (const pattern of patterns) { + for (const match of text.matchAll(pattern)) { + const key = match[1]; + if (key) keys.push(key); + } + } + return uniqueSorted(keys); +} + +function uniqueSorted(values: Iterable): string[] { + return Array.from(new Set(values)).sort((left, right) => left.localeCompare(right)); +} + +function boundedPositiveInteger(value: number | undefined, fallback: number, max: number): number { + if (typeof value !== "number" || !Number.isFinite(value)) return fallback; + return Math.min(max, Math.max(1, Math.floor(value))); +} + +function formatUsedByLine(values: readonly string[]): string { + const noun = values.length === 1 ? "file" : "files"; + if (!values.length) return `Used by 0 ${noun}: none`; + return `Used by ${values.length} ${noun}: ${values.join(", ")}`; +} + +function formatContextLine(label: string, values: readonly string[]): string { + if (!values.length) return `${label} 0: none`; + return `${label} ${values.length}: ${values.join(", ")}`; +} diff --git a/src/cli.ts b/src/cli.ts index f1623679..429ce353 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -32,6 +32,7 @@ import { handleDriftCommand } from "./cli/drift.js"; import { handleDuplicatesCommand } from "./cli/duplicates.js"; import { handleExplainCommand } from "./cli/explain.js"; import { handleExploreCommand } from "./cli/explore.js"; +import { handleFileCommand } from "./cli/file.js"; import { handleGraphDeltaCommand } from "./cli/graphDelta.js"; import { handleGraphQueryCommand } from "./cli/graphQueries.js"; import { handleGrepCommand } from "./cli/grep.js"; @@ -597,6 +598,20 @@ async function runCliWithActiveRuntime(rawArgs: string[]) { }); return; } + if (cmd === "file") { + await handleFileCommand({ + positionals: parsed.positionals, + root: projectRootFs, + buildOptions: buildAgentOptions(), + getOpt, + hasFlag, + writeJSONLine, + writeStdoutLine, + writeStderrLine, + exit: exitCli, + }); + return; + } if (cmd === "search") { await handleSearchCommand({ diff --git a/src/cli/explore.ts b/src/cli/explore.ts index 79427ab0..d3e18cd7 100644 --- a/src/cli/explore.ts +++ b/src/cli/explore.ts @@ -20,6 +20,8 @@ export async function handleExploreCommand(context: ExploreCommandContext): Prom maxPackets: parseNonNegativeIntegerOption(context.getOpt("--max-packets"), "--max-packets", 3), maxPaths: parseNonNegativeIntegerOption(context.getOpt("--max-paths"), "--max-paths", 3), includeSource: !context.hasFlag("--no-source"), + includeGraphContext: context.hasFlag("--include-graph-context"), + allowSensitive: context.hasFlag("--allow-sensitive"), }); if (context.hasFlag("--json") || !context.hasFlag("--pretty")) { diff --git a/src/cli/file.ts b/src/cli/file.ts new file mode 100644 index 00000000..ef6efe72 --- /dev/null +++ b/src/cli/file.ts @@ -0,0 +1,66 @@ +import { + DEFAULT_FILE_VIEW_BYTES, + DEFAULT_FILE_VIEW_LINES, + MAX_FILE_VIEW_BYTES, + MAX_FILE_VIEW_LINES, + formatAgentFileViewResponse, + getCodegraphFileView, +} from "../agent/fileView.js"; +import type { CliAgentCommandContext } from "./context.js"; +import { FILE_HELP_TEXT } from "./help.js"; +import { parseBoundedIntegerOption, parsePositiveIntegerOption } from "./options.js"; + +export type FileCommandContext = CliAgentCommandContext; + +export async function handleFileCommand(context: FileCommandContext): Promise { + const file = context.positionals[0]; + if (file === undefined) { + context.writeStderrLine(FILE_HELP_TEXT.trimEnd()); + context.exit(2); + } + + try { + const response = await getCodegraphFileView({ + root: context.root, + file, + ...(context.getOpt("--offset") !== undefined + ? { offset: parsePositiveIntegerOption(context.getOpt("--offset"), "--offset", 1) } + : {}), + ...(context.getOpt("--limit") !== undefined + ? { + limit: parseBoundedIntegerOption( + context.getOpt("--limit"), + "--limit", + DEFAULT_FILE_VIEW_LINES, + 1, + MAX_FILE_VIEW_LINES, + ), + } + : {}), + ...(context.getOpt("--max-bytes") !== undefined + ? { + maxBytes: parseBoundedIntegerOption( + context.getOpt("--max-bytes"), + "--max-bytes", + DEFAULT_FILE_VIEW_BYTES, + 1, + MAX_FILE_VIEW_BYTES, + ), + } + : {}), + includeGraphContext: context.hasFlag("--include-graph-context"), + allowSensitive: context.hasFlag("--allow-sensitive"), + ...(context.buildOptions ? { buildOptions: context.buildOptions } : {}), + }); + + if (context.hasFlag("--json") || !context.hasFlag("--pretty")) { + context.writeJSONLine(response); + } else { + context.writeStdoutLine(formatAgentFileViewResponse(response)); + } + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + context.writeStderrLine(message); + context.exit(1); + } +} diff --git a/src/cli/help.ts b/src/cli/help.ts index 4fc912ac..f8f83bca 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -5,6 +5,7 @@ Usage: codegraph [options] [path] Commands: orient Build a compact first-turn packet for agent repo context explore Answer a broad repo question with search, packets, paths, and blast radius + file Read a live project file with bounded line pagination review Generate code review report packet Retrieve bounded evidence packets by file path or stable target search Ranked agent search across files, symbols, chunks, SQL, and graph context @@ -86,6 +87,7 @@ Examples: codegraph orient ./src --budget small --pretty codegraph search "auth user" --json codegraph explore "how does auth reach db?" --pretty + codegraph file src/auth.ts --pretty codegraph explain src/auth.ts --json codegraph impact --provider git --base HEAD --head WORKTREE codegraph init --root . @@ -130,6 +132,7 @@ const knownCliCommands = new Set([ "dumpmod", "explain", "explore", + "file", "goto", "graph", "graph-delta", @@ -200,16 +203,28 @@ Safety: export const EXPLORE_HELP_TEXT = `codegraph explore - Answer a broad repo question with bounded repo context -Usage: codegraph explore "" [--root ] [--limit ] [--max-packets ] [--max-paths ] [--no-source] [--json | --pretty] +Usage: codegraph explore "" [--root ] [--limit ] [--max-packets ] [--max-paths ] [--no-source] [--include-graph-context] [--allow-sensitive] [--json | --pretty] Output: - Explore orchestrates search, packet retrieval, dependency paths, reverse dependencies, candidate tests, and follow-up commands. - JSON is the default. Use --pretty for concise model-readable sections. + Explore orchestrates search, packet retrieval, dependency paths, reverse dependencies, candidate tests, and follow-up commands. An exact file-path query also includes the live bounded file view. + JSON is the default. Use --pretty for concise model-readable sections. Graph context and raw sensitive values require explicit flags. Index options: Supports shared --cache, --cache-strict, --cache-verify, --threads, --native, --workers, --include-glob, --ignore-glob, and --no-gitignore options. `; +export const FILE_HELP_TEXT = `codegraph file - Read a live project file with bounded line pagination + +Usage: codegraph file [--root ] [--offset ] [--limit ] [--max-bytes ] [--include-graph-context] [--allow-sensitive] [--json | --pretty] + +Output: + JSON is the default. Pretty output uses exact numberline source lines plus bounded graph context when requested. + A file-ending newline is represented as a final numbered empty line. Follow page.nextOffset or the pretty next-page command for more lines. + +Safety: + Paths are confined to --root after realpath resolution. Binary files are rejected. Known sensitive text configs return structural key summaries; key material returns metadata only. Use --allow-sensitive for raw access subject to binary and UTF-8 guards. +`; + export const SEARCH_HELP_TEXT = `codegraph search - Ranked agent search across project context Usage: codegraph search "" [--root ] [--mode hybrid|symbol|path|text|graph|sql] [--limit ] [--from ] [--depth ] [--no-snippets] [--json] @@ -412,6 +427,7 @@ Options: export function helpTextForCommand(command: string, positionals: readonly string[]): string | undefined { if (command === "explore") return EXPLORE_HELP_TEXT; + if (command === "file") return FILE_HELP_TEXT; if (command === "search") return SEARCH_HELP_TEXT; if (command === "orient") return ORIENT_HELP_TEXT; if (command === "packet") return PACKET_HELP_TEXT; diff --git a/src/cli/options.ts b/src/cli/options.ts index be83b885..a0c1f7ee 100644 --- a/src/cli/options.ts +++ b/src/cli/options.ts @@ -83,6 +83,8 @@ const CLI_VALUE_OPTIONS = new Set([ "--artifact", "--host", "--port", + "--offset", + "--max-bytes", ]); type CliPositionalPolicy = @@ -295,11 +297,24 @@ const CLI_COMMAND_SCHEMAS = new Map([ [ "explore", commandSchema( - [...SHARED_BUILD_FLAGS, ...JSON_OUTPUT_FLAGS, "--no-source"], + [...SHARED_BUILD_FLAGS, ...JSON_OUTPUT_FLAGS, "--no-source", "--include-graph-context", "--allow-sensitive"], [...SHARED_BUILD_OPTIONS, "--limit", "--max-packets", "--max-paths"], { kind: "any" }, ), ], + [ + "file", + commandSchema( + [...SHARED_BUILD_FLAGS, ...JSON_OUTPUT_FLAGS, "--include-graph-context", "--allow-sensitive"], + [...SHARED_BUILD_OPTIONS, "--offset", "--limit", "--max-bytes"], + { + kind: "max", + max: 1, + usage: + "Usage: codegraph file [--root ] [--offset ] [--limit ] [--max-bytes ] [--include-graph-context] [--allow-sensitive] [--json | --pretty]", + }, + ), + ], [ "goto", commandSchema([...SHARED_BUILD_FLAGS, ...JSON_OUTPUT_FLAGS], SHARED_BUILD_OPTIONS, { diff --git a/src/index.ts b/src/index.ts index f6f79c87..04b9095f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -224,6 +224,25 @@ export { type SymbolManifestEntry, } from "./util/symbolHash.js"; +/** Live, bounded, root-confined file reads with optional graph context. */ +export { + DEFAULT_FILE_VIEW_BYTES, + DEFAULT_FILE_VIEW_LINES, + FILE_VIEW_GRAPH_CONTEXT_LIMIT, + formatAgentFileViewResponse, + getCodegraphFileView, + getCodegraphFileViewWithSession, + MAX_FILE_VIEW_BYTES, + MAX_FILE_VIEW_LINES, +} from "./agent/fileView.js"; +export type { + AgentFileGraphContext, + AgentFileViewRequest, + AgentFileViewResponse, + AgentFileViewSensitiveInfo, + AgentFileViewSensitiveKind, +} from "./agent/fileView.js"; + /** Agent project snapshots and cached service-layer helpers. */ export { createAgentSession } from "./agent/session.js"; export type { diff --git a/src/mcp/security.ts b/src/mcp/security.ts index 9470765c..4501cbc2 100644 --- a/src/mcp/security.ts +++ b/src/mcp/security.ts @@ -1,7 +1,8 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { isFilePathWithinRoot, normalizePath, toProjectRelativePath } from "../util/paths.js"; +import { findNearestExistingPath } from "../util/confinedFile.js"; +import { isFilePathWithinRoot, normalizePath } from "../util/paths.js"; export function resolveArtifactSqlitePathCandidate(root: string, artifactPath: string): string { const resolved = path.isAbsolute(artifactPath) ? artifactPath : path.resolve(root, artifactPath); @@ -11,70 +12,6 @@ export function resolveArtifactSqlitePathCandidate(root: string, artifactPath: s : path.join(resolved, "codegraph.sqlite"); return normalizePath(sqlitePath); } - -export async function resolveReadableFile( - realRoot: string, - root: string, - filePath: string, -): Promise<{ realPath: string; displayPath: string }> { - const candidatePath = path.isAbsolute(filePath) ? path.resolve(filePath) : path.resolve(root, filePath); - const realPath = await assertRealPathCandidateWithinRoot(realRoot, candidatePath, "File"); - const displayPath = - toProjectRelativePath(root, candidatePath) ?? toProjectRelativePath(realRoot, realPath) ?? normalizePath(realPath); - return { realPath, displayPath }; -} - -export async function resolveProjectFile(realRoot: string, root: string, filePath: string): Promise { - const candidatePath = path.isAbsolute(filePath) ? path.resolve(filePath) : path.resolve(root, filePath); - const realPath = await assertRealPathCandidateWithinRoot(realRoot, candidatePath, "File"); - const lexicalRelativePath = toProjectRelativePath(root, candidatePath); - if (lexicalRelativePath) return normalizePath(candidatePath); - const realRelativePath = toProjectRelativePath(realRoot, realPath); - if (realRelativePath) return normalizePath(path.resolve(root, realRelativePath)); - throw new Error(`File is outside project root: ${normalizePath(realPath)} (root: ${normalizePath(realRoot)})`); -} - -export async function readFilePrefix( - filePath: string, - maxBytes: number, -): Promise<{ text: string; truncated: boolean }> { - const handle = await fs.open(filePath, "r"); - try { - const readLimit = maxBytes + 1; - const buffer = Buffer.alloc(readLimit); - const { bytesRead } = await handle.read(buffer, 0, readLimit, 0); - const outputBytes = Math.min(bytesRead, maxBytes); - const outputBuffer = trimToUtf8Boundary(buffer.subarray(0, outputBytes)); - return { - text: outputBuffer.toString("utf8"), - truncated: bytesRead > maxBytes, - }; - } finally { - await handle.close(); - } -} - -export async function assertRealPathCandidateWithinRoot( - realRoot: string, - filePath: string, - label: string, -): Promise { - const existingPath = await nearestExistingPath(filePath); - const realExistingPath = await fs.realpath(existingPath); - const relativeSuffix = path.relative(existingPath, filePath); - const realTargetPath = path.resolve(realExistingPath, relativeSuffix); - if (!isFilePathWithinRoot(realRoot, realTargetPath)) { - throw new Error( - `${label} is outside project root: ${normalizePath(realTargetPath)} (root: ${normalizePath(realRoot)})`, - ); - } - const finalRealPath = normalizePath(await fs.realpath(filePath)); - if (!isFilePathWithinRoot(realRoot, finalRealPath)) { - throw new Error(`${label} is outside project root: ${finalRealPath} (root: ${normalizePath(realRoot)})`); - } - return finalRealPath; -} - export async function assertWritableDirectoryRealPathWithinRoot( realRoot: string, root: string, @@ -82,7 +19,7 @@ export async function assertWritableDirectoryRealPathWithinRoot( label: string, ): Promise { const lexicalPath = path.isAbsolute(requestedPath) ? requestedPath : path.resolve(root, requestedPath); - const existingPath = await nearestExistingPath(lexicalPath); + const existingPath = await findNearestExistingPath(lexicalPath); const realExistingPath = await fs.realpath(existingPath); const relativeSuffix = path.relative(existingPath, lexicalPath); const realTargetPath = path.resolve(realExistingPath, relativeSuffix); @@ -93,47 +30,3 @@ export async function assertWritableDirectoryRealPathWithinRoot( } return normalizePath(realTargetPath); } - -function trimToUtf8Boundary(buffer: Buffer): Buffer { - if (!buffer.length) return buffer; - let leadIndex = buffer.length - 1; - while (leadIndex >= 0) { - const byte = buffer[leadIndex]; - if (byte === undefined || (byte & 0xc0) !== 0x80) break; - leadIndex -= 1; - } - if (leadIndex < 0) return buffer.subarray(0, 0); - const leadByte = buffer[leadIndex]; - if (leadByte === undefined) return buffer.subarray(0, 0); - const continuationBytes = buffer.length - leadIndex - 1; - const expectedContinuationBytes = expectedUtf8ContinuationBytes(leadByte); - if (expectedContinuationBytes === null) return buffer.subarray(0, leadIndex); - if (continuationBytes < expectedContinuationBytes) return buffer.subarray(0, leadIndex); - return buffer; -} - -function expectedUtf8ContinuationBytes(byte: number): number | null { - if ((byte & 0x80) === 0) return 0; - if ((byte & 0xe0) === 0xc0) return 1; - if ((byte & 0xf0) === 0xe0) return 2; - if ((byte & 0xf8) === 0xf0) return 3; - return null; -} - -async function nearestExistingPath(filePath: string): Promise { - let current = filePath; - while (current !== path.dirname(current)) { - try { - await fs.stat(current); - return current; - } catch (error) { - if (!isMissingPathError(error)) throw error; - current = path.dirname(current); - } - } - return current; -} - -function isMissingPathError(error: unknown): boolean { - return error instanceof Error && "code" in error && error.code === "ENOENT"; -} diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 0a7c87ed..04047e5f 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -17,6 +17,12 @@ import { buildCodegraphArtifactWithSession } from "../agent/artifact.js"; import type { CodegraphArtifactBuildResult } from "../agent/artifact.js"; import { explainCodegraphTargetWithSession } from "../agent/explain.js"; import type { AgentExplanation, AgentExplanationReference } from "../agent/explain.js"; +import { + getCodegraphFileViewWithSession, + MAX_FILE_VIEW_BYTES, + MAX_FILE_VIEW_LINES, + type AgentFileViewResponse, +} from "../agent/fileView.js"; import { exploreCodegraphWithSession, type AgentExploreResponse } from "../agent/explore.js"; import { orientCodegraphWithSession, type AgentOrientBudget, type AgentOrientResponse } from "../agent/orient.js"; import { getCodegraphPacketWithSession, type AgentPacketResponse } from "../agent/packet.js"; @@ -30,6 +36,7 @@ import { isPlainRecord } from "../util/guards.js"; import { toProjectDisplayPath } from "../util/paths.js"; import { createAgentSession, listAgentSessionFiles } from "../agent/session.js"; import { mapLimit } from "../util/concurrency.js"; +import { assertRealPathCandidateWithinRoot, resolveProjectFile } from "../util/confinedFile.js"; import type { AgentFreshnessResult, AgentProjectSnapshot, AgentSession } from "../agent/session.js"; import type { BuildOptions, GoToResult } from "../indexer/types.js"; import { @@ -38,16 +45,7 @@ import { DEFAULT_SQLITE_BYTE_LIMIT, normalizeSqliteRowLimit, } from "./sqliteGuard.js"; -import { - DEFAULT_FILE_BYTES, - DEFAULT_FILE_LINES, - DEFAULT_MCP_COLLECTION_LIMIT, - listCodegraphMcpTools, - MAX_FILE_BYTES, - MAX_FILE_LINES, - MAX_MCP_COLLECTION_LIMIT, - MCP_TOOLS, -} from "./tools.js"; +import { DEFAULT_MCP_COLLECTION_LIMIT, listCodegraphMcpTools, MAX_MCP_COLLECTION_LIMIT, MCP_TOOLS } from "./tools.js"; import { buildAllowedHostHeaders, closeHttpServer, @@ -66,111 +64,9 @@ import { } from "./http.js"; export { listCodegraphMcpTools } from "./tools.js"; -import { - assertRealPathCandidateWithinRoot, - assertWritableDirectoryRealPathWithinRoot, - readFilePrefix, - resolveArtifactSqlitePathCandidate, - resolveProjectFile, - resolveReadableFile, -} from "./security.js"; +import { assertWritableDirectoryRealPathWithinRoot, resolveArtifactSqlitePathCandidate } from "./security.js"; export type CodegraphMcpWarmupMode = "off" | "base" | "symbols"; -type CodegraphMcpFileGraphContext = { - usedBy: string[]; - imports: string[]; - symbols: Array<{ name: string; kind: string; line: number }>; -}; - -const FILE_GRAPH_CONTEXT_LIMIT = DEFAULT_MCP_COLLECTION_LIMIT; - -type CodegraphMcpFileViewResponse = { - schemaVersion: 1; - file: string; - offset: number; - limit: number; - totalLines: number; - content: string; - lineFormat: "number-tab-line"; - text: string; - truncated: boolean; - graphContext?: CodegraphMcpFileGraphContext; - page?: { nextOffset?: number }; -}; - -function splitFileLines(text: string): string[] { - return text.split("\n"); -} - -function formatNumberedLines(lines: readonly string[], offset: number, limit: number): string { - const start = Math.max(0, offset - 1); - return lines - .slice(start, start + limit) - .map((line, index) => `${start + index + 1}\t${line}`) - .join("\n"); -} - -function uniqueSorted(values: Iterable): string[] { - return Array.from(new Set(values)).sort((left, right) => left.localeCompare(right)); -} - -function buildFileGraphContext( - snapshot: AgentProjectSnapshot, - file: string, - relative: (file: string) => string, -): CodegraphMcpFileGraphContext | undefined { - const moduleIndex = snapshot.index.byFile.get(file); - if (!moduleIndex) return undefined; - - const usedBy = uniqueSorted( - snapshot.fileGraph.edges - .filter((edge) => edge.to.type === "file" && edge.to.path === file) - .map((edge) => relative(edge.from)), - ).slice(0, FILE_GRAPH_CONTEXT_LIMIT); - const imports = uniqueSorted( - moduleIndex.imports.map((importBinding) => { - const resolved = importBinding.resolved; - if (typeof resolved === "string") return relative(resolved); - if (resolved) return resolved.external; - return importBinding.from; - }), - ).slice(0, FILE_GRAPH_CONTEXT_LIMIT); - const symbols = moduleIndex.locals - .map((symbol) => ({ name: symbol.localName, kind: symbol.kind, line: symbol.range.start.line })) - .sort((left, right) => left.line - right.line || left.name.localeCompare(right.name)) - .slice(0, FILE_GRAPH_CONTEXT_LIMIT); - - return { usedBy, imports, symbols }; -} - -function buildFileView(args: { - file: string; - text: string; - truncated: boolean; - offset: number; - limit: number; - graphContext?: CodegraphMcpFileGraphContext | undefined; -}): CodegraphMcpFileViewResponse { - const lines = splitFileLines(args.text); - const start = Math.max(0, args.offset - 1); - let nextOffset: number | undefined; - if (start + args.limit < lines.length) { - nextOffset = start + args.limit + 1; - } - return { - schemaVersion: 1, - file: args.file, - offset: args.offset, - limit: args.limit, - totalLines: lines.length, - content: formatNumberedLines(lines, args.offset, args.limit), - lineFormat: "number-tab-line", - text: args.text, - truncated: args.truncated, - ...(args.graphContext ? { graphContext: args.graphContext } : {}), - ...(nextOffset ? { page: { nextOffset } } : {}), - }; -} export type CodegraphMcpHandlerOptions = { root: string; @@ -231,7 +127,8 @@ export type CodegraphMcpHandlers = { limit?: number | undefined; maxBytes?: number | undefined; includeGraphContext?: boolean | undefined; - }) => Promise>; + allowSensitive?: boolean | undefined; + }) => Promise>; get_symbol: (request: { handle: string }) => Promise>; goto: (request: { file: string; line: number; column: number }) => Promise>; refs: ( @@ -368,10 +265,6 @@ function createCodegraphMcpHandlersForSession( if (typeof limit !== "number" || !Number.isFinite(limit)) return fallback; return Math.min(max, Math.max(0, Math.floor(limit))); }; - const boundedPositiveLimit = (limit: number | undefined, fallback: number, max: number): number => { - if (typeof limit !== "number" || !Number.isFinite(limit)) return fallback; - return Math.min(max, Math.max(1, Math.floor(limit))); - }; const staleFreshness = (files: readonly string[], reason: string): AgentFreshnessResult => { const changedFiles = [...files].sort(); const boundedChangedFiles = changedFiles.slice(0, MAX_MCP_FRESHNESS_CHANGED_FILES); @@ -614,33 +507,17 @@ function createCodegraphMcpHandlersForSession( }), ), - get_file: async (request) => { - // A plain get_file request returns live bytes and must not trigger workspace-wide - // freshness or indexing work. Graph context is explicitly opt-in. - const rootRealPath = await realRoot; - const resolvedFile = await resolveReadableFile(rootRealPath, root, request.file); - const maxBytes = boundedPositiveLimit(request.maxBytes, DEFAULT_FILE_BYTES, MAX_FILE_BYTES); - const offset = boundedPositiveLimit(request.offset, 1, Number.MAX_SAFE_INTEGER); - const lineLimit = boundedPositiveLimit(request.limit, DEFAULT_FILE_LINES, MAX_FILE_LINES); - const read = await readFilePrefix(resolvedFile.realPath, maxBytes); - const buildView = (graphContext?: CodegraphMcpFileGraphContext): CodegraphMcpFileViewResponse => - buildFileView({ - file: resolvedFile.displayPath, - text: read.text, - truncated: read.truncated, - offset, - limit: lineLimit, - ...(graphContext ? { graphContext } : {}), - }); - if (!request.includeGraphContext) { - return { ...buildView(), freshness: { state: "fresh" } as const }; - } - return await withFreshness(async () => { - const projectFile = await resolveProjectFile(rootRealPath, root, request.file); - const snapshot = await session.loadProject({ symbolGraph: "skip" }); - return buildView(buildFileGraphContext(snapshot, projectFile, relative)); - }); - }, + get_file: async (request) => + await getCodegraphFileViewWithSession(session, { + root, + file: request.file, + ...(request.offset !== undefined ? { offset: request.offset } : {}), + ...(request.limit !== undefined ? { limit: request.limit } : {}), + ...(request.maxBytes !== undefined ? { maxBytes: request.maxBytes } : {}), + ...(request.includeGraphContext !== undefined ? { includeGraphContext: request.includeGraphContext } : {}), + ...(request.allowSensitive !== undefined ? { allowSensitive: request.allowSensitive } : {}), + ...(options.buildOptions ? { buildOptions: options.buildOptions } : {}), + }), get_symbol: async (request) => await withFreshness(async () => { @@ -1141,9 +1018,10 @@ const packetGetSchema = z.object({ const getFileSchema = z.object({ file: z.string(), offset: z.number().int().positive().optional(), - limit: z.number().int().positive().max(MAX_FILE_LINES).optional(), - maxBytes: z.number().int().positive().optional(), + limit: z.number().int().positive().max(MAX_FILE_VIEW_LINES).optional(), + maxBytes: z.number().int().positive().max(MAX_FILE_VIEW_BYTES).optional(), includeGraphContext: z.boolean().optional(), + allowSensitive: z.boolean().optional(), }); const handleSchema = z.object({ diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 86b309d0..c2fdf694 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -1,11 +1,14 @@ import type { Tool } from "@modelcontextprotocol/sdk/types.js"; +import { + DEFAULT_FILE_VIEW_BYTES, + DEFAULT_FILE_VIEW_LINES, + MAX_FILE_VIEW_BYTES, + MAX_FILE_VIEW_LINES, +} from "../agent/fileView.js"; + import { DEFAULT_SQLITE_ROW_LIMIT, MAX_SQLITE_ROW_LIMIT } from "./sqliteGuard.js"; -export const DEFAULT_FILE_BYTES = 80_000; -export const MAX_FILE_BYTES = 500_000; -export const DEFAULT_FILE_LINES = 2000; -export const MAX_FILE_LINES = 10_000; export const DEFAULT_MCP_COLLECTION_LIMIT = 100; export const MAX_MCP_COLLECTION_LIMIT = 500; @@ -91,9 +94,10 @@ export const MCP_TOOLS: Tool[] = [ { file: stringProperty, offset: { type: "integer", minimum: 1, default: 1 }, - limit: { type: "integer", minimum: 1, maximum: MAX_FILE_LINES, default: DEFAULT_FILE_LINES }, - maxBytes: { type: "integer", minimum: 1, maximum: MAX_FILE_BYTES }, + limit: { type: "integer", minimum: 1, maximum: MAX_FILE_VIEW_LINES, default: DEFAULT_FILE_VIEW_LINES }, + maxBytes: { type: "integer", minimum: 1, maximum: MAX_FILE_VIEW_BYTES, default: DEFAULT_FILE_VIEW_BYTES }, includeGraphContext: { type: "boolean", default: false }, + allowSensitive: { type: "boolean", default: false }, }, ["file"], ), diff --git a/src/util/confinedFile.ts b/src/util/confinedFile.ts new file mode 100644 index 00000000..2a89fdf6 --- /dev/null +++ b/src/util/confinedFile.ts @@ -0,0 +1,65 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +import { isFilePathWithinRoot, normalizePath, toProjectRelativePath } from "./paths.js"; + +export async function resolveReadableFile( + realRoot: string, + root: string, + filePath: string, +): Promise<{ realPath: string; displayPath: string }> { + const candidatePath = path.isAbsolute(filePath) ? path.resolve(filePath) : path.resolve(root, filePath); + const realPath = await assertRealPathCandidateWithinRoot(realRoot, candidatePath, "File"); + const displayPath = + toProjectRelativePath(root, candidatePath) ?? toProjectRelativePath(realRoot, realPath) ?? normalizePath(realPath); + return { realPath, displayPath }; +} + +export async function resolveProjectFile(realRoot: string, root: string, filePath: string): Promise { + const candidatePath = path.isAbsolute(filePath) ? path.resolve(filePath) : path.resolve(root, filePath); + const realPath = await assertRealPathCandidateWithinRoot(realRoot, candidatePath, "File"); + const lexicalRelativePath = toProjectRelativePath(root, candidatePath); + if (lexicalRelativePath) return normalizePath(candidatePath); + const realRelativePath = toProjectRelativePath(realRoot, realPath); + if (realRelativePath) return normalizePath(path.resolve(root, realRelativePath)); + throw new Error(`File is outside project root: ${normalizePath(realPath)} (root: ${normalizePath(realRoot)})`); +} + +export async function assertRealPathCandidateWithinRoot( + realRoot: string, + filePath: string, + label: string, +): Promise { + const existingPath = await findNearestExistingPath(filePath); + const realExistingPath = await fs.realpath(existingPath); + const relativeSuffix = path.relative(existingPath, filePath); + const realTargetPath = path.resolve(realExistingPath, relativeSuffix); + if (!isFilePathWithinRoot(realRoot, realTargetPath)) { + throw new Error( + `${label} is outside project root: ${normalizePath(realTargetPath)} (root: ${normalizePath(realRoot)})`, + ); + } + const finalRealPath = normalizePath(await fs.realpath(filePath)); + if (!isFilePathWithinRoot(realRoot, finalRealPath)) { + throw new Error(`${label} is outside project root: ${finalRealPath} (root: ${normalizePath(realRoot)})`); + } + return finalRealPath; +} + +export async function findNearestExistingPath(filePath: string): Promise { + let current = filePath; + while (current !== path.dirname(current)) { + try { + await fs.stat(current); + return current; + } catch (error) { + if (!isMissingPathError(error)) throw error; + current = path.dirname(current); + } + } + return current; +} + +function isMissingPathError(error: unknown): boolean { + return error instanceof Error && "code" in error && error.code === "ENOENT"; +} diff --git a/tests/agent-explore.test.ts b/tests/agent-explore.test.ts index f5e48eeb..27edafe4 100644 --- a/tests/agent-explore.test.ts +++ b/tests/agent-explore.test.ts @@ -61,6 +61,18 @@ async function mkExploreRepo(): Promise { return root; } +const spacedExplorePath = "src/live reports/audit-report.ts"; +const spacedExploreText = [ + "export function renderAuditReport(total: number) {", + " return `audited:${total}`;", + "}", + "", +].join("\n"); + +async function writeSpacedExploreFixture(root: string): Promise { + await writeFile(root, spacedExplorePath, spacedExploreText); +} + function readRecord(value: unknown, label: string): JsonRecord { expect(value, label).toBeTypeOf("object"); expect(value, label).not.toBeNull(); @@ -85,6 +97,21 @@ function textOf(value: unknown): string { return JSON.stringify(value); } +function readComparableLiveFileFields(value: unknown, label: string): JsonRecord { + const view = readRecord(value, label); + return { + file: view.file, + totalLines: view.totalLines, + content: view.content, + lineFormat: view.lineFormat, + text: view.text, + truncated: view.truncated, + ...(view.graphContext !== undefined ? { graphContext: view.graphContext } : {}), + ...(view.sensitive !== undefined ? { sensitive: view.sensitive } : {}), + ...(view.page !== undefined ? { page: view.page } : {}), + }; +} + function expectExploreEnvelope(response: unknown, query: string): JsonRecord { const record = readRecord(response, "explore response"); expect(record.schemaVersion).toBe(1); @@ -118,10 +145,72 @@ describe("agent explore", () => { const response = expectExploreEnvelope(await exploreCodegraph({ root, query }), query); const packets = readArray(response.packets, "packets"); const blastRadius = readArray(response.blastRadius, "blastRadius"); + const fileView = readRecord(response.fileView, "fileView"); expect(packets.some((packet) => textOf(packet).includes("src/auth.ts"))).toBeTruthy(); expect(blastRadius.some((entry) => textOf(entry).includes("src/routes.ts"))).toBeTruthy(); expect(readArray(response.followUps, "followUps").length).toBeGreaterThan(0); + expect(fileView).toMatchObject({ + file: "src/auth.ts", + totalLines: 7, + content: [ + "1\timport { readUser } from './db';", + "2\t", + "3\texport function validateUser(userId: string) {", + "4\t const user = readUser(userId);", + "5\t return user.active;", + "6\t}", + "7\t", + ].join("\n"), + lineFormat: "number-tab-line", + }); + expect(fileView.graphContext).toBeUndefined(); + }); + + it("attaches the live file view for exact indexed project paths containing spaces in library and CLI explore", async () => { + const root = await mkExploreRepo(); + await writeSpacedExploreFixture(root); + + const libraryResponse = expectExploreEnvelope( + await exploreCodegraph({ root, query: spacedExplorePath }), + spacedExplorePath, + ); + const libraryView = readRecord(libraryResponse.fileView, "library explore fileView"); + expect(libraryView).toMatchObject({ + file: spacedExplorePath, + totalLines: 4, + text: spacedExploreText, + content: [ + "1\texport function renderAuditReport(total: number) {", + "2\t return `audited:${total}`;", + "3\t}", + "4\t", + ].join("\n"), + lineFormat: "number-tab-line", + }); + + const exploreResult = await captureCli(["explore", spacedExplorePath, "--root", root, "--json"]); + const fileResult = await captureCli(["file", spacedExplorePath, "--root", root, "--json"]); + + expect(exploreResult).toMatchObject({ stderr: "", exitCode: undefined }); + expect(fileResult).toMatchObject({ stderr: "", exitCode: undefined }); + const cliExploreResponse = readRecord(JSON.parse(exploreResult.stdout) as unknown, "CLI explore response"); + const cliExploreView = readRecord(cliExploreResponse.fileView, "CLI explore fileView"); + const cliFileView = readRecord(JSON.parse(fileResult.stdout) as unknown, "CLI file response"); + const expectedLiveFields = readComparableLiveFileFields(libraryView, "library explore fileView"); + expect(readComparableLiveFileFields(cliExploreView, "CLI explore fileView")).toEqual(expectedLiveFields); + expect(readComparableLiveFileFields(cliFileView, "CLI file response")).toEqual(expectedLiveFields); + }); + + it("does not attach a file view to a natural-language whitespace query containing a unique basename", async () => { + const root = await mkExploreRepo(); + await writeSpacedExploreFixture(root); + const query = "please inspect audit-report.ts for recent failures"; + + const response = expectExploreEnvelope(await exploreCodegraph({ root, query }), query); + + expect(textOf(response.blastRadius)).toContain(spacedExplorePath); + expect(response).not.toHaveProperty("fileView"); }); it("orders anchor-file derived outputs by project path for multi-file mentions", async () => { @@ -136,10 +225,11 @@ describe("agent explore", () => { expect(blastRadiusFiles.slice(0, 3)).toEqual(["src/auth.ts", "src/db.ts", "src/routes.ts"]); expect(followUps.slice(0, 3)).toEqual([ - "codegraph packet get src/auth.ts --pretty", - "codegraph packet get src/db.ts --pretty", - "codegraph packet get src/routes.ts --pretty", + "codegraph file src/auth.ts --pretty", + "codegraph file src/db.ts --pretty", + "codegraph file src/routes.ts --pretty", ]); + expect(response.fileView).toBeUndefined(); }); it("matches basename-only file mentions case-insensitively", async () => { @@ -225,11 +315,12 @@ describe("agent explore", () => { expect(anchors.some((anchor) => textOf(anchor).includes("validateUser"))).toBeTruthy(); expect(anchors.some((anchor) => textOf(anchor).includes("src/auth.ts"))).toBeTruthy(); expect(packets.some((packet) => textOf(packet).includes("validateUser"))).toBeTruthy(); + expect(response.fileView).toBeUndefined(); }); it("disables packet limits and packet omissions when source packets are excluded", async () => { const root = await mkExploreRepo(); - const query = "validateUser"; + const query = "src/auth.ts"; const response = expectExploreEnvelope(await exploreCodegraph({ root, query, includeSource: false }), query); const limits = readRecord(response.limits, "limits"); @@ -238,6 +329,7 @@ describe("agent explore", () => { expect(readArray(response.packets, "packets")).toHaveLength(0); expect(limits.packets).toBe(0); expect(omittedCounts.packets).toBe(0); + expect(response.fileView).toBeUndefined(); }); it("pretty output distinguishes empty relevant source reasons", async () => { @@ -498,6 +590,136 @@ describe("agent explore", () => { expectExploreEnvelope(JSON.parse(result.stdout) as unknown, query); }); + it("keeps exact-file CLI explore content in parity with CLI file, including graph context", async () => { + const root = await mkExploreRepo(); + const query = "src/auth.ts"; + + const exploreResult = await captureCli(["explore", query, "--root", root, "--json"]); + const fileResult = await captureCli(["file", query, "--root", root, "--json"]); + + expect(exploreResult).toMatchObject({ stderr: "", exitCode: undefined }); + expect(fileResult).toMatchObject({ stderr: "", exitCode: undefined }); + const exploreResponse = readRecord(JSON.parse(exploreResult.stdout) as unknown, "explore response"); + const exploreView = readRecord(exploreResponse.fileView, "explore fileView"); + const fileView = readRecord(JSON.parse(fileResult.stdout) as unknown, "file response"); + expect(readComparableLiveFileFields(exploreView, "explore fileView")).toEqual( + readComparableLiveFileFields(fileView, "file response"), + ); + expect(exploreView).toMatchObject({ + file: query, + totalLines: 7, + text: [ + "import { readUser } from './db';", + "", + "export function validateUser(userId: string) {", + " const user = readUser(userId);", + " return user.active;", + "}", + "", + ].join("\n"), + }); + + const contextualExploreResult = await captureCli([ + "explore", + query, + "--root", + root, + "--include-graph-context", + "--json", + ]); + const contextualFileResult = await captureCli(["file", query, "--root", root, "--include-graph-context", "--json"]); + + expect(contextualExploreResult).toMatchObject({ stderr: "", exitCode: undefined }); + expect(contextualFileResult).toMatchObject({ stderr: "", exitCode: undefined }); + const contextualExploreResponse = readRecord( + JSON.parse(contextualExploreResult.stdout) as unknown, + "contextual explore response", + ); + const contextualExploreView = readRecord(contextualExploreResponse.fileView, "contextual explore fileView"); + const contextualFileView = readRecord( + JSON.parse(contextualFileResult.stdout) as unknown, + "contextual file response", + ); + expect(readComparableLiveFileFields(contextualExploreView, "contextual explore fileView")).toEqual( + readComparableLiveFileFields(contextualFileView, "contextual file response"), + ); + const graphContext = readRecord(contextualExploreView.graphContext, "explore graphContext"); + expect(graphContext.usedBy).toEqual(["src/routes.ts"]); + expect(graphContext.imports).toEqual(["src/db.ts"]); + expect(readArray(graphContext.symbols, "explore graphContext symbols")).toContainEqual({ + name: "validateUser", + kind: "function", + line: 3, + }); + }); + + it("passes explicit sensitive access from exact-file CLI explore to its live file view", async () => { + const root = await mkExploreRepo(); + const query = "src/credentials.json"; + const sensitiveText = ["{", ' "apiToken": "explore-dispatcher-secret",', ' "username": "alice"', "}", ""].join( + "\n", + ); + await writeFile(root, query, sensitiveText); + await writeFile( + root, + "src/credentials-reader.ts", + [ + "import credentials from './credentials.json';", + "", + "export const deploymentUser = credentials.username;", + "", + ].join("\n"), + ); + + const exploreResult = await captureCli([ + "explore", + query, + "--root", + root, + "--allow-sensitive", + "--max-packets", + "0", + "--limit", + "0", + "--json", + ]); + const fileResult = await captureCli(["file", query, "--root", root, "--allow-sensitive", "--json"]); + + expect(exploreResult).toMatchObject({ stderr: "", exitCode: undefined }); + expect(fileResult).toMatchObject({ stderr: "", exitCode: undefined }); + const exploreResponse = readRecord(JSON.parse(exploreResult.stdout) as unknown, "sensitive explore response"); + const exploreView = readRecord(exploreResponse.fileView, "sensitive explore fileView"); + const fileView = readRecord(JSON.parse(fileResult.stdout) as unknown, "sensitive file response"); + expect(readComparableLiveFileFields(exploreView, "sensitive explore fileView")).toEqual( + readComparableLiveFileFields(fileView, "sensitive file response"), + ); + expect(exploreView).toMatchObject({ + file: query, + text: sensitiveText, + content: [ + "1\t{", + '2\t "apiToken": "explore-dispatcher-secret",', + '3\t "username": "alice"', + "4\t}", + "5\t", + ].join("\n"), + sensitive: { kind: "credential-config", redacted: false, allowSensitiveRequired: true }, + }); + }); + + it("suppresses the exact-file CLI explore file view when source is disabled", async () => { + const root = await mkExploreRepo(); + const query = "src/auth.ts"; + + const result = await captureCli(["explore", query, "--root", root, "--no-source", "--json"]); + + expect(result).toMatchObject({ stderr: "", exitCode: undefined }); + const response = readRecord(JSON.parse(result.stdout) as unknown, "no-source explore response"); + expect(response).not.toHaveProperty("fileView"); + expect(response.packets).toEqual([]); + expect(result.stdout).not.toContain("const user = readUser(userId)"); + }); + it("advertises a flat MCP explore schema and invokes the facade", async () => { const root = await mkExploreRepo(); const exploreTool = listCodegraphMcpTools().find((tool) => tool.name === "explore"); diff --git a/tests/cli-command-modules.test.ts b/tests/cli-command-modules.test.ts index 8e8cacd4..f579cbfe 100644 --- a/tests/cli-command-modules.test.ts +++ b/tests/cli-command-modules.test.ts @@ -3,13 +3,14 @@ import fsp from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, test, vi } from "vitest"; +import { MAX_FILE_VIEW_BYTES, MAX_FILE_VIEW_LINES } from "../src/agent/fileView.js"; import { handleChunkCommand, type ChunkCommandContext } from "../src/cli/chunk.js"; import type { CliAgentCommandContext } from "../src/cli/context.js"; import { buildDoctorReport } from "../src/cli/doctor.js"; import { handleGraphCommand, type GraphCommandContext } from "../src/cli/graph.js"; import { handleGraphDeltaCommand } from "../src/cli/graphDelta.js"; import { handleGraphQueryCommand, type GraphQueryCommandContext } from "../src/cli/graphQueries.js"; -import { CLI_HELP_TEXT, MCP_SERVE_HELP_TEXT, PACKET_HELP_TEXT } from "../src/cli/help.js"; +import { CLI_HELP_TEXT, FILE_HELP_TEXT, MCP_SERVE_HELP_TEXT, PACKET_HELP_TEXT } from "../src/cli/help.js"; import { handleImpactCommand, type ImpactCommandContext } from "../src/cli/impact.js"; import { handleGotoCommand, type NavigationCommandContext } from "../src/cli/navigation.js"; import { getCodegraphPackageIdentity, getCodegraphVersion } from "../src/cli/packageInfo.js"; @@ -435,6 +436,11 @@ describe("CLI command modules", () => { heading: "codegraph explain", usage: "Usage: codegraph explain ", }, + { + args: ["file", "--help"], + heading: "codegraph file", + usage: "Usage: codegraph file ", + }, { args: ["artifact", "--help"], heading: "codegraph artifact", usage: "Usage: codegraph artifact build" }, { args: ["drift", "--help"], heading: "codegraph drift", usage: "Usage: codegraph drift [roots...]" }, { args: ["mcp", "--help"], heading: "codegraph mcp", usage: "Usage: codegraph mcp serve" }, @@ -451,6 +457,17 @@ describe("CLI command modules", () => { } }); + test("file help documents live-view bounds and opt-in context", async () => { + const result = await captureCli(["file", "--help"]); + + expect(result.exitCode).toBeUndefined(); + expect(result.stderr).toBe(""); + expect(result.stdout).toBe(`${FILE_HELP_TEXT.trimEnd()}\n`); + for (const option of ["--offset", "--limit", "--max-bytes", "--include-graph-context", "--allow-sensitive"]) { + expect(result.stdout, option).toContain(option); + } + }); + test("documents drift-specific flags and head semantics in drift help", async () => { const result = await captureCli(["drift", "--help"]); @@ -543,6 +560,18 @@ describe("CLI command modules", () => { expect(result.stderr).toContain("Unknown option for search: -z"); }); + test("file positional validation prints the complete help usage", async () => { + const expectedUsage = + "Usage: codegraph file [--root ] [--offset ] [--limit ] [--max-bytes ] [--include-graph-context] [--allow-sensitive] [--json | --pretty]"; + const helpUsage = FILE_HELP_TEXT.split("\n").find((line) => line.startsWith("Usage: ")); + const result = await captureCli(["file", "src/first.ts", "src/second.ts"]); + + expect(helpUsage).toBe(expectedUsage); + expect(result.exitCode).toBe(2); + expect(result.stdout).toBe(""); + expect(result.stderr).toBe(`Unexpected positional argument for file: src/second.ts\n${expectedUsage}\n`); + }); + test("rejects unexpected positionals for commands without include roots", async () => { const cases = [ { @@ -1008,6 +1037,134 @@ describe("CLI command modules", () => { expect(stderrLines[0]).toContain("missing.ts"); }); + test("runs bounded JSON and pretty file views through the main CLI dispatcher", async () => { + const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), "codegraph-cli-file-view-")); + await fsp.writeFile(path.join(tempDir, "util.ts"), "export const first = 1;\nexport const second = 2;\n", "utf8"); + await fsp.writeFile( + path.join(tempDir, "main.ts"), + "import { second } from './util';\nexport const result = second;\n", + "utf8", + ); + + try { + const jsonResult = await captureCli([ + "file", + "util.ts", + "--root", + tempDir, + "--offset", + "2", + "--limit", + "1", + "--max-bytes", + "8", + ]); + const jsonView = readJsonRecord(JSON.parse(jsonResult.stdout)); + + expect(jsonResult).toMatchObject({ stderr: "", exitCode: undefined }); + expect(jsonView).toMatchObject({ + file: "util.ts", + offset: 2, + limit: 1, + totalLines: 3, + content: "2\texport c", + text: "export c", + truncated: true, + page: { nextOffset: 3 }, + }); + expect(jsonView.graphContext).toBeUndefined(); + + const prettyResult = await captureCli([ + "file", + "util.ts", + "--root", + tempDir, + "--offset", + "2", + "--limit", + "1", + "--max-bytes", + "100", + "--pretty", + ]); + expect(prettyResult).toEqual({ + stdout: [ + "File: util.ts", + "Lines 2-2 of 3", + "2\texport const second = 2;", + "Next page: codegraph file util.ts --offset 3 --limit 1 --pretty", + "", + ].join("\n"), + stderr: "", + exitCode: undefined, + }); + + const contextualResult = await captureCli([ + "file", + "util.ts", + "--root", + tempDir, + "--limit", + "1", + "--max-bytes", + "100", + "--include-graph-context", + "--json", + ]); + const contextualView = readJsonRecord(JSON.parse(contextualResult.stdout)); + const graphContext = readJsonRecord(contextualView.graphContext); + expect(graphContext.usedBy).toEqual(["main.ts"]); + } finally { + await fsp.rm(tempDir, { recursive: true, force: true }); + } + }); + + test.each([ + { option: "--limit", maximum: MAX_FILE_VIEW_LINES }, + { option: "--max-bytes", maximum: MAX_FILE_VIEW_BYTES }, + ])("rejects $option values above the file view bound", async ({ option, maximum }) => { + const excessiveValue = maximum + 1; + const result = await captureCli(["file", "util.ts", option, String(excessiveValue)]); + + expect(result).toEqual({ + stdout: "", + stderr: `Invalid ${option} value "${excessiveValue}". Expected an integer from 1 to ${maximum}.\n`, + exitCode: 1, + }); + }); + + test("redacts environment files through the CLI dispatcher unless sensitive access is explicit", async () => { + const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), "codegraph-cli-sensitive-file-view-")); + const sensitiveText = "API_TOKEN=dispatcher-secret\nUSER=alice\n"; + await fsp.writeFile(path.join(tempDir, ".env.local"), sensitiveText, "utf8"); + + try { + const redactedResult = await captureCli(["file", ".env.local", "--root", tempDir, "--json"]); + const redactedView = readJsonRecord(JSON.parse(redactedResult.stdout)); + + expect(redactedResult).toMatchObject({ stderr: "", exitCode: undefined }); + expect(redactedResult.stdout).not.toContain("dispatcher-secret"); + expect(redactedView).toMatchObject({ + file: ".env.local", + text: "Sensitive environment values omitted.\nKeys: API_TOKEN, USER", + content: "1\tSensitive environment values omitted.\n2\tKeys: API_TOKEN, USER", + sensitive: { kind: "environment", redacted: true, allowSensitiveRequired: true }, + }); + + const allowedResult = await captureCli(["file", ".env.local", "--root", tempDir, "--allow-sensitive", "--json"]); + const allowedView = readJsonRecord(JSON.parse(allowedResult.stdout)); + + expect(allowedResult).toMatchObject({ stderr: "", exitCode: undefined }); + expect(allowedView).toMatchObject({ + text: sensitiveText, + content: "1\tAPI_TOKEN=dispatcher-secret\n2\tUSER=alice\n3\t", + sensitive: { kind: "environment", redacted: false, allowSensitiveRequired: true }, + }); + } finally { + await fsp.rm(tempDir, { recursive: true, force: true }); + } + }); + test("runs graph exploration commands through the main CLI dispatcher", async () => { const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), "codegraph-cli-explore-")); await fsp.writeFile(path.join(tempDir, "util.ts"), "export function helper() { return 1; }\n", "utf8"); diff --git a/tests/file-view.test.ts b/tests/file-view.test.ts new file mode 100644 index 00000000..f452bc1f --- /dev/null +++ b/tests/file-view.test.ts @@ -0,0 +1,823 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import * as agentFacade from "../src/agent.js"; +import { type AgentSession } from "../src/agent/session.js"; +import * as rootFacade from "../src/index.js"; +import { isSymlinkUnavailable } from "./helpers/filesystem.js"; + +const { createAgentSession, formatAgentFileViewResponse, getCodegraphFileView, getCodegraphFileViewWithSession } = + rootFacade; + +const publicFileViewFacades = [ + { + name: "package root", + formatAgentFileViewResponse: rootFacade.formatAgentFileViewResponse, + getCodegraphFileView: rootFacade.getCodegraphFileView, + getCodegraphFileViewWithSession: rootFacade.getCodegraphFileViewWithSession, + }, + { + name: "agent", + formatAgentFileViewResponse: agentFacade.formatAgentFileViewResponse, + getCodegraphFileView: agentFacade.getCodegraphFileView, + getCodegraphFileViewWithSession: agentFacade.getCodegraphFileViewWithSession, + }, +] as const; + +const FILE_VIEW_INPUT_LIMIT_BYTES = 16 * 1024 * 1024; +const tempPaths = new Set(); + +async function makeTempDir(prefix: string): Promise { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + tempPaths.add(tempDir); + return tempDir; +} + +async function writeFile(root: string, relativePath: string, contents: string | Buffer): Promise { + const filePath = path.join(root, relativePath); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, contents); +} +async function writeSparseFile(root: string, relativePath: string, size: number): Promise { + const filePath = path.join(root, relativePath); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, ""); + await fs.truncate(filePath, size); + return filePath; +} + +afterEach(async () => { + await Promise.all(Array.from(tempPaths, async (tempPath) => await fs.rm(tempPath, { recursive: true, force: true }))); + tempPaths.clear(); +}); + +describe("agent file view", () => { + it.each(publicFileViewFacades)( + "executes file reads, session reads, and response formatting through the $name facade", + async ({ + formatAgentFileViewResponse: formatResponse, + getCodegraphFileView: readFileView, + getCodegraphFileViewWithSession: readFileViewWithSession, + }) => { + const root = await makeTempDir("cg-file-view-public-facade-"); + await writeFile(root, "facade.txt", "alpha\nbeta\n"); + const session: AgentSession = { + root, + checkFreshness: async () => { + throw new Error("plain public file reads must not check session freshness"); + }, + loadProject: async () => { + throw new Error("plain public file reads must not load a project index"); + }, + invalidate: () => undefined, + }; + + const direct = await readFileView({ root, file: "facade.txt", offset: 2, limit: 1, maxBytes: 100 }); + const withSession = await readFileViewWithSession(session, { + root, + file: "facade.txt", + offset: 1, + limit: 3, + maxBytes: 100, + }); + + expect(direct).toMatchObject({ + file: "facade.txt", + offset: 2, + totalLines: 3, + content: "2\tbeta", + text: "beta", + page: { nextOffset: 3 }, + }); + expect(withSession).toMatchObject({ + file: "facade.txt", + offset: 1, + totalLines: 3, + content: "1\talpha\n2\tbeta\n3\t", + text: "alpha\nbeta\n", + }); + expect(formatResponse(direct)).toBe( + [ + "File: facade.txt", + "Lines 2-2 of 3", + "2\tbeta", + "Next page: codegraph file facade.txt --offset 3 --limit 1 --pretty", + ].join("\n"), + ); + }, + ); + + it("numbers the requested line range while retaining the full-file line count and final empty line", async () => { + const root = await makeTempDir("cg-file-view-lines-"); + await writeFile(root, "notes.txt", "alpha\nbeta\ngamma\n"); + + const middle = await getCodegraphFileView({ root, file: "notes.txt", offset: 2, limit: 2, maxBytes: 100 }); + + expect(middle).toMatchObject({ + file: "notes.txt", + offset: 2, + limit: 2, + totalLines: 4, + content: "2\tbeta\n3\tgamma", + lineFormat: "number-tab-line", + text: "beta\ngamma", + truncated: false, + page: { nextOffset: 4 }, + }); + + const finalEmptyLine = await getCodegraphFileView({ + root, + file: "notes.txt", + offset: 4, + limit: 2, + maxBytes: 100, + }); + + expect(finalEmptyLine).toMatchObject({ + offset: 4, + totalLines: 4, + content: "4\t", + text: "", + truncated: false, + }); + expect(finalEmptyLine.page).toBeUndefined(); + }); + + it("returns an explicit empty page when the requested offset is beyond EOF", async () => { + const root = await makeTempDir("cg-file-view-beyond-eof-"); + await writeFile(root, "notes.txt", "alpha\nbeta\ngamma\n"); + + const beyondEnd = await getCodegraphFileView({ + root, + file: "notes.txt", + offset: 10, + limit: 2, + maxBytes: 100, + }); + + expect(beyondEnd.content).toBe(""); + expect(beyondEnd.text).toBe(""); + expect(beyondEnd.totalLines).toBe(4); + expect(beyondEnd.page).toBeUndefined(); + expect(formatAgentFileViewResponse(beyondEnd)).toBe("File: notes.txt\nLines: none at offset 10 of 4"); + }); + + it("continues at the next whole line after byte truncation and reads beyond the first byte window", async () => { + const root = await makeTempDir("cg-file-view-pages-"); + await writeFile(root, "paged notes.txt", "alphabet\nsecond\nthird"); + + const firstPage = await getCodegraphFileView({ + root, + file: "paged notes.txt", + offset: 1, + limit: 2, + maxBytes: 3, + }); + + expect(firstPage).toMatchObject({ + totalLines: 3, + content: "1\talp", + text: "alp", + truncated: true, + page: { nextOffset: 2 }, + }); + expect(formatAgentFileViewResponse(firstPage)).toBe( + [ + "File: paged notes.txt", + "Lines 1-1 of 3", + "1\talp", + "Content was truncated by the 500000-byte hard limit or a smaller requested maxBytes.", + "Next page: codegraph file 'paged notes.txt' --offset 2 --limit 2 --pretty", + ].join("\n"), + ); + + const nextPage = await getCodegraphFileView({ + root, + file: "paged notes.txt", + offset: 2, + limit: 2, + maxBytes: 12, + }); + + expect(nextPage).toMatchObject({ + offset: 2, + totalLines: 3, + content: "2\tsecond\n3\tthird", + text: "second\nthird", + truncated: false, + }); + expect(nextPage.page).toBeUndefined(); + }); + + it("rejects lexical and symlink escapes after resolving real paths", async () => { + const root = await makeTempDir("cg-file-view-root-"); + const outside = await makeTempDir("cg-file-view-outside-"); + const outsideFile = path.join(outside, "secret.txt"); + await fs.writeFile(outsideFile, "outside\n", "utf8"); + + await expect(getCodegraphFileView({ root, file: outsideFile, limit: 1, maxBytes: 100 })).rejects.toThrow( + /File is outside project root:/, + ); + + const linkedFile = path.join(root, "linked-secret.txt"); + try { + await fs.symlink(outsideFile, linkedFile, "file"); + } catch (error) { + if (isSymlinkUnavailable(error)) return; + throw error; + } + + await expect(getCodegraphFileView({ root, file: "linked-secret.txt", limit: 1, maxBytes: 100 })).rejects.toThrow( + /File is outside project root:/, + ); + }); + + it("redacts a sensitive in-root symlink target even when the requested filename is benign", async () => { + const root = await makeTempDir("cg-file-view-sensitive-symlink-"); + const targetFile = path.join(root, ".env"); + const linkedFile = path.join(root, "project-notes.txt"); + const secretValue = "symlink-target-secret"; + await fs.writeFile(targetFile, `API_TOKEN=${secretValue}\n`, "utf8"); + + try { + await fs.symlink(targetFile, linkedFile, "file"); + } catch (error) { + if (isSymlinkUnavailable(error)) return; + throw error; + } + + const redacted = await getCodegraphFileView({ root, file: "project-notes.txt", limit: 10, maxBytes: 100 }); + + expect(redacted).toMatchObject({ + file: "project-notes.txt", + totalLines: 2, + text: "Sensitive environment values omitted.\nKeys: API_TOKEN", + content: "1\tSensitive environment values omitted.\n2\tKeys: API_TOKEN", + sensitive: { kind: "environment", redacted: true, allowSensitiveRequired: true }, + }); + expect(JSON.stringify(redacted)).not.toContain(secretValue); + }); + + it("keeps a key-material symlink alias metadata-only when its target is a sensitive text config", async () => { + const root = await makeTempDir("cg-file-view-key-alias-"); + const targetFile = path.join(root, ".env"); + const linkedFile = path.join(root, "id_rsa"); + const secretValue = "key-alias-target-secret"; + const payload = `API_TOKEN=${secretValue}\n`; + await fs.writeFile(targetFile, payload, "utf8"); + + try { + await fs.symlink(targetFile, linkedFile, "file"); + } catch (error) { + if (isSymlinkUnavailable(error)) return; + throw error; + } + + const openSpy = vi.spyOn(fs, "open"); + const readFileSpy = vi.spyOn(fs, "readFile"); + + try { + const redacted = await getCodegraphFileView({ root, file: "id_rsa", limit: 10, maxBytes: 100 }); + + expect(redacted).toMatchObject({ + file: "id_rsa", + totalLines: 2, + text: `Sensitive key material omitted.\nSize: ${Buffer.byteLength(payload)} bytes.`, + content: `1\tSensitive key material omitted.\n2\tSize: ${Buffer.byteLength(payload)} bytes.`, + sensitive: { kind: "key-material", redacted: true, allowSensitiveRequired: true }, + }); + expect(JSON.stringify(redacted)).not.toContain(secretValue); + expect(openSpy).not.toHaveBeenCalled(); + expect(readFileSpy).not.toHaveBeenCalled(); + } finally { + openSpy.mockRestore(); + readFileSpy.mockRestore(); + } + }); + + it("keeps graph context opt-in and reports only the target's direct importer", async () => { + const root = await makeTempDir("cg-file-view-graph-"); + await writeFile(root, "src/auth.ts", "export function validateUser() { return true; }\n"); + await writeFile( + root, + "src/server.ts", + "import { validateUser } from './auth';\nexport const allowed = validateUser();\n", + ); + await writeFile(root, "src/api.ts", "import { allowed } from './server';\nexport const response = allowed;\n"); + + const plain = await getCodegraphFileView({ root, file: "src/auth.ts", limit: 1, maxBytes: 100 }); + expect(plain.content).toBe("1\texport function validateUser() { return true; }"); + expect(plain.graphContext).toBeUndefined(); + + const contextual = await getCodegraphFileView({ + root, + file: "src/auth.ts", + limit: 1, + maxBytes: 100, + includeGraphContext: true, + }); + + expect(contextual.graphContext?.usedBy).toEqual(["src/server.ts"]); + expect(contextual.graphContext?.symbols).toContainEqual({ name: "validateUser", kind: "function", line: 1 }); + }); + + it("does not perform freshness or project-index work for a plain session read", async () => { + const root = await makeTempDir("cg-file-view-index-free-"); + await writeFile(root, "plain.txt", "live bytes\n"); + const forbiddenSessionWork = async (): Promise => { + throw new Error("plain file reads must not touch the project session"); + }; + const session: AgentSession = { + root, + checkFreshness: forbiddenSessionWork, + loadProject: forbiddenSessionWork, + invalidate: () => undefined, + }; + + const response = await getCodegraphFileViewWithSession(session, { + root, + file: "plain.txt", + limit: 1, + maxBytes: 100, + }); + + expect(response.content).toBe("1\tlive bytes"); + expect(response.graphContext).toBeUndefined(); + }); + + it("returns current disk bytes even when the supplied session snapshot is stale", async () => { + const root = await makeTempDir("cg-file-view-stale-"); + await writeFile(root, "state.ts", "export const state = 'old';\n"); + const session = createAgentSession({ root }); + await session.loadProject({ symbolGraph: "skip" }); + await writeFile(root, "state.ts", "export const state = 'live';\n"); + + const response = await getCodegraphFileViewWithSession(session, { + root, + file: "state.ts", + limit: 1, + maxBytes: 100, + }); + + expect(response.content).toBe("1\texport const state = 'live';"); + expect(response.text).toBe("export const state = 'live';"); + }); + + it("rejects NUL-bearing content even when its extension looks textual", async () => { + const root = await makeTempDir("cg-file-view-binary-"); + await writeFile(root, "payload.txt", Buffer.from([0x61, 0x62, 0x00, 0x63])); + + await expect(getCodegraphFileView({ root, file: "payload.txt", limit: 10, maxBytes: 100 })).rejects.toThrow( + /Binary files are not supported:/, + ); + }); + + it("rejects malformed UTF-8 on an unselected later line outside the returned byte window", async () => { + const root = await makeTempDir("cg-file-view-invalid-later-utf8-"); + await writeFile( + root, + "later-line.txt", + Buffer.concat([Buffer.from("selected\nvalid but unselected\n", "utf8"), Buffer.from([0xff]), Buffer.from("\n")]), + ); + + await expect( + getCodegraphFileView({ root, file: "later-line.txt", offset: 1, limit: 1, maxBytes: 4 }), + ).rejects.toThrow(/Binary or non-UTF-8 files are not supported:/); + }); + + it("rejects an invalid UTF-8 continuation crossing the 64 KiB read boundary outside the selected page", async () => { + const root = await makeTempDir("cg-file-view-invalid-boundary-utf8-"); + const selectedLine = Buffer.from("selected\n", "utf8"); + const readBufferBytes = 64 * 1024; + const padding = Buffer.alloc(readBufferBytes - selectedLine.length - 1, 0x61); + await writeFile(root, "boundary.txt", Buffer.concat([selectedLine, padding, Buffer.from([0xc3, 0x28])])); + + await expect( + getCodegraphFileView({ root, file: "boundary.txt", offset: 1, limit: 1, maxBytes: 8 }), + ).rejects.toThrow(/Binary or non-UTF-8 files are not supported:/); + }); + + it.each([ + { name: "invalid continuation", file: "invalid-continuation.ts", bytes: [0xc3, 0x28] }, + { name: "incomplete final sequence", file: "incomplete-sequence.md", bytes: [0xe2, 0x82] }, + ])("rejects $name bytes in text-looking files", async ({ file, bytes }) => { + const root = await makeTempDir("cg-file-view-invalid-utf8-"); + await writeFile(root, file, Buffer.concat([Buffer.from("valid prefix\n", "utf8"), Buffer.from(bytes)])); + + await expect(getCodegraphFileView({ root, file, limit: 10, maxBytes: 100 })).rejects.toThrow( + /Binary or non-UTF-8 files are not supported:/, + ); + }); + + it.each([ + { + name: "NUL in an environment file", + file: ".env", + invalidBytes: [0x00], + expectedError: /Binary files are not supported:/, + }, + { + name: "malformed UTF-8 in a credential config", + file: "credentials.json", + invalidBytes: [0xff], + expectedError: /Binary or non-UTF-8 files are not supported:/, + }, + { + name: "incomplete trailing UTF-8 in an environment file", + file: ".env.production", + invalidBytes: [0xe2, 0x82], + expectedError: /Binary or non-UTF-8 files are not supported:/, + }, + ])("rejects $name beyond the bounded sensitive-summary prefix", async ({ file, invalidBytes, expectedError }) => { + const root = await makeTempDir("cg-file-view-sensitive-invalid-"); + const structuralScanBytes = 64 * 1024; + const keyAndValue = Buffer.from("API_TOKEN=summary-prefix-secret\n", "utf8"); + const padding = Buffer.alloc(structuralScanBytes - keyAndValue.length, 0x61); + await writeFile(root, file, Buffer.concat([keyAndValue, padding, Buffer.from(invalidBytes)])); + + await expect(getCodegraphFileView({ root, file, offset: 1, limit: 1, maxBytes: 16 })).rejects.toThrow( + expectedError, + ); + }); + + it.each([ + { name: "ordinary text", file: "oversized.txt" }, + { name: "recognized sensitive config", file: "credentials.json" }, + ])("rejects a $name file above the bounded file-view input limit", async ({ file }) => { + const root = await makeTempDir("cg-file-view-input-limit-"); + const filePath = await writeSparseFile(root, file, FILE_VIEW_INPUT_LIMIT_BYTES + 1); + + await expect(getCodegraphFileView({ root, file, limit: 1, maxBytes: 1 })).rejects.toThrow( + `File exceeds the 16777216-byte file view input limit: ${filePath}`, + ); + }); + + it("enforces the input limit when a text file grows after its initial size check", async () => { + const root = await makeTempDir("cg-file-view-growth-limit-"); + const filePath = path.join(root, "growth-race.txt"); + await writeFile(root, "growth-race.txt", "initial\n"); + const originalOpen = fs.open.bind(fs); + const openSpy = vi.spyOn(fs, "open"); + let grewFile = false; + + openSpy.mockImplementation(async (target, flags) => { + if (!grewFile && target === filePath && flags === "r") { + grewFile = true; + const growthHandle = await originalOpen(filePath, "w"); + const chunk = Buffer.alloc(64 * 1024, 0x61); + let remainingBytes = FILE_VIEW_INPUT_LIMIT_BYTES + 1; + try { + while (remainingBytes) { + const bytesToWrite = Math.min(chunk.length, remainingBytes); + const { bytesWritten } = await growthHandle.write(chunk, 0, bytesToWrite, null); + remainingBytes -= bytesWritten; + } + } finally { + await growthHandle.close(); + } + } + return await originalOpen(target, flags); + }); + + try { + await expect(getCodegraphFileView({ root, file: "growth-race.txt", limit: 1, maxBytes: 1 })).rejects.toThrow( + `File exceeds the 16777216-byte file view input limit: ${filePath}`, + ); + } finally { + openSpy.mockRestore(); + } + }); + + const sensitiveConfigFixtures = [ + { + file: ".npmrc", + kind: "authentication-config", + raw: "_authToken=npm-token-value\n", + expectedKeys: "_authToken", + secretValues: ["npm-token-value"], + }, + { + file: ".pypirc", + kind: "authentication-config", + raw: "[pypi]\nusername=release-user\npassword=pypi-password-value\n", + expectedKeys: "password, username", + secretValues: ["release-user", "pypi-password-value"], + }, + { + file: ".netrc", + kind: "authentication-config", + raw: "machine registry.example.test login ci-user password netrc-password-value\n", + expectedKeys: "login, machine, password", + secretValues: ["registry.example.test", "ci-user", "netrc-password-value"], + }, + { + file: "credentials.json", + kind: "credential-config", + raw: '{\n "client_id": "build-client",\n "client_secret": "json-secret-value"\n}\n', + expectedKeys: "client_id, client_secret", + secretValues: ["build-client", "json-secret-value"], + }, + { + file: "secrets.production.yaml", + kind: "credential-config", + raw: "account: deploy-user\npassword: yaml-secret-value\n", + expectedKeys: "account, password", + secretValues: ["deploy-user", "yaml-secret-value"], + }, + { + file: "service-account.toml", + kind: "credential-config", + raw: 'project_id = "service-project"\nprivate_key = "toml-secret-value"\n', + expectedKeys: "private_key, project_id", + secretValues: ["service-project", "toml-secret-value"], + }, + { + file: "credential.ini", + kind: "credential-config", + raw: "[default]\naccess_key=ini-access-value\nsecret_key=ini-secret-value\n", + expectedKeys: "access_key, secret_key", + secretValues: ["ini-access-value", "ini-secret-value"], + }, + ] as const; + + it.each(sensitiveConfigFixtures)( + "redacts $kind values in $file by default and returns exact raw text only when allowed", + async ({ file, kind, raw, expectedKeys, secretValues }) => { + const root = await makeTempDir("cg-file-view-sensitive-config-"); + await writeFile(root, file, raw); + + const redacted = await getCodegraphFileView({ root, file, limit: 20, maxBytes: 1024 }); + const expectedSummary = `Sensitive ${kind} values omitted.\nKeys: ${expectedKeys}`; + + expect(redacted).toMatchObject({ + file, + totalLines: 2, + text: expectedSummary, + content: `1\tSensitive ${kind} values omitted.\n2\tKeys: ${expectedKeys}`, + sensitive: { kind, redacted: true, allowSensitiveRequired: true }, + }); + for (const secretValue of secretValues) { + expect(JSON.stringify(redacted)).not.toContain(secretValue); + } + + const allowed = await getCodegraphFileView({ + root, + file, + limit: 20, + maxBytes: 1024, + allowSensitive: true, + }); + + expect(allowed.text).toBe(raw); + expect(allowed).toMatchObject({ + file, + sensitive: { kind, redacted: false, allowSensitiveRequired: true }, + }); + }, + ); + + const keyBundleFixtures = [ + { file: "client-identity.p12", marker: "p12-private-key-secret" }, + { file: "client-identity.pfx", marker: "pfx-private-key-secret" }, + ] as const; + + it.each(keyBundleFixtures)( + "redacts valid UTF-8 $file metadata and rejects raw access by binary extension", + async ({ file, marker }) => { + const root = await makeTempDir("cg-file-view-key-bundle-"); + const payload = `${marker}\notherwise ordinary text\n`; + await writeFile(root, file, payload); + + const redacted = await getCodegraphFileView({ root, file, limit: 10, maxBytes: 100 }); + + expect(redacted).toMatchObject({ + file, + totalLines: 2, + text: `Sensitive key material omitted.\nSize: ${Buffer.byteLength(payload)} bytes.`, + content: `1\tSensitive key material omitted.\n2\tSize: ${Buffer.byteLength(payload)} bytes.`, + sensitive: { kind: "key-material", redacted: true, allowSensitiveRequired: true }, + }); + expect(JSON.stringify(redacted)).not.toContain(marker); + + await expect( + getCodegraphFileView({ root, file, limit: 10, maxBytes: 100, allowSensitive: true }), + ).rejects.toThrow(/Binary files are not supported:/); + }, + ); + + const customSshPrivateKeyFixtures = [ + { file: "id_ci", marker: "ci-private-key-marker" }, + { file: "id_github", marker: "github-private-key-marker" }, + ] as const; + + it.each(customSshPrivateKeyFixtures)( + "keeps custom SSH private key $file metadata-only until sensitive access is allowed", + async ({ file, marker }) => { + const root = await makeTempDir("cg-file-view-custom-ssh-key-"); + const raw = `-----BEGIN OPENSSH PRIVATE KEY-----\n${marker}\n-----END OPENSSH PRIVATE KEY-----\n`; + await writeFile(root, file, raw); + const openSpy = vi.spyOn(fs, "open"); + const readFileSpy = vi.spyOn(fs, "readFile"); + + try { + const redacted = await getCodegraphFileView({ root, file, limit: 10, maxBytes: 1024 }); + + expect(redacted).toMatchObject({ + file, + totalLines: 2, + text: `Sensitive key material omitted.\nSize: ${Buffer.byteLength(raw)} bytes.`, + content: `1\tSensitive key material omitted.\n2\tSize: ${Buffer.byteLength(raw)} bytes.`, + sensitive: { kind: "key-material", redacted: true, allowSensitiveRequired: true }, + }); + expect(JSON.stringify(redacted)).not.toContain(marker); + expect(openSpy).not.toHaveBeenCalled(); + expect(readFileSpy).not.toHaveBeenCalled(); + + const allowed = await getCodegraphFileView({ + root, + file, + limit: 10, + maxBytes: 1024, + allowSensitive: true, + }); + + expect(allowed).toMatchObject({ + file, + totalLines: 4, + text: raw, + content: `1\t-----BEGIN OPENSSH PRIVATE KEY-----\n2\t${marker}\n3\t-----END OPENSSH PRIVATE KEY-----\n4\t`, + sensitive: { kind: "key-material", redacted: false, allowSensitiveRequired: true }, + }); + expect(openSpy).toHaveBeenCalledTimes(1); + expect(openSpy).toHaveBeenCalledWith(path.join(root, file), "r"); + expect(readFileSpy).not.toHaveBeenCalled(); + } finally { + openSpy.mockRestore(); + readFileSpy.mockRestore(); + } + }, + ); + + it("returns an id_-prefixed OpenSSH public key as ordinary raw text without sensitive access", async () => { + const root = await makeTempDir("cg-file-view-custom-ssh-public-key-"); + const file = "id_ci.pub"; + const raw = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICI-public-key ci@example.test\n"; + await writeFile(root, file, raw); + + const result = await getCodegraphFileView({ root, file, limit: 10, maxBytes: 1024 }); + + expect(result).toMatchObject({ + file, + totalLines: 2, + text: raw, + content: `1\t${raw.trimEnd()}\n2\t`, + }); + expect(result.sensitive).toBeUndefined(); + }); + + it("returns key-material metadata without opening target bytes and opens the ordinary raw path only when allowed", async () => { + const root = await makeTempDir("cg-file-view-key-metadata-only-"); + const keyFiles = [ + { file: "signing.key", marker: "key-private-marker" }, + { file: "certificate.pem", marker: "pem-private-marker" }, + { file: "id_ed25519", marker: "ssh-private-marker" }, + ...keyBundleFixtures, + ]; + for (const { file, marker } of keyFiles) { + await writeFile(root, file, `${marker}\n`); + } + const openSpy = vi.spyOn(fs, "open"); + const readFileSpy = vi.spyOn(fs, "readFile"); + + try { + for (const { file, marker } of keyFiles) { + const redacted = await getCodegraphFileView({ root, file, limit: 10, maxBytes: 100 }); + expect(redacted).toMatchObject({ + file, + text: `Sensitive key material omitted.\nSize: ${Buffer.byteLength(`${marker}\n`)} bytes.`, + sensitive: { kind: "key-material", redacted: true, allowSensitiveRequired: true }, + }); + expect(JSON.stringify(redacted)).not.toContain(marker); + } + expect(openSpy).not.toHaveBeenCalled(); + expect(readFileSpy).not.toHaveBeenCalled(); + + const allowed = await getCodegraphFileView({ + root, + file: keyFiles[0]!.file, + limit: 10, + maxBytes: 100, + allowSensitive: true, + }); + + expect(allowed).toMatchObject({ + file: "signing.key", + text: "key-private-marker\n", + content: "1\tkey-private-marker\n2\t", + sensitive: { kind: "key-material", redacted: false, allowSensitiveRequired: true }, + }); + expect(openSpy).toHaveBeenCalledTimes(1); + expect(openSpy).toHaveBeenCalledWith(path.join(root, "signing.key"), "r"); + expect(readFileSpy).not.toHaveBeenCalled(); + } finally { + openSpy.mockRestore(); + readFileSpy.mockRestore(); + } + }); + + it("keeps oversized key material metadata-only but caps explicit raw access", async () => { + const root = await makeTempDir("cg-file-view-large-key-"); + const file = "oversized-signing.key"; + const oversizedBytes = FILE_VIEW_INPUT_LIMIT_BYTES + 1; + const filePath = await writeSparseFile(root, file, oversizedBytes); + const openSpy = vi.spyOn(fs, "open"); + const readFileSpy = vi.spyOn(fs, "readFile"); + + try { + const redacted = await getCodegraphFileView({ root, file, limit: 10, maxBytes: 100 }); + + expect(redacted).toMatchObject({ + file, + totalLines: 2, + text: `Sensitive key material omitted.\nSize: ${oversizedBytes} bytes.`, + content: `1\tSensitive key material omitted.\n2\tSize: ${oversizedBytes} bytes.`, + sensitive: { kind: "key-material", redacted: true, allowSensitiveRequired: true }, + }); + expect(openSpy).not.toHaveBeenCalled(); + expect(readFileSpy).not.toHaveBeenCalled(); + + await expect( + getCodegraphFileView({ root, file, limit: 10, maxBytes: 100, allowSensitive: true }), + ).rejects.toThrow(`File exceeds the 16777216-byte file view input limit: ${filePath}`); + expect(openSpy).not.toHaveBeenCalled(); + expect(readFileSpy).not.toHaveBeenCalled(); + } finally { + openSpy.mockRestore(); + readFileSpy.mockRestore(); + } + }); + + it("rejects a directory whose name has a key-material suffix", async () => { + const root = await makeTempDir("cg-file-view-key-directory-"); + await fs.mkdir(path.join(root, "identity.pem")); + + await expect(getCodegraphFileView({ root, file: "identity.pem", limit: 10, maxBytes: 100 })).rejects.toThrow( + /File view target is not a file:/, + ); + }); + + it("returns metadata for a NUL-bearing .key file but validates and rejects explicit raw access", async () => { + const root = await makeTempDir("cg-file-view-key-file-"); + const file = "signing.key"; + const marker = "private-key-secret"; + const payload = Buffer.concat([Buffer.from(marker, "utf8"), Buffer.from([0x00, 0xff])]); + await writeFile(root, file, payload); + + const redacted = await getCodegraphFileView({ root, file, limit: 10, maxBytes: 100 }); + + expect(redacted).toMatchObject({ + file, + totalLines: 2, + text: `Sensitive key material omitted.\nSize: ${payload.length} bytes.`, + content: `1\tSensitive key material omitted.\n2\tSize: ${payload.length} bytes.`, + sensitive: { kind: "key-material", redacted: true, allowSensitiveRequired: true }, + }); + expect(JSON.stringify(redacted)).not.toContain(marker); + + await expect(getCodegraphFileView({ root, file, limit: 10, maxBytes: 100, allowSensitive: true })).rejects.toThrow( + /Binary files are not supported:/, + ); + }); + + it("redacts sensitive values into a key-only summary unless raw access is explicit", async () => { + const root = await makeTempDir("cg-file-view-sensitive-"); + await writeFile(root, ".env", "API_TOKEN=super-secret\nUSER=alice\n"); + + const redacted = await getCodegraphFileView({ root, file: ".env", limit: 10, maxBytes: 100 }); + + expect(redacted).toMatchObject({ + file: ".env", + totalLines: 2, + text: "Sensitive environment values omitted.\nKeys: API_TOKEN, USER", + content: "1\tSensitive environment values omitted.\n2\tKeys: API_TOKEN, USER", + sensitive: { kind: "environment", redacted: true, allowSensitiveRequired: true }, + }); + + const allowed = await getCodegraphFileView({ + root, + file: ".env", + limit: 10, + maxBytes: 100, + allowSensitive: true, + }); + + expect(allowed).toMatchObject({ + totalLines: 3, + text: "API_TOKEN=super-secret\nUSER=alice\n", + content: "1\tAPI_TOKEN=super-secret\n2\tUSER=alice\n3\t", + sensitive: { kind: "environment", redacted: false, allowSensitiveRequired: true }, + }); + }); +}); diff --git a/tests/mcp-server.test.ts b/tests/mcp-server.test.ts index 8e737acf..814ee58f 100644 --- a/tests/mcp-server.test.ts +++ b/tests/mcp-server.test.ts @@ -419,6 +419,44 @@ describe("codegraph MCP handlers", () => { expect(packetProperties.target).toBeTruthy(); }); + it("advertises get_file with the exact bounded file-view schema", () => { + const getFileTool = listCodegraphMcpTools().find((tool) => tool.name === "get_file"); + expect(getFileTool).toBeTruthy(); + if (getFileTool === undefined) { + throw new Error("MCP tools/list did not include the get_file tool."); + } + + const getFileSchema = readObject(getFileTool.inputSchema); + const getFileProperties = readObject(getFileSchema.properties); + + expect(getFileSchema.type).toBe("object"); + expect(getFileSchema.required).toEqual(["file"]); + expect(Object.keys(getFileProperties).sort()).toEqual([ + "allowSensitive", + "file", + "includeGraphContext", + "limit", + "maxBytes", + "offset", + ]); + expect(readObject(getFileProperties.file)).toEqual({ type: "string" }); + expect(readObject(getFileProperties.offset)).toEqual({ type: "integer", minimum: 1, default: 1 }); + expect(readObject(getFileProperties.limit)).toEqual({ + type: "integer", + minimum: 1, + maximum: 10_000, + default: 2_000, + }); + expect(readObject(getFileProperties.maxBytes)).toEqual({ + type: "integer", + minimum: 1, + maximum: 500_000, + default: 80_000, + }); + expect(readObject(getFileProperties.includeGraphContext)).toEqual({ type: "boolean", default: false }); + expect(readObject(getFileProperties.allowSensitive)).toEqual({ type: "boolean", default: false }); + }); + it("bounds refs by handle with the refs limit", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-refs-limit-")); await fs.writeFile(path.join(root, "auth.ts"), "export function validateUser(id: number) { return id > 0; }\n"); @@ -894,20 +932,87 @@ describe("codegraph MCP handlers", () => { }); }); - it("omits pagination when a truncated byte window has no more available lines", async () => { + it("paginates complete lines beyond the initial byte window without hiding the full-file line count", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-file-window-page-")); await fs.writeFile(path.join(root, "notes.txt"), "one\ntwo\nthree\n", "utf8"); const handlers = createCodegraphMcpHandlers({ root }); - const result = await handlers.get_file({ file: "notes.txt", maxBytes: 7, limit: 2 }); + const firstPage = await handlers.get_file({ file: "notes.txt", maxBytes: 7, limit: 2 }); - expect(result).toMatchObject({ + expect(firstPage).toMatchObject({ text: "one\ntwo", content: "1\tone\n2\ttwo", - totalLines: 2, - truncated: true, + totalLines: 4, + truncated: false, + page: { nextOffset: 3 }, + }); + + const finalPage = await handlers.get_file({ file: "notes.txt", offset: 3, maxBytes: 7, limit: 2 }); + expect(finalPage).toMatchObject({ + text: "three\n", + content: "3\tthree\n4\t", + totalLines: 4, + truncated: false, + }); + expect(finalPage.page).toBeUndefined(); + }); + + it("redacts environment files through get_file unless allowSensitive is true", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-sensitive-file-")); + const sensitiveText = "API_TOKEN=mcp-secret\nUSER=alice\n"; + await fs.writeFile(path.join(root, ".env.test"), sensitiveText, "utf8"); + const handlers = createCodegraphMcpHandlers({ root }); + + const redacted = await handlers.get_file({ file: ".env.test" }); + + expect(JSON.stringify(redacted)).not.toContain("mcp-secret"); + expect(redacted).toMatchObject({ + file: ".env.test", + text: "Sensitive environment values omitted.\nKeys: API_TOKEN, USER", + content: "1\tSensitive environment values omitted.\n2\tKeys: API_TOKEN, USER", + sensitive: { kind: "environment", redacted: true, allowSensitiveRequired: true }, + }); + + const allowed = await handlers.get_file({ file: ".env.test", allowSensitive: true }); + + expect(allowed).toMatchObject({ + text: sensitiveText, + content: "1\tAPI_TOKEN=mcp-secret\n2\tUSER=alice\n3\t", + sensitive: { kind: "environment", redacted: false, allowSensitiveRequired: true }, }); - expect(result.page).toBeUndefined(); + }); + + it("honors requested freshness and project loading while sensitive content stays redacted", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-sensitive-context-")); + const rawSecret = "freshness-secret-value"; + await fs.writeFile(path.join(root, ".env.test"), `API_TOKEN=${rawSecret}\nUSER=alice\n`, "utf8"); + const backingSession = createAgentSession({ root }); + let freshnessChecks = 0; + let projectLoads = 0; + const session: AgentSession = { + ...backingSession, + checkFreshness: async () => { + freshnessChecks += 1; + return { state: "refreshed", changedFiles: [".env.test"] }; + }, + loadProject: async (options) => { + projectLoads += 1; + return await backingSession.loadProject(options); + }, + }; + const handlers = createCodegraphMcpHandlers({ root, session }); + + const redacted = await handlers.get_file({ file: ".env.test", includeGraphContext: true }); + + expect(JSON.stringify(redacted)).not.toContain(rawSecret); + expect(redacted).toMatchObject({ + text: "Sensitive environment values omitted.\nKeys: API_TOKEN, USER", + content: "1\tSensitive environment values omitted.\n2\tKeys: API_TOKEN, USER", + freshness: { state: "refreshed", changedFiles: [".env.test"] }, + sensitive: { kind: "environment", redacted: true, allowSensitiveRequired: true }, + }); + expect(freshnessChecks).toBe(1); + expect(projectLoads).toBe(1); }); it("keeps plain get_file index-free and opts into freshness and direct graph context", async () => { @@ -956,17 +1061,22 @@ describe("codegraph MCP handlers", () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-file-graph-cap-")); const targetFile = path.join(root, "target.ts"); await fs.writeFile(targetFile, "export const target = true;\n", "utf8"); - const usedByFiles = Array.from({ length: 101 }, (_, index) => + const unsortedIndexes = Array.from({ length: 101 }, (_, position) => { + const offset = Math.floor(position / 2); + if (position % 2) return offset; + return 100 - offset; + }); + const usedByFiles = unsortedIndexes.map((index) => path.join(root, `consumer-${String(index).padStart(3, "0")}.ts`), ); const moduleIndex: ModuleIndex = { file: targetFile, exports: [], - imports: Array.from({ length: 101 }, (_, index) => ({ + imports: unsortedIndexes.map((index) => ({ kind: "star", from: `package-${String(index).padStart(3, "0")}`, })), - locals: Array.from({ length: 101 }, (_, index) => ({ + locals: unsortedIndexes.map((index) => ({ file: targetFile, localName: `symbol-${String(index).padStart(3, "0")}`, kind: SymbolKind.Function, @@ -1013,13 +1123,22 @@ describe("codegraph MCP handlers", () => { const result = await handlers.get_file({ file: "target.ts", includeGraphContext: true }); expect(result.graphContext?.usedBy).toHaveLength(100); - expect(result.graphContext?.usedBy.at(-1)).toBe("consumer-099.ts"); + expect(result.graphContext?.usedBy.slice(0, 2)).toEqual(["consumer-000.ts", "consumer-001.ts"]); + expect(result.graphContext?.usedBy.slice(-2)).toEqual(["consumer-098.ts", "consumer-099.ts"]); expect(result.graphContext?.usedBy).not.toContain("consumer-100.ts"); expect(result.graphContext?.imports).toHaveLength(100); - expect(result.graphContext?.imports.at(-1)).toBe("package-099"); + expect(result.graphContext?.imports.slice(0, 2)).toEqual(["package-000", "package-001"]); + expect(result.graphContext?.imports.slice(-2)).toEqual(["package-098", "package-099"]); expect(result.graphContext?.imports).not.toContain("package-100"); expect(result.graphContext?.symbols).toHaveLength(100); - expect(result.graphContext?.symbols.at(-1)?.name).toBe("symbol-099"); + expect(result.graphContext?.symbols.slice(0, 2)).toEqual([ + { name: "symbol-000", kind: SymbolKind.Function, line: 1 }, + { name: "symbol-001", kind: SymbolKind.Function, line: 2 }, + ]); + expect(result.graphContext?.symbols.slice(-2)).toEqual([ + { name: "symbol-098", kind: SymbolKind.Function, line: 99 }, + { name: "symbol-099", kind: SymbolKind.Function, line: 100 }, + ]); expect(result.graphContext?.symbols.some((symbol) => symbol.name === "symbol-100")).toBe(false); });