diff --git a/README.md b/README.md index 40d21e39..6eb01d56 100644 --- a/README.md +++ b/README.md @@ -29,9 +29,9 @@ Use Codegraph when you need fast structural answers about a repo without relying - Export graph data as JSON, Mermaid, DOT, or SQLite, then inspect it from scripts, Markdown renderers, Graphviz, or SQL tools. - Keep one workflow across source languages, monorepos, and graph-first document and template formats instead of stitching together separate tools. -For unfamiliar repos, start with `orient --root . --budget small --pretty`, then use `search` and `explain` to land on one concrete code anchor. +For unfamiliar repos with a concrete question, start with `explore "how does auth reach db?" --root . --pretty`; use `orient --root . --budget small --pretty` when you need a map before asking a question. For daily change work, start with `review --base HEAD --head WORKTREE --summary`; use `impact --base HEAD --head WORKTREE --pretty` as the broader blast-radius map when needed. -Search is code-first by default in hybrid mode, and search, explain, and review packets now include analysis labels so reduced-mode or mixed-semantics runs stay visible. +Search is code-first by default in hybrid mode, and explore, search, explain, and review packets include analysis labels so reduced-mode or mixed-semantics runs stay visible. Detailed command contracts and JSON shapes live in [docs/cli.md](./docs/cli.md). ## Features @@ -39,7 +39,7 @@ Detailed command contracts and JSON shapes live in [docs/cli.md](./docs/cli.md). - Multi-language dependency graphs, including imports, re-exports, `require()`, dynamic imports, workspace resolution, document links, stylesheet imports, and SFC script dependencies. - Per-file symbol indexes with locals, exports, docstrings, line spans, and lightweight complexity metadata. - Cross-file go-to-definition and find-references support across the shared source-language pipeline. -- Deterministic agent orientation, packet retrieval, search, bounded explanations, portable artifact bundles, and MCP tools across files, symbols, chunks, SQL objects, graph neighborhoods, and review ranges with stable follow-up targets. +- Deterministic agent exploration, orientation, packet retrieval, search, bounded explanations, portable artifact bundles, and MCP tools across files, symbols, chunks, SQL objects, graph neighborhoods, and review ranges with stable follow-up targets. - Semantic chunking for code and text files, including Vue and Svelte single-file component block splitting. - Duplicate and near-duplicate detection over indexed symbols, semantic chunks, text chunks, token fingerprints, and AST shape hashes when parser context is available. - AST grep, public API summaries, unresolved import reports, hotspot analysis, cycle detection, and shortest dependency paths. @@ -86,6 +86,9 @@ node ./dist/cli.js review --base HEAD --head WORKTREE --summary # broader blast-radius map when the review packet needs expansion node ./dist/cli.js impact --base HEAD --head WORKTREE --pretty +# one-call answer for a concrete repo question +node ./dist/cli.js explore "how does auth reach db?" --root . --pretty + # bounded repo orientation with next-step suggestions node ./dist/cli.js orient --root . --budget small --pretty @@ -131,7 +134,8 @@ Use these as starting points, then see [docs/cli.md](./docs/cli.md) for all flag codegraph review --base HEAD --head WORKTREE --summary codegraph impact --base HEAD --head WORKTREE --pretty -# repo orientation and bounded follow-up +# repo question, orientation, and bounded follow-up +codegraph explore "how does auth reach db?" --root . --pretty codegraph orient --root . --budget small --pretty codegraph search "build review report" --json codegraph explain src/review.ts @@ -340,7 +344,7 @@ For a custom location, use `codegraph skill install --target /skills/codeg ## Using as a library -Use the TypeScript API when another program needs deterministic file packs, review packets, or model prompts. CLI `--pretty` and `--summary` output is also useful for model-readable triage, but library callers should keep structured fields until the final UI or prompt boundary. For repeated calls, prefer one warm `createCodeReviewSession()` or one agent/MCP session over rebuilding ad hoc indexes. +Use the TypeScript API when another program needs deterministic explore responses, file packs, review packets, or model prompts. CLI `--pretty` and `--summary` output is also useful for model-readable triage, but library callers should keep structured fields until the final UI or prompt boundary. For repeated calls, prefer one warm `createCodeReviewSession()` or one agent/MCP session over rebuilding ad hoc indexes. ```ts import { @@ -430,8 +434,8 @@ For the full capability matrix, limitations, and fixture coverage, see [docs/lan - [docs/installation.md](./docs/installation.md): source checkout, scoped registry, release tarball, native runtime modes, and reduced-mode behavior - [docs/cli.md](./docs/cli.md): command reference, output formats, SQLite schema, review bundles, and graph export usage -- [docs/library-api.md](./docs/library-api.md): agent orientation/packet/search/explain/artifacts, semantic chunking, indexing, graph APIs, read-only SQL, impact examples, and programmatic review output -- [docs/agent-workflows.md](./docs/agent-workflows.md): orientation packets, search anchors, MCP, sessions, streaming, tool wrappers, review bundles, and agent-oriented review recipes +- [docs/library-api.md](./docs/library-api.md): agent explore/orientation/packet/search/explain/artifacts, semantic chunking, indexing, graph APIs, read-only SQL, impact examples, and programmatic review output +- [docs/agent-workflows.md](./docs/agent-workflows.md): explore, orientation packets, search anchors, MCP, sessions, streaming, tool wrappers, review bundles, and agent-oriented review recipes - [docs/mcp.md](./docs/mcp.md): MCP server setup, tool list, safety model, and client configuration examples - [docs/how-it-works.md](./docs/how-it-works.md): performance, caching, native runtime behavior, architecture, and testing guidance - [docs/language-parity.md](./docs/language-parity.md): per-language capability matrix diff --git a/codegraph-skill/codegraph/SKILL.md b/codegraph-skill/codegraph/SKILL.md index a49a93d6..f3b83c86 100644 --- a/codegraph-skill/codegraph/SKILL.md +++ b/codegraph-skill/codegraph/SKILL.md @@ -11,7 +11,7 @@ Use Codegraph for structure-aware repo questions: - symbol navigation with definitions, references, dependencies, and paths - PR or worktree impact review with candidate tests and risk signals - duplicate cleanup and refactor-risk triage -- bounded agent context through orientation, search, packets, explain, and MCP +- bounded agent context through explore, orientation, search, packets, explain, and MCP Prefer plain text search for raw strings, logs, config keys, secrets, and exact literals. Do not use Codegraph as the only evidence for runtime behavior; pair it with tests or execution. @@ -24,10 +24,11 @@ For PR, worktree, or sweeping review tasks, start with the compact reviewer hand codegraph review --base HEAD --head WORKTREE --summary ``` -Use `codegraph impact --base HEAD --head WORKTREE --pretty` when you need the broader blast-radius map. For unfamiliar repos without a diff, start bounded with `codegraph orient --root . --budget small --pretty`. +Use `codegraph impact --base HEAD --head WORKTREE --pretty` when you need the broader blast-radius map. For unfamiliar repos without a diff, start bounded with `codegraph explore "how does auth reach db?" --root . --pretty` or `codegraph orient --root . --budget small --pretty` when no concrete question exists. 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` - packet: `codegraph packet get --pretty` - search: `codegraph search "auth user" --json` - explain: `codegraph explain ` @@ -54,6 +55,7 @@ Hybrid search is code-first by default, and search/explain packets include analy Current high-value surfaces: +- `explore --pretty`: one-call question answer with anchors, packets, paths, blast radius, candidate tests, and follow-ups - `orient --pretty`: ranked first-turn focus targets with copyable follow-ups - `impact --pretty`: ranked "what could this break?" map - `review --summary`: compact reviewer handoff @@ -65,7 +67,7 @@ Treat duplicate leads and call-compatibility hints as review leads, not proof. ## MCP If MCP tools are available, prefer them over repeated CLI invocations. -Use MCP `orient`, `search`, `packet_get`, `goto`, `refs`, `deps`, `rdeps`, `path`, `impact`, `review`, and `query_sqlite` first. +Use MCP `explore`, `orient`, `search`, `packet_get`, `goto`, `refs`, `deps`, `rdeps`, `path`, `impact`, `review`, and `query_sqlite` first. 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. Fall back to CLI when MCP is unavailable. diff --git a/docs/agent-workflows.md b/docs/agent-workflows.md index 8d29be63..1f02a8cb 100644 --- a/docs/agent-workflows.md +++ b/docs/agent-workflows.md @@ -21,6 +21,7 @@ codegraph impact --base HEAD --head WORKTREE --pretty For an unfamiliar repo, keep the first loop bounded and actionable: ```bash +codegraph explore "how does auth reach db?" --root . --pretty codegraph orient --root . --budget small --pretty codegraph search "auth user" --json codegraph explain --json @@ -29,7 +30,7 @@ codegraph explain --json For PR, worktree, or sweeping review tasks, prefer `review` first; use `impact` when you need the broader blast radius map instead of the reviewer handoff. Use `doctor` only when package/runtime state or an existing artifact path is the question. -Use `search` when the agent has a query but no handle, `explain` when it already knows a file/symbol/SQL object/handle, and `inspect` for a human-readable architecture summary. +Use `explore` when the agent has a broad question and needs search anchors, packets, paths, blast radius, candidate tests, and follow-ups in one bounded response. Use `search` when it only needs anchors, `explain` when it already knows a file/symbol/SQL object/handle, and `inspect` for a human-readable architecture summary. Use `artifact build` for durable handoff directories and `mcp serve` when repeated follow-up calls should share one warm repo session. Choose output by the next consumer: @@ -42,6 +43,18 @@ For durable repo-local scan scope, add `codegraph.config.json` at the project ro For raw command flags and output contracts, see [docs/cli.md](./cli.md). For library types and wrappers, see [docs/library-api.md](./library-api.md). +## Explore facade + +Start with `explore` when an agent can ask a concrete repo question: + +```bash +codegraph explore "how does auth reach db?" --root . --pretty +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. + ## 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 d5d95387..df74afa4 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -14,7 +14,8 @@ Default workflow: - code review: `codegraph review --base HEAD --head WORKTREE --summary` - blast-radius follow-up: `codegraph impact --base HEAD --head WORKTREE --pretty` -- unfamiliar repo: `codegraph orient --root . --budget small --pretty` +- unfamiliar repo: `codegraph explore "how does auth reach db?" --root . --pretty` +- first-turn map: `codegraph orient --root . --budget small --pretty` - targeted follow-up: `codegraph search "" --json` then `codegraph explain ` ## Runtime selection @@ -128,6 +129,8 @@ codegraph index --workers --threads 8 --cache disk # Search for agent-ready anchors across symbols, paths, chunks, SQL objects, and graph context codegraph orient --root . --budget small --pretty codegraph orient --root . ./src --budget medium --json +codegraph explore "how does auth reach db?" --root . --pretty +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 @@ -240,6 +243,7 @@ Short JSON shape: #### Agent orientation and packets +- Use `explore --pretty` for a one-call repo question that combines search anchors, bounded packets, dependency paths, reverse dependencies, candidate tests, limits, omissions, and follow-ups. Use `--limit`, `--max-packets`, `--max-paths`, or `--no-source` to keep output small. - Use `orient --pretty` as the compact first-turn reading surface for people or models; it prints the ranked `focus` targets and their follow-up commands before the scope sketch. - Use `orient --json` when follow-up tools need exact focus reasons, limits, and omitted counts. Orient suppresses index rebuild warnings so stdout stays parseable. - Small orientation budgets default to `--health skip`. Medium and large default to `--health summary`, which counts cycles and unresolved imports while omitting duplicate health; use `--health full` when exhaustive duplicate counts matter. @@ -248,6 +252,8 @@ Short JSON shape: `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, candidate tests, follow-ups, flat limits, and omission counts. + For SQL, prefer handles or schema-qualified names when basenames may be ambiguous. Reference and snippet omission counts are lower bounds after bounded navigation reaches its cap. #### Artifact bundles @@ -260,7 +266,7 @@ For SQL, prefer handles or schema-qualified names when basenames may be ambiguou #### MCP server -- `mcp serve` exposes navigation, search, impact, review, SQLite query, session refresh, and artifact-build tools. +- `mcp serve` exposes explore, navigation, search, impact, review, SQLite query, session refresh, and artifact-build tools. - MCP uses stdio by default or Streamable HTTP with `--port `. - Startup is lazy by default; `--warmup` builds the base session cache before serving requests, and `--warmup-symbols` also builds the detailed symbol graph. - Index-backed responses include `freshness`; small file changes auto-refresh, while stale responses include a reason, total changed-file count, and a bounded changed-file sample. diff --git a/docs/library-api.md b/docs/library-api.md index 3feacdde..bc92ef03 100644 --- a/docs/library-api.md +++ b/docs/library-api.md @@ -115,7 +115,7 @@ Small orientation budgets default to `health: "skip"` and set health fields to ` `searchCodegraph()` builds a project snapshot and returns deterministic, agent-ready anchors across files, symbols, chunks, SQL objects, and optional graph neighborhoods. Hybrid search is code-first by default, so implementation files and symbols outrank docs unless `mode: "text"` is explicit or docs are the strongest remaining evidence. Identifier-like queries stay symbol-first. Pure `path` and `text` searches skip detailed symbol graph construction; hybrid, symbol, SQL, and graph searches keep symbol-aware ranking and neighbors. Handles are project-relative and explainable; result packets include top-level `analysis`, per-result `provenance`, `resultCount`, `totalCandidates`, `limits`, and `omittedCounts`. ```ts -import { buildCodegraphArtifact, explainCodegraphTarget, searchCodegraph } from "@lzehrung/codegraph"; +import { buildCodegraphArtifact, explainCodegraphTarget, exploreCodegraph, searchCodegraph } from "@lzehrung/codegraph"; const response = await searchCodegraph({ root: process.cwd(), @@ -126,8 +126,20 @@ const response = await searchCodegraph({ const first = response.results[0]; console.log(first?.handle, first?.rankReasons, first?.omittedCounts, first?.followUps); + +const explored = await exploreCodegraph({ + root: process.cwd(), + query: "how does auth reach db?", + limit: 5, + maxPackets: 3, + maxPaths: 3, +}); + +console.log(explored.summary, explored.paths, explored.followUps); ``` +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, candidate tests, follow-ups, flat limits, and omission counts. + 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. `explainCodegraphTarget()` resolves a file path, symbol name, SQL object name, or search handle into a bounded packet for follow-up agent work. Explanations include the same top-level `analysis` label as search so reduced or mixed runs stay visible. SQL object names resolve by exact name first; unqualified basenames resolve only when unique. File and symbol explanations also include bounded medium-or-higher duplicate context that touches the target, with stable handles and conservative repair hints. SQL related objects include a `relation` such as `incoming:reads_from`, `outgoing:writes_to`, or `same_file`. With changed context enabled, the packet includes compact review tasks and candidate tests: @@ -162,7 +174,7 @@ console.log(artifact.manifestPath, artifact.artifacts); The `graph.json` artifact is self-describing (`schemaVersion: 1`, `format: "codegraph.graph-json"`) and uses project-relative file paths and portable symbol handles. `questions.json` uses the same stable handles for follow-up commands. With `force: true`, stale known Codegraph artifact files are removed before the selected outputs are written; unrelated files in the directory are preserved. -`createAgentSession()` keeps one in-process project snapshot warm for repeated orient, search, explain, packet, artifact, and MCP calls. It uses incremental indexing with disk cache by default, auto-enables native workers for large cold builds, and carries forward top-level analysis metadata from the build report. +`createAgentSession()` keeps one in-process project snapshot warm for repeated explore, orient, search, explain, packet, artifact, and MCP calls. It uses incremental indexing with disk cache by default, auto-enables native workers for large cold builds, and carries forward top-level analysis metadata from the build report. Session callers can use `freshness: { policy: "check" | "auto" | "manual" }` plus `checkFreshness()` to detect file edits before reusing a warm snapshot. `check` reports stale state without invalidating, `auto` invalidates for bounded changes, and stale results include `changedFileCount`, `omittedChangedFileCount`, `reason`, and a bounded changed-file sample. Set `buildOptions.useNativeWorkers` to `false` to opt out. Use `buildCodegraphArtifactWithSession()` when a host already has a session and wants SQLite, graph JSON, report, questions, and manifest outputs from the same snapshot. `createCodegraphMcpHandlers()` exposes the same primitives without starting stdio, which is useful for tests or host applications: diff --git a/docs/mcp.md b/docs/mcp.md index f2ef908b..c78bf193 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2,7 +2,7 @@ Codegraph can run as a Model Context Protocol server so tool-capable agents can query repo structure without spawning a new CLI process for every follow-up. -Use MCP when an agent will make repeated navigation, search, packet, review, or artifact queries. Use normal CLI commands for one-off local inspection or when your agent runtime does not expose MCP tools. +Use MCP when an agent will make repeated explore, navigation, search, packet, review, or artifact queries. Use normal CLI commands for one-off local inspection or when your agent runtime does not expose MCP tools. ## Start the server @@ -35,6 +35,7 @@ Use stdio for a client-owned subprocess. Use HTTP for one long-running Codegraph The server exposes the same bounded primitives as the CLI and library session layer: +- `explore`: recommended first tool for broad repo questions; returns bounded anchors, packets, paths, blast radius, candidate tests, and follow-ups. - `orient`: compact first-turn repo context. - `packet_get`: bounded evidence packet by file path, symbol name, SQL object name, or stable target. - `search`: deterministic ranked search across paths, symbols, chunks, SQL objects, and graph context. @@ -193,9 +194,9 @@ OpenCode uses the `mcp` object in `opencode.json`: When Codegraph MCP tools are available to an agent: -1. Start with `orient`. -2. Use `search` to find anchors. -3. Use `packet_get`, `refs`, `goto`, `deps`, `rdeps`, or `path` for focused follow-up. +1. Start with `explore` for a broad question. +2. Use `orient` when you need a compact first-turn map rather than a question answer. +3. Use `search` to find anchors and `packet_get`, `refs`, `goto`, `deps`, `rdeps`, or `path` for focused follow-up. 4. Check `freshness` on MCP responses after edits; `refreshed` means the answer used an updated snapshot, and `stale` includes a reason plus a bounded changed-file sample. 5. Use `impact` and `review` for git-range risk analysis. 6. Use `query_sqlite` only for read-only artifact inspection; rebuild the artifact when it reports stale state. diff --git a/src/agent.ts b/src/agent.ts index 06e72765..50663ba8 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -7,6 +7,14 @@ export type { AgentSessionFreshnessOptions, AgentSessionOptions, } from "./agent/session.js"; +export { exploreCodegraph, exploreCodegraphWithSession, formatAgentExploreResponse } from "./agent/explore.js"; +export type { + AgentExploreBlastRadiusSummary, + AgentExploreDependencyPathSummary, + AgentExplorePacketSummary, + AgentExploreRequest, + AgentExploreResponse, +} from "./agent/explore.js"; export { orientCodegraph } from "./agent/orient.js"; export type { AgentModuleSummary, diff --git a/src/agent/explore.ts b/src/agent/explore.ts new file mode 100644 index 00000000..ad536d65 --- /dev/null +++ b/src/agent/explore.ts @@ -0,0 +1,486 @@ +import path from "node:path"; +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 { getCodegraphPacketWithSession, type AgentPacketResponse } from "./packet.js"; +import { searchCodegraphWithSession, type AgentSearchResponse, type AgentSearchResult } from "./search.js"; +import { createAgentSession, type AgentProjectSnapshot, type AgentSession } from "./session.js"; +import { quoteShellArg } from "./shell.js"; + +export type AgentExploreRequest = { + root: string; + query: string; + buildOptions?: BuildOptions; + limit?: number; + maxPackets?: number; + maxPaths?: number; + includeSource?: boolean; +}; + +export type AgentExplorePacketSummary = AgentPacketResponse; + +export type AgentExploreDependencyPathSummary = { + from: string; + to: string; + path: string[]; +}; + +export type AgentExploreBlastRadiusSummary = { + file: string; + reverseDependencies: Array<{ file: string; depth: number }>; + omittedCount: number; +}; + +export type AgentExploreResponse = { + schemaVersion: 1; + query: string; + analysis: AnalysisSummary; + summary: string[]; + anchors: AgentSearchResult[]; + packets: AgentExplorePacketSummary[]; + paths: AgentExploreDependencyPathSummary[]; + blastRadius: AgentExploreBlastRadiusSummary[]; + candidateTests: string[]; + followUps: string[]; + limits: Record; + omittedCounts: Record; +}; + +const DEFAULT_ANCHOR_LIMIT = 5; +const DEFAULT_MAX_PACKETS = 3; +const DEFAULT_MAX_PATHS = 3; +const DEFAULT_MAX_REVERSE_DEPENDENCIES = 20; +const DEFAULT_MAX_CANDIDATE_TESTS = 10; +const MAX_ANCHOR_LIMIT = 50; +const MAX_PACKET_LIMIT = 10; +const MAX_PATH_LIMIT = 10; + +export async function exploreCodegraph(request: AgentExploreRequest): Promise { + const session = createAgentSession({ + root: request.root, + ...(request.buildOptions ? { buildOptions: request.buildOptions } : {}), + }); + return await exploreCodegraphWithSession(session, request); +} + +export async function exploreCodegraphWithSession( + session: AgentSession, + request: AgentExploreRequest, +): Promise { + const anchorLimit = boundedPositiveInteger(request.limit, DEFAULT_ANCHOR_LIMIT, MAX_ANCHOR_LIMIT); + const maxPackets = boundedPositiveInteger(request.maxPackets, DEFAULT_MAX_PACKETS, MAX_PACKET_LIMIT); + const maxPaths = boundedPositiveInteger(request.maxPaths, DEFAULT_MAX_PATHS, MAX_PATH_LIMIT); + const includeSource = request.includeSource ?? true; + const search = await searchCodegraphWithSession(session, { + root: request.root, + query: request.query, + limit: anchorLimit, + includeSnippets: includeSource, + }); + const snapshot = await session.loadProject({ symbolGraph: "skip" }); + const anchors = search.results.slice(0, anchorLimit); + const anchorFiles = collectAnchorFiles(snapshot, request.query, anchors); + const packetTargets = includeSource ? collectPacketTargets(anchors, maxPackets) : []; + const packets = await collectPackets(session, request.root, packetTargets); + const paths = collectDependencyPaths(snapshot, request.query, anchorFiles, maxPaths); + const blastRadius = collectBlastRadius(snapshot, anchorFiles, DEFAULT_MAX_REVERSE_DEPENDENCIES); + const candidateTests = collectCandidateTests(snapshot, anchorFiles, DEFAULT_MAX_CANDIDATE_TESTS); + const followUps = collectFollowUps(request.root, request.query, anchors, packets, anchorFiles, includeSource); + + return { + schemaVersion: 1, + query: request.query, + analysis: search.analysis, + summary: buildSummary(search, packets, paths, blastRadius, candidateTests), + anchors, + packets, + paths, + blastRadius, + candidateTests, + followUps, + limits: { + anchors: anchorLimit, + packets: maxPackets, + paths: maxPaths, + reverseDependencies: DEFAULT_MAX_REVERSE_DEPENDENCIES, + candidateTests: DEFAULT_MAX_CANDIDATE_TESTS, + }, + omittedCounts: { + anchors: search.omittedCounts.results, + packets: Math.max(0, collectPacketTargets(anchors, Number.POSITIVE_INFINITY).length - packetTargets.length), + paths: countOmittedPaths(snapshot, request.query, anchorFiles, maxPaths), + blastRadius: blastRadius.reduce((sum, entry) => sum + entry.omittedCount, 0), + candidateTests: Math.max(0, countCandidateTests(snapshot, anchorFiles) - candidateTests.length), + }, + }; +} + +export function formatAgentExploreResponse(response: AgentExploreResponse): string { + const lines: string[] = ["Summary"]; + if (response.summary.length) { + lines.push(...response.summary.map((entry) => `- ${entry}`)); + } else { + lines.push("- No summary available."); + } + + lines.push("", "Anchors"); + if (response.anchors.length) { + for (const anchor of response.anchors) { + lines.push(`- ${anchor.label} [${anchor.kind}] ${anchor.file}`); + } + } else { + lines.push("- No anchors found."); + } + + lines.push("", "Relevant source"); + if (response.packets.length) { + for (const packet of response.packets) { + lines.push(`- ${packet.target} [${packet.kind}]`); + for (const summary of packetSummaryLines(packet).slice(0, 3)) { + lines.push(` - ${summary}`); + } + } + } else { + lines.push("- Not included."); + } + + lines.push("", "Paths"); + if (response.paths.length) { + for (const entry of response.paths) { + lines.push(`- ${entry.from} -> ${entry.to}: ${entry.path.join(" -> ")}`); + } + } else { + lines.push("- No dependency paths found."); + } + + lines.push("", "Blast radius"); + if (response.blastRadius.length) { + for (const entry of response.blastRadius) { + const files = entry.reverseDependencies.map((dependency) => dependency.file).join(", "); + const suffix = entry.omittedCount ? `, ${entry.omittedCount} omitted` : ""; + lines.push(`- ${entry.file}: ${files || "no reverse dependencies"}${suffix}`); + } + } else { + lines.push("- No reverse dependencies found."); + } + + lines.push("", "Candidate tests"); + if (response.candidateTests.length) { + lines.push(...response.candidateTests.map((file) => `- ${file}`)); + } else { + lines.push("- None detected."); + } + + lines.push("", "Follow-ups"); + lines.push(...response.followUps.map((entry) => `- ${entry}`)); + + lines.push("", "Limits"); + for (const [name, value] of Object.entries(response.limits)) { + lines.push(`- ${name}: ${value}`); + } + return lines.join("\n"); +} + +function packetSummaryLines(packet: AgentPacketResponse): string[] { + const payload = packet.packet; + if (!("summary" in payload)) return []; + return Array.isArray(payload.summary) ? payload.summary : []; +} + +function boundedPositiveInteger(value: number | undefined, fallback: number, max: number): number { + if (value === undefined) return fallback; + if (!Number.isFinite(value)) return fallback; + return Math.min(max, Math.max(0, Math.floor(value))); +} + +function collectPackets( + session: AgentSession, + root: string, + targets: readonly string[], +): Promise { + return Promise.all( + targets.map( + async (target) => + await getCodegraphPacketWithSession(session, { + root, + target, + }), + ), + ); +} + +function collectPacketTargets(anchors: readonly AgentSearchResult[], limit: number): string[] { + const targets: string[] = []; + const seen = new Set(); + for (const anchor of anchors) { + const target = anchor.kind === "file" ? anchor.file : anchor.handle; + if (seen.has(target)) continue; + seen.add(target); + targets.push(target); + if (targets.length >= limit) break; + } + return targets; +} + +function collectAnchorFiles( + snapshot: AgentProjectSnapshot, + query: string, + anchors: readonly AgentSearchResult[], +): string[] { + const files = new Set(); + for (const file of extractFileMentions(snapshot, query)) { + files.add(file); + } + for (const anchor of anchors) { + const absolute = path.resolve(snapshot.root, anchor.file); + if (snapshot.fileGraph.nodes.has(absolute)) { + files.add(absolute); + } + } + return [...files]; +} + +function extractFileMentions(snapshot: AgentProjectSnapshot, query: string): string[] { + const normalizedQuery = normalizeQueryPathText(query); + const explicitFiles: string[] = []; + const basenameMatches = new Map(); + for (const file of snapshot.fileGraph.nodes) { + const relative = toProjectDisplayPath(snapshot.root, file); + const normalizedRelative = normalizeQueryPathText(relative); + if (normalizedQuery.includes(normalizedRelative)) { + explicitFiles.push(file); + continue; + } + const basename = path.basename(relative); + const bucket = basenameMatches.get(basename) ?? []; + bucket.push(file); + basenameMatches.set(basename, bucket); + } + + for (const token of tokenizeQuery(query)) { + const matches = basenameMatches.get(token); + if (matches?.length === 1) { + explicitFiles.push(matches[0]!); + } + } + return uniqueFiles(explicitFiles); +} + +function normalizeQueryPathText(input: string): string { + return input.replace(/\\/g, "/").replace(/^\.\//, "").toLowerCase(); +} + +function tokenizeQuery(query: string): string[] { + return query + .split(/\s+/) + .map((token) => token.replace(/^["'`([{]+|["'`\])},.:;]+$/g, "")) + .filter((token) => token.length > 0); +} + +function uniqueFiles(files: readonly string[]): string[] { + const seen = new Set(); + const out: string[] = []; + for (const file of files) { + if (seen.has(file)) continue; + seen.add(file); + out.push(file); + } + return out; +} + +function collectDependencyPaths( + snapshot: AgentProjectSnapshot, + query: string, + anchorFiles: readonly string[], + maxPaths: number, +): AgentExploreDependencyPathSummary[] { + if (!shouldCollectPaths(query, anchorFiles)) return []; + const paths: AgentExploreDependencyPathSummary[] = []; + for (let fromIndex = 0; fromIndex < anchorFiles.length; fromIndex += 1) { + for (let toIndex = 0; toIndex < anchorFiles.length; toIndex += 1) { + if (fromIndex === toIndex) continue; + const from = anchorFiles[fromIndex]!; + const to = anchorFiles[toIndex]!; + const pathResult = getShortestPath(snapshot.fileGraph, from, to); + if (!pathResult || pathResult.length < 2) continue; + paths.push({ + from: toProjectDisplayPath(snapshot.root, from), + to: toProjectDisplayPath(snapshot.root, to), + path: pathResult.map((file) => toProjectDisplayPath(snapshot.root, file)), + }); + if (paths.length >= maxPaths) return paths; + } + } + return paths; +} + +function countOmittedPaths( + snapshot: AgentProjectSnapshot, + query: string, + anchorFiles: readonly string[], + maxPaths: number, +): number { + if (!shouldCollectPaths(query, anchorFiles)) return 0; + let count = 0; + for (let fromIndex = 0; fromIndex < anchorFiles.length; fromIndex += 1) { + for (let toIndex = 0; toIndex < anchorFiles.length; toIndex += 1) { + if (fromIndex === toIndex) continue; + const pathResult = getShortestPath(snapshot.fileGraph, anchorFiles[fromIndex]!, anchorFiles[toIndex]!); + if (pathResult && pathResult.length > 1) { + count += 1; + } + } + } + return Math.max(0, count - maxPaths); +} + +function shouldCollectPaths(query: string, anchorFiles: readonly string[]): boolean { + if (anchorFiles.length < 2) return false; + const normalized = query.toLowerCase(); + return /\b(reach|flow|call|through|path|depend|from|to)\b/.test(normalized); +} + +function collectBlastRadius( + snapshot: AgentProjectSnapshot, + anchorFiles: readonly string[], + limit: number, +): AgentExploreBlastRadiusSummary[] { + const summaries: AgentExploreBlastRadiusSummary[] = []; + for (const file of anchorFiles.slice(0, DEFAULT_ANCHOR_LIMIT)) { + const dependencies = getReverseDependencies(snapshot.fileGraph, file, { limit: limit + 1, depth: 2 }); + const visible = dependencies.slice(0, limit).map((dependency) => formatDependency(snapshot, dependency)); + summaries.push({ + file: toProjectDisplayPath(snapshot.root, file), + reverseDependencies: visible, + omittedCount: Math.max(0, dependencies.length - limit), + }); + } + return summaries; +} + +function formatDependency(snapshot: AgentProjectSnapshot, dependency: DependencyNode): { file: string; depth: number } { + return { + file: toProjectDisplayPath(snapshot.root, dependency.file), + depth: dependency.depth, + }; +} + +function collectCandidateTests( + snapshot: AgentProjectSnapshot, + anchorFiles: readonly string[], + limit: number, +): string[] { + return candidateTestsForAnchors(snapshot, anchorFiles).slice(0, limit); +} + +function countCandidateTests(snapshot: AgentProjectSnapshot, anchorFiles: readonly string[]): number { + return candidateTestsForAnchors(snapshot, anchorFiles).length; +} + +function candidateTestsForAnchors(snapshot: AgentProjectSnapshot, anchorFiles: readonly string[]): string[] { + const candidateNames = new Set(); + for (const file of anchorFiles) { + candidateNames.add(normalizeStem(path.basename(file))); + for (const dependency of getReverseDependencies(snapshot.fileGraph, file, { + depth: 2, + limit: DEFAULT_MAX_REVERSE_DEPENDENCIES, + })) { + candidateNames.add(normalizeStem(path.basename(dependency.file))); + } + } + const candidateNameList = [...candidateNames]; + const tests: string[] = []; + for (const file of snapshot.fileGraph.nodes) { + const relative = toProjectDisplayPath(snapshot.root, file); + if (!looksLikeTestFile(relative)) continue; + const testStem = normalizeStem(path.basename(relative)); + if (!candidateNameList.length || candidateNameList.some((candidateName) => testStem.includes(candidateName))) { + tests.push(relative); + } + } + return tests.sort(); +} + +function looksLikeTestFile(file: string): boolean { + const normalized = file.toLowerCase(); + return /(^|[/.])(test|tests|__tests__)([/.]|$)/.test(normalized) || /\.(test|spec)\.[^.]+$/.test(normalized); +} + +function normalizeStem(name: string): string { + return name + .replace(/\.(test|spec)\.[^.]+$/i, "") + .replace(/\.[^.]+$/, "") + .toLowerCase(); +} + +function collectFollowUps( + root: string, + query: string, + anchors: readonly AgentSearchResult[], + packets: readonly AgentPacketResponse[], + anchorFiles: readonly string[], + includeSource: boolean, +): string[] { + const followUps: string[] = []; + for (const file of anchorFiles.slice(0, 3)) { + const relative = toProjectDisplayPath(root, file); + followUps.push(`codegraph packet get ${quoteShellArg(relative)} --pretty`); + } + for (const anchor of anchors) { + followUps.push(...anchor.followUps); + } + for (const packet of packets) { + followUps.push(...packet.followUps); + } + for (const file of anchorFiles.slice(0, 3)) { + const relative = toProjectDisplayPath(root, file); + followUps.push(`codegraph refs --file ${quoteShellArg(relative)} --line 1 --col 0`); + } + if (!includeSource) { + followUps.push(`codegraph explore ${quoteShellArg(query)} --root ${quoteShellArg(root)} --pretty`); + } + if (!anchors.length) { + followUps.push(`codegraph search ${quoteShellArg(query)} --root ${quoteShellArg(root)} --json`); + followUps.push(`codegraph orient --root ${quoteShellArg(root)} --budget small --pretty`); + } + return dedupeStrings(followUps).slice(0, 12); +} + +function dedupeStrings(values: readonly string[]): string[] { + const seen = new Set(); + const out: string[] = []; + for (const value of values) { + if (seen.has(value)) continue; + seen.add(value); + out.push(value); + } + return out; +} + +function buildSummary( + search: AgentSearchResponse, + packets: readonly AgentPacketResponse[], + paths: readonly AgentExploreDependencyPathSummary[], + blastRadius: readonly AgentExploreBlastRadiusSummary[], + candidateTests: readonly string[], +): 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 (packets.length) { + summary.push(`Included ${packets.length} bounded source packet(s).`); + } + if (paths.length) { + summary.push(`Found ${paths.length} dependency path(s).`); + } + if (blastRadius.length) { + const reverseCount = blastRadius.reduce((sum, entry) => sum + entry.reverseDependencies.length, 0); + summary.push(`Found ${reverseCount} reverse dependenc${reverseCount === 1 ? "y" : "ies"} across primary anchors.`); + } + if (candidateTests.length) { + summary.push(`Detected ${candidateTests.length} candidate test file(s).`); + } + return summary; +} diff --git a/src/cli.ts b/src/cli.ts index f26b6812..f4bf3d1a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -31,6 +31,7 @@ import { buildDoctorReport } from "./cli/doctor.js"; 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 { handleGraphDeltaCommand } from "./cli/graphDelta.js"; import { handleGraphQueryCommand } from "./cli/graphQueries.js"; import { handleGrepCommand } from "./cli/grep.js"; @@ -522,6 +523,21 @@ async function runCliWithActiveRuntime(rawArgs: string[]) { return await resolveFilesFromRoots(); }; + if (cmd === "explore") { + await handleExploreCommand({ + positionals: parsed.positionals, + root: projectRootFs, + buildOptions: buildAgentOptions(), + getOpt, + hasFlag, + writeJSONLine, + writeStdoutLine, + writeStderrLine, + exit: exitCli, + }); + return; + } + if (cmd === "search") { await handleSearchCommand({ positionals: parsed.positionals, diff --git a/src/cli/explore.ts b/src/cli/explore.ts new file mode 100644 index 00000000..31cf732c --- /dev/null +++ b/src/cli/explore.ts @@ -0,0 +1,30 @@ +import { exploreCodegraph, formatAgentExploreResponse } from "../agent/explore.js"; +import type { CliAgentCommandContext } from "./context.js"; +import { EXPLORE_HELP_TEXT } from "./help.js"; +import { parsePositiveIntegerOption } from "./options.js"; + +export type ExploreCommandContext = CliAgentCommandContext; + +export async function handleExploreCommand(context: ExploreCommandContext): Promise { + const query = context.positionals.join(" ").trim(); + if (!query) { + context.writeStderrLine(EXPLORE_HELP_TEXT.trimEnd()); + context.exit(2); + } + + const response = await exploreCodegraph({ + root: context.root, + query, + ...(context.buildOptions ? { buildOptions: context.buildOptions } : {}), + limit: parsePositiveIntegerOption(context.getOpt("--limit"), "--limit", 5), + maxPackets: parsePositiveIntegerOption(context.getOpt("--max-packets"), "--max-packets", 3), + maxPaths: parsePositiveIntegerOption(context.getOpt("--max-paths"), "--max-paths", 3), + includeSource: !context.hasFlag("--no-source"), + }); + + if (context.hasFlag("--json") || !context.hasFlag("--pretty")) { + context.writeJSONLine(response); + } else { + context.writeStdoutLine(formatAgentExploreResponse(response)); + } +} diff --git a/src/cli/help.ts b/src/cli/help.ts index 6d9297a8..1380c03c 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -4,6 +4,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 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 @@ -72,11 +73,13 @@ Recommended review commands: Unfamiliar repo: codegraph orient --root . --budget small --pretty + codegraph explore "how does auth reach db?" --root . --pretty Examples: codegraph review --base HEAD --head WORKTREE --summary codegraph orient ./src --budget small --pretty codegraph search "auth user" --json + codegraph explore "how does auth reach db?" --pretty codegraph explain src/auth.ts --json codegraph impact --provider git --base HEAD --head WORKTREE codegraph packet get file:src%2Fcli.ts --json @@ -113,6 +116,7 @@ const knownCliCommands = new Set([ "duplicates", "dumpmod", "explain", + "explore", "goto", "graph", "graph-delta", @@ -139,6 +143,18 @@ export function isKnownCliCommand(command: string): boolean { return knownCliCommands.has(command); } +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] + +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. + +Index options: + Supports shared --cache, --cache-strict, --cache-verify, --threads, --native, --workers, --include-glob, --ignore-glob, and --no-gitignore options. +`; + 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] diff --git a/src/cli/options.ts b/src/cli/options.ts index 5734c582..8730c706 100644 --- a/src/cli/options.ts +++ b/src/cli/options.ts @@ -77,6 +77,8 @@ const CLI_VALUE_OPTIONS = new Set([ "--max-snippets", "--max-symbols", "--max-duplicates", + "--max-packets", + "--max-paths", "--artifact", "--host", "--port", @@ -277,6 +279,14 @@ const CLI_COMMAND_SCHEMAS = new Map([ { kind: "any" }, ), ], + [ + "explore", + commandSchema( + [...SHARED_BUILD_FLAGS, ...JSON_OUTPUT_FLAGS, "--no-source"], + [...SHARED_BUILD_OPTIONS, "--limit", "--max-packets", "--max-paths"], + { kind: "any" }, + ), + ], [ "goto", commandSchema([...SHARED_BUILD_FLAGS, ...JSON_OUTPUT_FLAGS], SHARED_BUILD_OPTIONS, { diff --git a/src/index.ts b/src/index.ts index a147ea94..8383fd60 100644 --- a/src/index.ts +++ b/src/index.ts @@ -235,6 +235,16 @@ export type { AgentSessionOptions, } from "./agent/session.js"; +/** Agent one-call exploration facade over search, packets, paths, and blast radius. */ +export { exploreCodegraph, exploreCodegraphWithSession, formatAgentExploreResponse } from "./agent/explore.js"; +export type { + AgentExploreBlastRadiusSummary, + AgentExploreDependencyPathSummary, + AgentExplorePacketSummary, + AgentExploreRequest, + AgentExploreResponse, +} from "./agent/explore.js"; + /** Agent first-turn orientation packets with file-path follow-up targets. */ export { orientCodegraph } from "./agent/orient.js"; export type { diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 4612a149..d4de0a1d 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -17,6 +17,7 @@ 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 { 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"; import { searchCodegraphWithSession } from "../agent/search.js"; @@ -109,6 +110,13 @@ export type CodegraphMcpHandlers = { depth?: number | undefined; limit?: number | undefined; }) => Promise>; + explore: (request: { + query: string; + limit?: number | undefined; + maxPackets?: number | undefined; + maxPaths?: number | undefined; + includeSource?: boolean | undefined; + }) => Promise>; orient: (request: { includeRoots?: string[] | undefined; budget?: AgentOrientBudget | undefined; @@ -455,6 +463,19 @@ function createCodegraphMcpHandlersForSession( }), ), + explore: async (request) => + await withFreshness( + async () => + await exploreCodegraphWithSession(session, { + root, + query: request.query, + ...(request.limit !== undefined ? { limit: request.limit } : {}), + ...(request.maxPackets !== undefined ? { maxPackets: request.maxPackets } : {}), + ...(request.maxPaths !== undefined ? { maxPaths: request.maxPaths } : {}), + ...(request.includeSource !== undefined ? { includeSource: request.includeSource } : {}), + }), + ), + orient: async (request) => await withFreshness( async () => @@ -875,6 +896,8 @@ async function callMcpTool(handlers: CodegraphMcpHandlers, name: string, input: switch (name) { case "search": return await handlers.search(searchSchema.parse(input)); + case "explore": + return await handlers.explore(exploreSchema.parse(input)); case "orient": return await handlers.orient(orientSchema.parse(input)); case "packet_get": @@ -949,6 +972,14 @@ const searchSchema = z.object({ limit: z.number().int().nonnegative().optional(), }); +const exploreSchema = z.object({ + query: z.string(), + limit: z.number().int().nonnegative().optional(), + maxPackets: z.number().int().nonnegative().optional(), + maxPaths: z.number().int().nonnegative().optional(), + includeSource: z.boolean().optional(), +}); + const orientSchema = z.object({ includeRoots: z.array(z.string()).optional(), budget: z.enum(["small", "medium", "large"]).optional(), diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 9a05d28e..63b6d986 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -46,6 +46,21 @@ export const MCP_TOOLS: Tool[] = [ ["query"], ), }, + { + name: "explore", + description: + "Recommended first tool for broad repo questions; returns bounded anchors, source packets, paths, blast radius, tests, and follow-ups.", + inputSchema: objectSchema( + { + query: stringProperty, + limit: { type: "integer", minimum: 0, maximum: 50, default: 5 }, + maxPackets: { type: "integer", minimum: 0, maximum: 10, default: 3 }, + maxPaths: { type: "integer", minimum: 0, maximum: 10, default: 3 }, + includeSource: { type: "boolean", default: true }, + }, + ["query"], + ), + }, { name: "orient", description: "Build a compact first-turn packet for agent repo context.", diff --git a/tests/agent-explore.test.ts b/tests/agent-explore.test.ts new file mode 100644 index 00000000..d8005c76 --- /dev/null +++ b/tests/agent-explore.test.ts @@ -0,0 +1,236 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { exploreCodegraph } from "../src/index.js"; +import { createCodegraphMcpHandlers, listCodegraphMcpTools } from "../src/mcp/server.js"; +import { captureCli } from "./helpers/cli.js"; +import { mkTmpDir } from "./helpers/filesystem.js"; + +type JsonRecord = Record; + +async function writeFile(root: string, relativePath: string, content: string): Promise { + const filePath = path.join(root, relativePath); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, content, "utf8"); +} + +async function mkExploreRepo(): Promise { + const root = await mkTmpDir("cg-agent-explore-"); + await writeFile( + root, + "src/db.ts", + [ + "export type UserRecord = { id: string; active: boolean };", + "", + "export function readUser(userId: string): UserRecord {", + " return { id: userId, active: userId.length > 0 };", + "}", + "", + ].join("\n"), + ); + await writeFile( + root, + "src/auth.ts", + [ + "import { readUser } from './db';", + "", + "export function validateUser(userId: string) {", + " const user = readUser(userId);", + " return user.active;", + "}", + "", + ].join("\n"), + ); + await writeFile( + root, + "src/routes.ts", + [ + "import { validateUser } from './auth';", + "", + "export function handleRequest(userId: string) {", + " return validateUser(userId) ? 'ok' : 'denied';", + "}", + "", + ].join("\n"), + ); + await writeFile( + root, + "tests/routes.test.ts", + ["import { handleRequest } from '../src/routes';", "", "handleRequest('alice');", ""].join("\n"), + ); + return root; +} + +function readRecord(value: unknown, label: string): JsonRecord { + expect(value, label).toBeTypeOf("object"); + expect(value, label).not.toBeNull(); + return value as JsonRecord; +} + +function readArray(value: unknown, label: string): unknown[] { + expect(Array.isArray(value), label).toBeTruthy(); + return value as unknown[]; +} + +function textOf(value: unknown): string { + return JSON.stringify(value); +} + +function expectExploreEnvelope(response: unknown, query: string): JsonRecord { + const record = readRecord(response, "explore response"); + expect(record.schemaVersion).toBe(1); + expect(record.query).toBe(query); + expect(record.analysis).toBeTypeOf("object"); + expect(Array.isArray(record.summary)).toBeTruthy(); + expect(Array.isArray(record.anchors)).toBeTruthy(); + expect(Array.isArray(record.packets)).toBeTruthy(); + expect(Array.isArray(record.paths)).toBeTruthy(); + expect(Array.isArray(record.blastRadius)).toBeTruthy(); + expect(Array.isArray(record.candidateTests)).toBeTruthy(); + expect(Array.isArray(record.followUps)).toBeTruthy(); + const limits = readRecord(record.limits, "limits"); + expect(limits.anchors).toBeTypeOf("number"); + expect(limits.packets).toBeTypeOf("number"); + expect(limits.paths).toBeTypeOf("number"); + expect(limits.reverseDependencies).toBeTypeOf("number"); + expect(limits.candidateTests).toBeTypeOf("number"); + expect(record.omittedCounts).toBeTypeOf("object"); + return record; +} + +describe("agent explore", () => { + it("returns a file packet and reverse dependency blast radius for a file-path query", async () => { + const root = await mkExploreRepo(); + const query = "src/auth.ts"; + + const response = expectExploreEnvelope(await exploreCodegraph({ root, query }), query); + const packets = readArray(response.packets, "packets"); + const blastRadius = readArray(response.blastRadius, "blastRadius"); + + 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); + }); + + it("returns symbol anchors and evidence packets for a symbol query", async () => { + const root = await mkExploreRepo(); + const query = "validateUser"; + + const response = expectExploreEnvelope(await exploreCodegraph({ root, query }), query); + const anchors = readArray(response.anchors, "anchors"); + const packets = readArray(response.packets, "packets"); + + 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(); + }); + + it("includes the dependency path for a flow-style query between connected files", async () => { + const root = await mkExploreRepo(); + const query = "flow src/routes.ts to src/db.ts"; + + const response = expectExploreEnvelope(await exploreCodegraph({ root, query }), query); + const paths = readArray(response.paths, "paths"); + const pathText = textOf(paths); + + expect(pathText).toContain("src/routes.ts"); + expect(pathText).toContain("src/auth.ts"); + expect(pathText).toContain("src/db.ts"); + }); + + it("returns follow-ups instead of throwing when a query has no graph matches", async () => { + const root = await mkExploreRepo(); + const query = "definitelyMissingPaymentWebhook"; + + const response = expectExploreEnvelope(await exploreCodegraph({ root, query }), query); + const anchors = readArray(response.anchors, "anchors"); + const packets = readArray(response.packets, "packets"); + const followUps = readArray(response.followUps, "followUps"); + + expect(anchors).toHaveLength(0); + expect(packets).toHaveLength(0); + expect(followUps.length).toBeGreaterThan(0); + expect(textOf(followUps)).toContain("definitelyMissingPaymentWebhook"); + }); + + it("applies per-section limits and reports omitted counts", async () => { + const root = await mkExploreRepo(); + await writeFile( + root, + "src/admin.ts", + "import { validateUser } from './auth';\nexport const admin = validateUser('admin');\n", + ); + await writeFile( + root, + "src/audit.ts", + "import { validateUser } from './auth';\nexport const audit = validateUser('auditor');\n", + ); + const query = "validateUser"; + + const response = expectExploreEnvelope( + await exploreCodegraph({ + root, + query, + limit: 1, + maxPackets: 1, + maxPaths: 1, + }), + query, + ); + const omittedCounts = readRecord(response.omittedCounts, "omittedCounts"); + const limits = readRecord(response.limits, "limits"); + + expect(readArray(response.anchors, "anchors")).toHaveLength(1); + expect(readArray(response.packets, "packets")).toHaveLength(1); + expect(readArray(response.blastRadius, "blastRadius")).toHaveLength(1); + expect(readArray(response.followUps, "followUps").length).toBeGreaterThan(0); + expect(limits.anchors).toBe(1); + expect(limits.packets).toBe(1); + expect(limits.paths).toBe(1); + expect(omittedCounts.anchors).toBeGreaterThan(0); + }); + + it("prints the same bounded JSON envelope from the CLI explore command", async () => { + const root = await mkExploreRepo(); + const query = "validateUser"; + + const result = await captureCli(["explore", query, "--root", root, "--json"]); + + expect(result.exitCode).toBeUndefined(); + expect(result.stderr).toBe(""); + expectExploreEnvelope(JSON.parse(result.stdout) as unknown, query); + }); + + it("advertises a flat MCP explore schema and invokes the facade", async () => { + const root = await mkExploreRepo(); + const exploreTool = listCodegraphMcpTools().find((tool) => tool.name === "explore"); + expect(exploreTool).toBeTruthy(); + const schema = readRecord(exploreTool!.inputSchema, "explore input schema"); + const properties = readRecord(schema.properties, "explore schema properties"); + + expect(schema.type).toBe("object"); + expect(schema.required).toEqual(["query"]); + expect(schema.oneOf).toBeUndefined(); + expect(schema.anyOf).toBeUndefined(); + expect(schema.allOf).toBeUndefined(); + expect(properties.query).toEqual(expect.objectContaining({ type: "string" })); + expect(properties.root).toBeUndefined(); + expect(properties.limits).toBeUndefined(); + expect(properties.limit).toEqual(expect.objectContaining({ type: "integer", minimum: 0 })); + expect(properties.maxPackets).toEqual(expect.objectContaining({ type: "integer", minimum: 0 })); + expect(properties.maxPaths).toEqual(expect.objectContaining({ type: "integer", minimum: 0 })); + expect(properties.includeSource).toEqual(expect.objectContaining({ type: "boolean" })); + + const handlers = createCodegraphMcpHandlers({ root }); + const query = "validateUser"; + const response = expectExploreEnvelope( + await handlers.explore({ query, limit: 1, maxPackets: 1, maxPaths: 1 }), + query, + ); + + expect(readArray(response.anchors, "anchors")).toHaveLength(1); + expect(readArray(response.packets, "packets")).toHaveLength(1); + expect(readArray(response.blastRadius, "blastRadius")).toHaveLength(1); + expect(response.freshness).toBeTypeOf("object"); + }); +});