diff --git a/README.md b/README.md index ddbf93f7..40d21e39 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 a first pass, run `orient --root . --budget small --pretty`. -Use `packet get`, `search`, `explain`, `impact`, and `review` from the recommended next commands when you need deeper architecture, symbol, or change context. -For PR, worktree, or sweeping review tasks, start with `review --base HEAD --head WORKTREE --summary` or `impact --base HEAD --head WORKTREE --pretty`. +For unfamiliar repos, start with `orient --root . --budget small --pretty`, then use `search` and `explain` to land on one concrete code anchor. +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. Detailed command contracts and JSON shapes live in [docs/cli.md](./docs/cli.md). ## Features @@ -77,23 +77,28 @@ npm run build `npm run build` always rebuilds `dist/`. If Cargo is available, it also requires the local native workspace build to succeed; if Cargo is unavailable, it still completes with the JavaScript build output and a warning. -Then start with orientation and follow the returned commands: +Then start with the default workflow. For code reviews, the lowest-friction loop is `review --summary` first, `impact --pretty` only when you need blast radius, then `search` or `explain` on a file or symbol named in the summary; use review JSON when a follow-up needs stable handles. ```bash -# initial repo orientation with next-step suggestions +# compact reviewer handoff for current edits +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 + +# bounded repo orientation with next-step suggestions node ./dist/cli.js orient --root . --budget small --pretty +# find and explain a concrete anchor +node ./dist/cli.js search "build review report" --json +node ./dist/cli.js explain src/cli.ts + # optional runtime and artifact health check node ./dist/cli.js doctor # optional broader architecture summary node ./dist/cli.js inspect ./src --limit 20 -# find and explain a concrete anchor -node ./dist/cli.js packet get src/cli.ts --pretty -node ./dist/cli.js search "graph json" --json -node ./dist/cli.js explain src/cli.ts - # build a graph for product code node ./dist/cli.js graph --root . ./src --compact-json --output codegraph.json @@ -122,11 +127,14 @@ Choose output by consumer: Use these as starting points, then see [docs/cli.md](./docs/cli.md) for all flags, defaults, and output contracts. ```bash +# fastest code-review handoff for current edits +codegraph review --base HEAD --head WORKTREE --summary +codegraph impact --base HEAD --head WORKTREE --pretty + # repo orientation and bounded follow-up codegraph orient --root . --budget small --pretty -codegraph packet get src/cli/graph.ts --pretty -codegraph search "graph json" --json -codegraph explain file:src/cli/graph.ts +codegraph search "build review report" --json +codegraph explain src/review.ts # semantic navigation codegraph goto @@ -178,19 +186,32 @@ Recommended next ```json { "schemaVersion": 1, - "query": "graph json", + "query": "build review report", "mode": "hybrid", - "resultCount": 20, - "totalCandidates": 7911, + "analysis": { + "label": "native semantic" + }, + "resultCount": 1, + "totalCandidates": 42, "results": [ { - "handle": "chunk:docs%2Fcli.md:646", - "kind": "chunk", - "label": "docs/cli.md:646", - "file": "docs/cli.md", - "score": 282, - "rankReasons": ["exact phrase match in docs text", "text token match: graph, json"], - "followUps": ["codegraph chunk docs/cli.md", "codegraph deps docs/cli.md --json"] + "handle": "symbol:src%2Freview.ts:buildReviewReport:214:1", + "kind": "symbol", + "label": "buildReviewReport", + "file": "src/review.ts", + "score": 248, + "provenance": { + "surface": "code", + "capability": "semantic", + "analysisMode": "semantic", + "backend": "native", + "confidence": "high" + }, + "rankReasons": ["exact phrase match in symbol name", "symbol token match: build, review, report"], + "followUps": [ + "codegraph explain \"symbol:src%2Freview.ts:buildReviewReport:214:1\"", + "codegraph refs --file src/review.ts --line 214 --col 1 --pretty" + ] } ] } @@ -319,7 +340,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. +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. ```ts import { @@ -380,8 +401,8 @@ The supported package import surface includes the compatibility root export, `@l - Repo triage: run `codegraph inspect ./src --limit 20`, then follow with `codegraph hotspots ./src --limit 20` or `codegraph unresolved` to focus the next pass. - Duplicate cleanup: run `codegraph duplicates ./src --min-confidence medium` for the default pretty triage view, or add `--json` when a downstream tool needs grouped duplicate data. - Symbol navigation: use `codegraph goto ` and `codegraph refs --file --line --col --pretty` when a question is about definitions or semantic usages rather than matching strings. -- PR review: run `codegraph impact --base origin/main --head HEAD --pretty` for a ranked map, `codegraph review --base origin/main --head HEAD --summary` for a compact reviewer handoff with actionable candidate tests, or redirect plain `review` output when a downstream tool needs the full JSON bundle. -- Worktree review: run `codegraph impact --base HEAD --head WORKTREE --pretty` for current staged and unstaged tracked-file changes, then `codegraph review --base HEAD --head WORKTREE --summary` for a compact handoff. Use `--head STAGED` to compare `HEAD` against the current index. +- PR review: run `codegraph review --base origin/main --head HEAD --summary` for a compact reviewer handoff with actionable candidate tests, add `codegraph impact --base origin/main --head HEAD --pretty` when you need a ranked blast-radius map, or redirect plain `review` output when a downstream tool needs the full JSON bundle. +- Worktree review: run `codegraph review --base HEAD --head WORKTREE --summary` for current staged and unstaged tracked-file changes, then add `codegraph impact --base HEAD --head WORKTREE --pretty` only when the handoff needs wider blast-radius context. Use `--head STAGED` to compare `HEAD` against the current index. - Graph exploration: run `codegraph graph --root . ./src --compact-json --output codegraph.json` for scripts, `--mermaid` for Markdown renderers, or `--dot` for Graphviz. Bare `codegraph graph` writes `codegraph.json`; add `--stdout` when piping. - Public API inspection: run `codegraph apisurface` to summarize exported symbols before refactors, reviews, or release checks. diff --git a/codegraph-skill/codegraph/SKILL.md b/codegraph-skill/codegraph/SKILL.md index 54853d7c..f614ddb9 100644 --- a/codegraph-skill/codegraph/SKILL.md +++ b/codegraph-skill/codegraph/SKILL.md @@ -18,16 +18,14 @@ Do not use Codegraph as the only evidence for runtime behavior; pair it with tes ## First Move -Start bounded: +For PR, worktree, or sweeping review tasks, start with the compact reviewer handoff: ```bash -codegraph orient --root . --budget small --pretty +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 `doctor` only when install, native-runtime, or artifact health is the task. - -For PR, worktree, or sweeping review tasks, start with `codegraph review --base HEAD --head WORKTREE --summary` or `codegraph impact --base HEAD --head WORKTREE --pretty` instead. - Then choose the smallest useful follow-up: - packet: `codegraph packet get --pretty` @@ -52,6 +50,8 @@ For `orient`, `drift`, and positional graph commands, positional paths are inclu Use readable output when a human or model will read the result. Use JSON when the next step needs exact fields, counts, or filtering. +Hybrid search is code-first by default, and search/explain packets include analysis labels plus per-result provenance so reduced or mixed runs stay visible. + Current high-value surfaces: - `orient --pretty`: ranked first-turn focus targets with copyable follow-ups diff --git a/docs/agent-workflows.md b/docs/agent-workflows.md index 363f4cae..8d29be63 100644 --- a/docs/agent-workflows.md +++ b/docs/agent-workflows.md @@ -6,14 +6,27 @@ Use Codegraph for structural repo questions: architecture, dependency direction, ## Start here +For code reviews, start with `review`; it is the compact handoff with changed files, changed symbols, candidate tests, risks, duplicate leads, and analysis labels. + +```bash +codegraph review --base HEAD --head WORKTREE --summary +``` + +Add `impact` only when you need a wider blast-radius map: + +```bash +codegraph impact --base HEAD --head WORKTREE --pretty +``` + For an unfamiliar repo, keep the first loop bounded and actionable: ```bash codegraph orient --root . --budget small --pretty -codegraph packet get --pretty +codegraph search "auth user" --json +codegraph explain --json ``` -For PR, worktree, or sweeping review tasks, start with `codegraph review --base HEAD --head WORKTREE --summary` or `codegraph impact --base HEAD --head WORKTREE --pretty` instead of orientation. +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. @@ -55,11 +68,12 @@ codegraph search "handle login" --mode graph --from src/auth.ts --depth 1 --json codegraph explain "" --json ``` -Search results include stable handles, evidence, rank reasons, neighbors, follow-ups, limits, and omitted counts. +Search results include top-level `analysis` metadata plus stable handles, per-result `provenance`, evidence, rank reasons, neighbors, follow-ups, limits, and omitted counts. `explain` accepts those handles plus file paths, symbol names, and SQL object names, then returns bounded dependencies, references, snippets, duplicate context, SQL relation facts, review context, and follow-ups. Generated command strings quote dynamic arguments, SQL handles avoid ambiguous basenames, and omission counts stay explicit when packets hit limits. Agent CLI commands use the incremental index path and default to disk cache. +Hybrid search is code-first by default. Use `mode: "text"` when you specifically want documentation or prose-heavy matches to outrank implementation symbols. Pure path/text searches skip detailed symbol graph construction; hybrid, symbol, SQL, and graph searches keep symbol-aware ranking and neighbors. Pass shared index flags only when an agent pass must mirror a specific scan mode; see [docs/cli.md](./cli.md#agent-oriented-commands) for the canonical flag list. @@ -82,7 +96,14 @@ See [MCP server](./mcp.md) for client configuration examples. ## Session management -For agents performing code reviews or making multiple queries, use sessions to maintain warm caches: +For agents performing code reviews or making multiple queries, use sessions to maintain warm caches. Use one of these canonical reuse models: + +- library callers: one shared `createCodeReviewSession()` per repo snapshot +- agent hosts: one shared `createAgentSession()` or MCP server per repo snapshot + +The local review session refreshes manually with `refresh()` and records stale-snapshot metadata in `getStats()`. Navigation checks the requested file immediately and checks config or added/removed-file drift on the stale-check interval; impact calls add an interval-throttled tracked-file scan before computing the report. + +For library callers performing repeated navigation or impact work, use sessions like this: ```ts import { createCodeReviewSession } from "@lzehrung/codegraph"; @@ -344,13 +365,18 @@ codegraph review --base origin/main --head HEAD --include-symbol-details --max-c codegraph review --base origin/main --head HEAD --review-depth standard > review.json ``` -For current local edits, start with a ranked model-readable map, then hand off the compact review summary: +For current local edits, start with the compact review summary: ```bash -codegraph impact --base HEAD --head WORKTREE --pretty codegraph review --base HEAD --head WORKTREE --summary ``` +Add a ranked blast-radius map only when needed: + +```bash +codegraph impact --base HEAD --head WORKTREE --pretty +``` + Use `--head STAGED` instead of `WORKTREE` when the review should cover only the index. Keep the full JSON review bundle for scripts or agent steps that need `projectFiles`, `graphDelta`, or detailed symbol handles. For function-call integrations, keep the JSON object as the handoff. Do not parse `review --summary` or `impact --pretty` text to recover fields that are already present in the TypeScript return values. diff --git a/docs/cli.md b/docs/cli.md index b6397880..413d3304 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -10,6 +10,13 @@ Bare `codegraph graph` writes `codegraph.json` and `codegraph.err` in the curren Numeric options such as `--limit`, `--threads`, `--depth`, `--max-refs`, and token bounds must be integers in their documented ranges; invalid numeric values fail instead of being silently clamped or ignored. +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` +- targeted follow-up: `codegraph search "" --json` then `codegraph explain ` + ## Runtime selection The CLI defaults to `--native auto`, which uses the native Tree-sitter path when a compatible native artifact is available and falls back automatically otherwise. @@ -45,7 +52,12 @@ Cache and manifest reuse is rooted at `--root`. Reusing a project root lets comm ### Dependency graphs ```bash +# Fast code-review handoff for current local edits +codegraph review --base HEAD --head WORKTREE --summary +codegraph impact --base HEAD --head WORKTREE --pretty + # First-pass repo summary and next-step suggestions +codegraph orient --root . --budget small --pretty codegraph inspect ./src --limit 20 # Whole-repo graph @@ -116,8 +128,9 @@ 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 search "build review report" --json +codegraph explain src/review.ts --json codegraph packet get src/cli.ts --pretty -codegraph search "validate user" --json codegraph search "public users" --mode sql --json codegraph search "handle login" --from src/auth.ts --mode graph --depth 1 --json codegraph search --help @@ -233,7 +246,7 @@ 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. -`search` is deterministic and vectorless. `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, 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. +`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. 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. diff --git a/docs/how-it-works.md b/docs/how-it-works.md index c9a10d58..70d3b370 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -34,6 +34,7 @@ Runtime behavior, performance characteristics, architecture, extension points, a - `.codegraph-cache/index-v1/manifest.json` stores the last indexed commit, graph options, and per-file signatures plus resolved edges. - Incremental runs treat the manifest as a cached base graph: unchanged files keep their edges, while changed files are reparsed and their edges replaced. - `codegraph hotspots` and `codegraph inspect` reuse the disk index cache when the manifest is present and log the manifest path, timestamp, and last commit hash to stderr. +- Agent tool wrappers and agent sessions default to incremental warm-cache reuse so repeated local and MCP queries pay the cold build cost once, then reuse compatible manifests and parsed state. - Remove the manifest, clear `.codegraph-cache/index-v1`, or rerun with different graph flags to force a full graph rebuild. ### Read paths @@ -117,6 +118,7 @@ Language adapters expose: - Call compatibility runs only for changed callable signatures with provider-backed signature extraction and high-confidence callsite argument counts. - Hints compare arity only. They do not perform type checking, overload resolution, data-flow analysis, macro expansion, or dynamic dispatch. - Existing impact filters apply before hints are emitted, so ignored files and tests excluded by default stay out of call compatibility results. +- Long-lived `CodeReviewSession` instances keep cheap freshness baselines for config files and project-directory mtimes. Navigation also checks the requested file signature, while impact calls add an interval-throttled tracked-file scan before reuse. When those signals show drift, the session refreshes before serving results, and `getStats()` exposes stale/refresh metadata for callers that want to surface it. ### 6. AST grep diff --git a/docs/library-api.md b/docs/library-api.md index c90cb2fe..e16e58a8 100644 --- a/docs/library-api.md +++ b/docs/library-api.md @@ -23,6 +23,11 @@ const index = await buildProjectIndex(process.cwd(), { native: "auto" }); const reducedIndex = await buildProjectIndex(process.cwd(), { native: "off" }); ``` +For repeated calls, prefer one warm session instead of rebuilding indexes ad hoc: + +- `createCodeReviewSession()` for repeated navigation and impact work in library code +- `createAgentSession()` or MCP for repeated orient/search/explain/packet work in agent hosts + CLI commands and agent sessions read `codegraph.config.json` from the project root when it exists. Core indexing APIs keep discovery explicit, so pass `discovery` options directly when you want the same scan scope in custom code: ```ts @@ -107,7 +112,7 @@ Small orientation budgets default to `health: "skip"` and set health fields to ` ## Agent search -`searchCodegraph()` builds a project snapshot and returns deterministic, agent-ready anchors across files, symbols, chunks, SQL objects, and optional graph neighborhoods. Natural-language multi-token searches boost exact documentation phrases, while 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; large result packets include `resultCount`, `totalCandidates`, `limits`, and `omittedCounts`. +`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"; @@ -125,7 +130,7 @@ console.log(first?.handle, first?.rankReasons, first?.omittedCounts, first?.foll 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. 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: +`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: ```ts const explanation = await explainCodegraphTarget({ @@ -157,7 +162,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 accepts `buildOptions` when callers need explicit cache, thread, native runtime, worker, graph, or discovery settings. 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: +`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. 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: ```ts import { createCodegraphMcpHandlers } from "@lzehrung/codegraph"; diff --git a/docs/plans/2026-06-27-roi-implementation-progress.md b/docs/plans/2026-06-27-roi-implementation-progress.md new file mode 100644 index 00000000..d7c60e64 --- /dev/null +++ b/docs/plans/2026-06-27-roi-implementation-progress.md @@ -0,0 +1,94 @@ +# ROI Implementation Progress + +This file tracks implementation progress for the ROI-sorted improvement plan without modifying the original attached plan file. It is intended to be safe session handoff context. + +## Current status + +- [x] `review-default` Center the default workflow on review-first daily change analysis and align docs/help accordingly. +- [x] `search-relevance` Retune search ranking to prefer implementation code and symbols over docs by default. +- [x] `confidence-signals` Expose confidence, provenance, and capability-mode markers across search, navigation, impact, and review outputs. +- [x] `session-canonical` Consolidate and document one canonical warm-session reuse model for library and agent integrations. +- [x] `stale-detection` Add lightweight stale-session detection and refresh signaling for long-lived local sessions. +- [x] `warm-cache` Reduce warm-run latency by persisting and reusing more ready-to-query cache state. +- [x] `surface-narrowing` Simplify the product story around a small set of flagship workflows while retaining advanced features. + +All ROI plan checklist items are complete. + +## What has landed + +### Review-first workflow and surface narrowing + +- Updated the primary workflow messaging in: + - `README.md` + - `docs/cli.md` + - `docs/agent-workflows.md` + - `docs/library-api.md` + - `codegraph-skill/codegraph/SKILL.md` + - `src/cli/help.ts` +- Repositioned `review --base HEAD --head WORKTREE --summary` as the daily default. +- Kept `impact` as the broader blast-radius map and `orient -> search -> explain` as the unfamiliar-repo path. + +### Search relevance and visible provenance + +- Added shared analysis summary support in `src/analysisSummary.ts`. +- Extended agent search responses in `src/agent/search.ts` with: + - top-level `analysis` + - per-result `provenance` + - code-first hybrid ranking behavior +- Extended explain responses in `src/agent/explain.ts` with top-level `analysis`. +- Updated tests in `tests/agent-search.test.ts`. +- Added top-level `analysis` metadata to impact batch, compact, CLI pretty, and streaming summary outputs. +- Updated impact coverage in: + - `tests/impact-cli.test.ts` + - `tests/impact-streaming.test.ts` + - `tests/impact.test.ts` + +### Review analysis labeling + +- Added optional `analysis` to review output types in: + - `src/review/types.ts` + - `src/review/report.ts` + - `src/review.ts` + - `src/cli/review.ts` + +### Session freshness and cache reuse + +- Added stale-session detection and auto-refresh behavior to `src/session.ts`. +- Added session stats fields for stale state and refresh reasons. +- Updated agent/tool-side warm index usage in `src/agent-tools.ts` to use incremental builds with disk cache defaults. +- Persisted bloom filters inside the on-disk project snapshot and reused them on unchanged incremental loads instead of rebuilding them from source files. +- Added snapshot-schema fallback coverage so older snapshot versions rebuild cleanly and rewrite the current format. +- Added session coverage in `tests/session.test.ts`. + +## Validation status + +### Passed targeted tests + +- `tests/agent-search.test.ts` +- `tests/agent-explain.test.ts` +- `tests/session.test.ts` +- targeted CLI help tests from `tests/cli-command-modules.test.ts` +- targeted CLI search regression from `tests/cli-regressions.test.ts` +- `tests/impact-cli.test.ts` +- `tests/impact-streaming.test.ts` +- `tests/impact.test.ts` +- `tests/cache-invalidation.test.ts` +- `tests/parsed-cache-reuse.test.ts` +- `tests/bloom-filter-integration.test.ts` +- `tests/sqlite.test.ts` +- `tests/cli-command-modules.test.ts` +- `tests/cli-regressions.test.ts` + +### Repo-wide validation + +- `npm run check` passed in the implementation checkout; rerun it in the target native-build environment before merge. + +## Working tree notes + +- `tests/cli-regressions.test.ts` changed during formatting. +- The observed diff is formatter-only wrapping, not logic changes. + +## Recommended next resume point + +1. If needed, trim or summarize the implementation notes into release notes or a PR description. +2. If follow-on work is desired, evaluate whether the ROI workflow changes should be split into smaller thematic PRs for easier review. diff --git a/src/agent-tools.ts b/src/agent-tools.ts index 162af077..77772fe4 100644 --- a/src/agent-tools.ts +++ b/src/agent-tools.ts @@ -1,4 +1,4 @@ -import { buildProjectIndex } from "./indexer/build-index.js"; +import { buildProjectIndexIncremental } from "./indexer/build-index.js"; import { listSymbols, symbolId } from "./indexer/symbols.js"; import { goToDefinition, findReferences } from "./indexer/navigation.js"; import type { @@ -266,12 +266,7 @@ export async function tool_findSymbol( error?: string; }> { try { - const index = - options.index ?? - (await buildProjectIndex(root, { - logLevel: "error", - ...(options.native ? { native: options.native } : {}), - })); + const index = options.index ?? (await getToolIndex(root, options)); const allSymbols = listSymbols(index, { includeImports: false }); const q = query.toLowerCase(); @@ -515,7 +510,10 @@ function normalizePathArg(root: string, file: string): string { async function getToolIndex(root: string, options: ToolRuntimeOptions): Promise { return ( options.index ?? - (await buildProjectIndex(root, { + (await buildProjectIndexIncremental(root, { + cache: "disk", + keepParsed: true, + useBloomFilters: true, logLevel: "error", ...(options.native ? { native: options.native } : {}), })) diff --git a/src/agent/explain.ts b/src/agent/explain.ts index d4099d30..2ee2e028 100644 --- a/src/agent/explain.ts +++ b/src/agent/explain.ts @@ -1,4 +1,5 @@ import fs from "node:fs/promises"; +import type { AnalysisSummary } from "../analysisSummary.js"; import { findDuplicateContext, type DuplicateGroup, @@ -148,6 +149,7 @@ export type AgentExplanationDuplicate = { export type AgentExplanation = { schemaVersion: 1; root: string; + analysis: AnalysisSummary; target: AgentExplanationTarget; summary: string[]; symbols: AgentExplanationSymbol[]; @@ -215,6 +217,7 @@ export async function explainCodegraphTargetWithSession( export function formatAgentExplanation(explanation: AgentExplanation): string { const lines = [ `${explanation.target.kind}: ${explanation.target.label}`, + `Analysis: ${explanation.analysis.label}`, ...explanation.summary.map((entry) => `- ${entry}`), ]; if (explanation.symbols.length) { @@ -463,6 +466,7 @@ async function buildExplanation( return { schemaVersion: 1, root: snapshot.root, + analysis: snapshot.analysis, target: explainTarget(snapshot, resolved, relFile), summary: buildSummary( resolved, @@ -507,6 +511,7 @@ function emptyExplanation(snapshot: AgentProjectSnapshot, target: AgentExplanati return { schemaVersion: 1, root: snapshot.root, + analysis: snapshot.analysis, target, summary: [`No indexed target resolved for ${target.label}.`], symbols: [], diff --git a/src/agent/search.ts b/src/agent/search.ts index 8769686d..08ee5b96 100644 --- a/src/agent/search.ts +++ b/src/agent/search.ts @@ -1,5 +1,6 @@ import fs from "node:fs/promises"; import { LANG_CONFIGS } from "../bootstrap/treeSitterLanguages.js"; +import { type AnalysisBackend, type AnalysisMode, type AnalysisSummary } from "../analysisSummary.js"; import { supportForFile } from "../languages.js"; import { chunkFile } from "../chunking/chunkFile.js"; import { SymbolKind, type BuildOptions, type SymbolDef } from "../indexer/types.js"; @@ -68,6 +69,13 @@ export type AgentSearchResult = { file: string; range?: Range; score: number; + provenance: { + surface: "code" | "docs" | "config"; + capability: "semantic" | "graph" | "text"; + analysisMode: AnalysisMode; + backend: AnalysisBackend; + confidence: "high" | "medium"; + }; rankReasons: string[]; evidence: AgentSearchEvidence[]; neighbors: Array<{ relation: string; target: string; file?: string }>; @@ -85,6 +93,7 @@ export type AgentSearchResponse = { query: string; mode: AgentSearchMode; root: string; + analysis: AnalysisSummary; limits: { results: number; rankReasonsPerResult: number; @@ -127,6 +136,7 @@ type SearchResultBase = { label: string; file: string; range?: Range; + provenance: AgentSearchResult["provenance"]; }; type ReachableFile = { @@ -165,8 +175,8 @@ type SearchCache = { const DEFAULT_LIMIT = 20; const MAX_TEXT_BYTES = 300_000; const MAX_GRAPH_DEPTH = 5; -const DOCS_EXACT_PHRASE_BOOST = 220; -const DOCS_PROXIMITY_BOOST = 20; +const DOCS_EXACT_PHRASE_BOOST = 18; +const DOCS_PROXIMITY_BOOST = 6; const CHUNK_LANGUAGE_ALIASES: Record = { js: "javascript", ts: "typescript", @@ -198,17 +208,20 @@ export async function searchCodegraphWithSession( export function formatAgentSearchResponse(response: AgentSearchResponse): string { if (!response.results.length) { - return `No matches for "${response.query}"`; + return `No matches for "${response.query}"\nAnalysis: ${response.analysis.label}`; + } + const lines = [`Analysis: ${response.analysis.label}`]; + for (const [index, result] of response.results.entries()) { + const location = result.range + ? `${result.file}:${result.range.start.line}:${result.range.start.column}` + : result.file; + const reasons = result.rankReasons.slice(0, AGENT_SEARCH_FORMAT_REASON_LIMIT).join("; "); + lines.push( + `${index + 1}. ${result.label} [${result.kind}] ${location} score=${result.score} (${result.provenance.surface}, ${result.provenance.capability})`, + ); + lines.push(` ${reasons}`); } - return response.results - .map((result, index) => { - const location = result.range - ? `${result.file}:${result.range.start.line}:${result.range.start.column}` - : result.file; - const reasons = result.rankReasons.slice(0, AGENT_SEARCH_FORMAT_REASON_LIMIT).join("; "); - return `${index + 1}. ${result.label} [${result.kind}] ${location} score=${result.score}\n ${reasons}`; - }) - .join("\n"); + return lines.join("\n"); } async function searchSnapshot( @@ -236,7 +249,7 @@ async function searchSnapshot( addPathResults(snapshot, resultMap, getFileNeighborIndex(), query); } if (mode === "hybrid" || mode === "text") { - await addTextResults(snapshot, resultMap, query, request.includeSnippets ?? true); + await addTextResults(snapshot, resultMap, query, request.includeSnippets ?? true, mode); } } @@ -259,6 +272,7 @@ async function searchSnapshot( query: request.query, mode, root: snapshot.root, + analysis: snapshot.analysis, limits: { results: limit, rankReasonsPerResult: AGENT_SEARCH_RANK_REASONS_PER_RESULT_LIMIT, @@ -295,6 +309,10 @@ function searchPathOnly(root: string, files: readonly string[], request: AgentSe kind: "file", label: relFile, file: relFile, + provenance: createSearchProvenance(relFile, "text", "high", { + mode: "reduced", + backend: "unknown", + }), }); result.score += pathMatch.score * 2; addReason(result, `path token match: ${pathMatch.matched.join(", ")}`); @@ -311,6 +329,15 @@ function searchPathOnly(root: string, files: readonly string[], request: AgentSe query: request.query, mode: "path", root, + analysis: { + mode: "reduced", + backend: "unknown", + parserDegradedFiles: 0, + fallbackImportExtractionFiles: 0, + nativeFilesUsed: 0, + nativeFilesFellBack: 0, + label: "path-only", + }, limits: { results: limit, rankReasonsPerResult: AGENT_SEARCH_RANK_REASONS_PER_RESULT_LIMIT, @@ -458,6 +485,7 @@ function addSymbolResults( label: node.name, file: relFile, range: def.range, + provenance: createSearchProvenance(relFile, "semantic", "high", snapshot.analysis), }); result.score += score + (lookup.exportedIds.has(node.id) ? 5 : 0); if (nameMatch.matched.length) { @@ -504,6 +532,7 @@ function addSqlResults( label: local.localName, file: relFile, range: local.range, + provenance: createSearchProvenance(relFile, "semantic", "high", snapshot.analysis), }); result.score += score; if (nameMatch.matched.length) { @@ -554,6 +583,7 @@ function addPathResults( kind: "file", label: relFile, file: relFile, + provenance: createSearchProvenance(relFile, "graph", "high", snapshot.analysis), }); result.score += pathMatch.score * 2; addReason(result, `path token match: ${pathMatch.matched.join(", ")}`); @@ -568,6 +598,7 @@ async function addTextResults( resultMap: Map, query: SearchQueryTerms, includeSnippets: boolean, + mode: AgentSearchMode, ): Promise { const cache = getSearchCache(snapshot); for (const file of snapshot.files) { @@ -591,8 +622,9 @@ async function addTextResults( start: { line: chunk.startLine, column: 0 }, end: { line: chunk.endLine, column: 0 }, }, + provenance: createSearchProvenance(relFile, "text", documentationFile ? "medium" : "high", snapshot.analysis), }); - result.score += match.score + textResultBoost(match, documentationFile, query); + result.score += match.score + textResultBoost(match, documentationFile, query, mode); addReason(result, `text token match: ${match.matched.join(", ")}`); addPhraseReasons(result, match, documentationFile ? "docs text" : "text"); addEvidence(result, { @@ -607,8 +639,14 @@ async function addTextResults( } } -function textResultBoost(match: TokenMatch, documentationFile: boolean, query: SearchQueryTerms): number { +function textResultBoost( + match: TokenMatch, + documentationFile: boolean, + query: SearchQueryTerms, + mode: AgentSearchMode, +): number { if (!documentationFile || query.identifierLike) return 0; + if (mode !== "text" && mode !== "hybrid") return 0; if (match.exactPhrase) return DOCS_EXACT_PHRASE_BOOST; if (match.proximity) return DOCS_PROXIMITY_BOOST; return 0; @@ -708,6 +746,7 @@ function applyGraphNeighborhood( kind: "graph_node", label: relFile, file: relFile, + provenance: createSearchProvenance(relFile, "graph", "medium", snapshot.analysis), }); graphResult.score += fileMatch.score + graphBoost(entry.distance); addGraphEvidence(graphResult, relFile, entry); @@ -868,6 +907,7 @@ function upsertResult(resultMap: Map, base: SearchR label: base.label, file: base.file, ...(base.range ? { range: base.range } : {}), + provenance: base.provenance, score: 0, rankReasons: new Set(), evidence: [], @@ -1057,6 +1097,7 @@ function finalizeResult(result: MutableSearchResult): AgentSearchResult { file: result.file, ...(result.range ? { range: result.range } : {}), score: Number(result.score.toFixed(3)), + provenance: result.provenance, rankReasons: boundedRankReasons.items, evidence: boundedEvidence.items, neighbors: boundedNeighbors.items, @@ -1069,3 +1110,38 @@ function finalizeResult(result: MutableSearchResult): AgentSearchResult { }, }; } + +function createSearchProvenance( + relFile: string, + capability: "semantic" | "graph" | "text", + confidence: "high" | "medium", + analysis: Pick, +): AgentSearchResult["provenance"] { + return { + surface: detectSearchSurface(relFile), + capability, + analysisMode: analysis.mode, + backend: analysis.backend, + confidence, + }; +} + +function detectSearchSurface(relFile: string): "code" | "docs" | "config" { + const lower = relFile.toLowerCase(); + if (isDocumentationFile(lower)) { + return "docs"; + } + if ( + lower.endsWith(".json") || + lower.endsWith(".yaml") || + lower.endsWith(".yml") || + lower.endsWith(".toml") || + lower.endsWith(".ini") || + lower.endsWith(".env") || + lower === "dockerfile" || + lower.startsWith(".github/") + ) { + return "config"; + } + return "code"; +} diff --git a/src/agent/session.ts b/src/agent/session.ts index cde0ac1a..67d3d9dc 100644 --- a/src/agent/session.ts +++ b/src/agent/session.ts @@ -1,11 +1,12 @@ import { buildProjectIndexIncremental } from "../indexer/build-index.js"; -import type { BuildOptions, ProjectIndex } from "../indexer/types.js"; +import type { BuildOptions, BuildReport, ProjectIndex } from "../indexer/types.js"; import { buildSymbolGraphDetailed } from "../graphs/symbol-graph-detailed.js"; import { type SymbolGraph } from "../graphs/symbol-graph.js"; import type { Graph } from "../types.js"; import { listProjectFiles, type ProjectFileDiscoveryOptions } from "../util/projectFiles.js"; import { hasDiscoveryOptions, loadCodegraphConfig, mergeDiscoveryOptions } from "../config.js"; import { createAgentFileLookup } from "./normalize.js"; +import { summarizeAnalysis, type AnalysisSummary } from "../analysisSummary.js"; export type AgentProjectSnapshot = { root: string; @@ -14,6 +15,8 @@ export type AgentProjectSnapshot = { index: ProjectIndex; fileGraph: Graph; symbolGraph: SymbolGraph; + buildReport?: BuildReport; + analysis: AnalysisSummary; }; export type AgentLoadProjectOptions = { @@ -93,6 +96,8 @@ export function createAgentSession(options: AgentSessionOptions): AgentSession { ) { buildOptions.useNativeWorkers = true; } + const buildReport: BuildReport = { timings: {} }; + buildOptions.report = buildReport; const index = await buildProjectIndexIncremental(options.root, buildOptions); const fileGraph = index.graph; @@ -102,6 +107,8 @@ export function createAgentSession(options: AgentSessionOptions): AgentSession { fileLookup: createAgentFileLookup(files), index, fileGraph, + buildReport, + analysis: summarizeAnalysis({ index, report: buildReport }), }; })(); cachedBase = loadPromise; diff --git a/src/analysisSummary.ts b/src/analysisSummary.ts new file mode 100644 index 00000000..138177d9 --- /dev/null +++ b/src/analysisSummary.ts @@ -0,0 +1,108 @@ +import type { ProjectIndex, BuildReport } from "./indexer/types.js"; + +export type AnalysisMode = "semantic" | "mixed" | "reduced"; + +export type AnalysisBackend = "native" | "mixed" | "graph-only" | "unknown"; + +export type AnalysisSummary = { + mode: AnalysisMode; + backend: AnalysisBackend; + parserDegradedFiles: number; + fallbackImportExtractionFiles: number; + nativeFilesUsed: number; + nativeFilesFellBack: number; + label: string; +}; + +function deriveAnalysisBackend(input: { + index?: ProjectIndex | undefined; + report?: BuildReport | undefined; + nativeMode?: ProjectIndex["nativeMode"] | undefined; +}): AnalysisBackend { + if (input.nativeMode === "off" || input.index?.nativeMode === "off") { + return "graph-only"; + } + const nativeReport = input.report?.backend?.native; + const parserFallbackCount = input.report?.backend?.parser?.total ?? 0; + const importFallbackCount = input.report?.graph?.fallbackImportExtraction?.total ?? 0; + if (nativeReport) { + if (!nativeReport.filesUsed && (parserFallbackCount || importFallbackCount)) { + return "graph-only"; + } + if (nativeReport.filesUsed && (nativeReport.filesFellBack || parserFallbackCount || importFallbackCount)) { + return "mixed"; + } + if (nativeReport.filesUsed) { + return "native"; + } + } + if (parserFallbackCount || importFallbackCount) { + return "graph-only"; + } + return "unknown"; +} + +function deriveAnalysisMode(summary: Omit): AnalysisMode { + if (summary.backend === "graph-only") { + return "reduced"; + } + if ( + summary.backend === "mixed" || + summary.parserDegradedFiles > 0 || + summary.fallbackImportExtractionFiles > 0 || + summary.nativeFilesFellBack > 0 + ) { + return "mixed"; + } + return "semantic"; +} + +export function formatAnalysisSummaryLabel(summary: AnalysisSummary): string { + if (summary.mode === "semantic") { + return summary.backend === "native" ? "native semantic" : "semantic"; + } + if (summary.mode === "reduced") { + return "reduced graph-only"; + } + const details: string[] = []; + if (summary.parserDegradedFiles) { + details.push(`${summary.parserDegradedFiles} parser fallback`); + } + if (summary.fallbackImportExtractionFiles) { + details.push(`${summary.fallbackImportExtractionFiles} regex import fallback`); + } + if (!details.length && summary.nativeFilesFellBack) { + details.push(`${summary.nativeFilesFellBack} native fallback`); + } + return details.length ? `mixed semantics (${details.join(", ")})` : "mixed semantics"; +} + +export function summarizeAnalysis(input: { + index?: ProjectIndex | undefined; + nativeMode?: ProjectIndex["nativeMode"] | undefined; + report?: BuildReport | undefined; +}): AnalysisSummary { + if (input.index?.analysis) { + return input.index.analysis; + } + const parserDegradedFiles = input.report?.backend?.parser?.total ?? 0; + const fallbackImportExtractionFiles = input.report?.graph?.fallbackImportExtraction?.total ?? 0; + const nativeFilesUsed = input.report?.backend?.native?.filesUsed ?? 0; + const nativeFilesFellBack = input.report?.backend?.native?.filesFellBack ?? 0; + const backend = deriveAnalysisBackend(input); + const summaryBase = { + backend, + parserDegradedFiles, + fallbackImportExtractionFiles, + nativeFilesUsed, + nativeFilesFellBack, + }; + const mode = deriveAnalysisMode(summaryBase); + const summary: AnalysisSummary = { + ...summaryBase, + mode, + label: "", + }; + summary.label = formatAnalysisSummaryLabel(summary); + return summary; +} diff --git a/src/cli/help.ts b/src/cli/help.ts index c0d9c149..6d9297a8 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -3,18 +3,18 @@ export const CLI_HELP_TEXT = `codegraph - Code analysis and dependency graph too Usage: codegraph [options] [path] Commands: - graph Build dependency graph (default) - inspect Summarize repo structure and recommend next commands orient Build a compact first-turn packet for agent repo context + 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 explain Explain a file, symbol, SQL object, or search handle + impact Analyze PR impact + inspect Summarize repo structure and recommend next commands + graph Build dependency graph (default) artifact Build an agent-ready SQLite/graph/report/question bundle drift Compare architecture health between refs or artifacts mcp Serve MCP tools for agent graph navigation index Build the project symbol index - impact Analyze PR impact - review Generate code review report goto Go to definition refs Find references deps List dependencies @@ -64,20 +64,28 @@ Output Options: --output Write to file instead of stdout --stdout Write default graph output to stdout +Recommended review commands: + codegraph review --base HEAD --head WORKTREE --summary + codegraph impact --base HEAD --head WORKTREE --pretty (optional blast-radius follow-up) + codegraph search "auth user" --json + codegraph explain src/auth.ts --json + +Unfamiliar repo: + codegraph orient --root . --budget small --pretty + Examples: - codegraph graph ./src - codegraph graph --fast-graph --mermaid ./src - codegraph version - codegraph -v + codegraph review --base HEAD --head WORKTREE --summary codegraph orient ./src --budget small --pretty - codegraph packet get file:src%2Fcli.ts --json - codegraph doctor - codegraph inspect ./src --limit 20 - codegraph duplicates ./src --min-confidence medium codegraph search "auth user" --json codegraph explain src/auth.ts --json + codegraph impact --provider git --base HEAD --head WORKTREE + codegraph packet get file:src%2Fcli.ts --json codegraph artifact build --root . --out codegraph-out --json codegraph mcp serve --root . --stdio + codegraph inspect ./src --limit 20 + codegraph duplicates ./src --min-confidence medium + codegraph graph ./src + codegraph graph --fast-graph --mermaid ./src codegraph graph --root . ./src --include-glob "**/*.ts" --ignore-glob "**/*.spec.ts" codegraph skill install --agent agents codegraph skill install --agent codex @@ -87,10 +95,11 @@ Examples: codegraph skill install --agent opencode codegraph skill install --target ~/.codex/skills/codegraph --force codegraph skill doctor - codegraph impact --provider git --base main --head HEAD codegraph impact --provider git --base main --head HEAD --pretty --duplicates off - codegraph impact --provider git --base HEAD --head WORKTREE codegraph refs --file src/index.ts --line 42 --col 10 + codegraph doctor + codegraph version + codegraph -v `; const knownCliCommands = new Set([ @@ -143,7 +152,8 @@ Search Modes: sql Prefer SQL object context Output: - Results include stable handles, rank reasons, evidence, graph neighbors, follow-up commands, limits, and omission counts. + Results include top-level analysis metadata plus stable handles, rank reasons, provenance, evidence, graph neighbors, follow-up commands, limits, and omission counts. + Hybrid search is code-first by default: source files and symbols outrank docs unless you use text mode or the docs are the strongest remaining evidence. Index options: Supports shared --cache, --cache-strict, --cache-verify, --threads, --native, --workers, --include-glob, --ignore-glob, and --no-gitignore options. diff --git a/src/cli/impact.ts b/src/cli/impact.ts index a05c93d8..568bfc68 100644 --- a/src/cli/impact.ts +++ b/src/cli/impact.ts @@ -1,5 +1,5 @@ import { buildProjectIndex } from "../indexer/build-index.js"; -import type { BuildOptions, ProjectIndex } from "../indexer/types.js"; +import type { BuildOptions, BuildReport, ProjectIndex } from "../indexer/types.js"; import { analyzeImpactFromDiff, type ChangedSymbol, @@ -162,6 +162,7 @@ function ensureImpactReport(report: ImpactReport | CompactImpactReport): ImpactR const result: ImpactReport = { schemaVersion: report.schemaVersion, format: "full", + ...(report.analysis ? { analysis: report.analysis } : {}), changedFiles, changedSymbols, impacted, @@ -421,6 +422,9 @@ function formatPrettyImpactReport(impactReport: ImpactReport, duplicateSummary?: lines.push(`WARNING: ${impactReport.warning}`); lines.push(""); } + if (impactReport.analysis) { + lines.push(`Analysis: ${impactReport.analysis.label}`); + } lines.push(`Changed files: ${impactReport.changedFiles.length}`); lines.push(`Changed symbols: ${impactReport.changedSymbols.length}`); lines.push(`Impacted items: ${impactReport.impacted.length}`); @@ -478,11 +482,11 @@ export async function handleImpactCommand(context: ImpactCommandContext): Promis try { const duplicateScope = pretty && !mermaid ? parseDuplicateLeadScope(context.getOpt("--duplicates"), "changed") : "off"; - const index = await buildProjectIndex( - context.projectRootFs, - buildIndexOptions(context, options, { keepParsedForDuplicates: duplicateScope !== "off" }), - ); - const report = await analyzeImpactFromDiff(context.projectRootFs, index, options as ImpactOptions); + const buildReport: BuildReport = { timings: {} }; + const indexOptions = buildIndexOptions(context, options, { keepParsedForDuplicates: duplicateScope !== "off" }); + indexOptions.report = buildReport; + const index = await buildProjectIndex(context.projectRootFs, indexOptions); + const report = await analyzeImpactFromDiff(context.projectRootFs, index, options as ImpactOptions, { buildReport }); const impactReport = ensureImpactReport(report); if (mermaid) { diff --git a/src/cli/review.ts b/src/cli/review.ts index 37c161cd..a9527b9d 100644 --- a/src/cli/review.ts +++ b/src/cli/review.ts @@ -181,6 +181,9 @@ function formatReviewSummary( lines.push("Review Summary"); lines.push("=============="); lines.push(`Status: ${report.status}`); + if (report.analysis) { + lines.push(`Analysis: ${report.analysis.label}`); + } lines.push(`Files changed: ${report.summary.filesChanged}`); lines.push(`Symbols changed: ${report.summary.symbolsChanged}`); lines.push( diff --git a/src/impact/index.ts b/src/impact/index.ts index 50a33caa..83f757ce 100644 --- a/src/impact/index.ts +++ b/src/impact/index.ts @@ -1,4 +1,4 @@ -import { type ProjectIndex } from "../indexer/types.js"; +import { type ProjectIndex, type BuildReport } from "../indexer/types.js"; import type { ImpactReport, CompactImpactReport, ImpactOptions } from "./types.js"; import { collectImpactAnalysis } from "./collect.js"; import { buildImpactReport } from "./report.js"; @@ -16,6 +16,10 @@ export { type ExtractCallsiteArgumentsRequest, } from "./callCompatibility.js"; +export type ImpactAnalysisContext = { + buildReport?: BuildReport | undefined; +}; + /** * Analyze a diff against an existing project index and return a structured impact report. * @@ -28,6 +32,7 @@ export async function analyzeImpactFromDiff( projectRoot: string, index: ProjectIndex, options: ImpactOptions, + context: ImpactAnalysisContext = {}, ): Promise { const analysis = await collectImpactAnalysis(projectRoot, index, options); const suggestions = await collectImpactReportSuggestions( @@ -46,7 +51,7 @@ export async function analyzeImpactFromDiff( analysis.changedSymbols, analysis.impactedItems, suggestions, - { ...options, warning: analysis.warning }, + { ...options, warning: analysis.warning, buildReport: context.buildReport }, analysis.diagnostics, ); } diff --git a/src/impact/report.ts b/src/impact/report.ts index 1fcafbb2..ac612b89 100644 --- a/src/impact/report.ts +++ b/src/impact/report.ts @@ -1,6 +1,7 @@ import type { FileId } from "../types.js"; import path from "node:path"; -import { type ProjectIndex } from "../indexer/types.js"; +import { type ProjectIndex, type BuildReport } from "../indexer/types.js"; +import { summarizeAnalysis } from "../analysisSummary.js"; import type { FileChange, ChangedSymbol, @@ -34,7 +35,7 @@ export async function buildImpactReport( changedSymbols: ChangedSymbol[], impactedItems: ImpactItem[], suggestions: ImpactSuggestion[], - options: Partial & { warning?: string | undefined } = {}, + options: Partial & { warning?: string | undefined; buildReport?: BuildReport | undefined } = {}, diagnostics?: ImpactDiagnostics, ): Promise { const normalizedDiffFiles = diffFiles.map((change) => normalizeImpactFileChange(projectRoot, change)); @@ -44,6 +45,7 @@ export async function buildImpactReport( const topImpacts = buildTopImpacts(impactedItems); const surfaceArea = buildSurfaceArea(index, normalizedDiffFiles, impactedItems); const projectFiles = index.projectFiles ?? (await discoverProjectFiles(projectRoot)); + const analysis = summarizeAnalysis({ index, report: options.buildReport ?? index.buildReport }); // Build changedFiles summary const changedFiles = normalizedDiffFiles.map((fileChange) => ({ @@ -128,6 +130,7 @@ export async function buildImpactReport( // Check if compact format is requested if (options.compact) { const report = buildCompactImpactReport({ + analysis, changedFiles, changedSymbols, impactedItems, @@ -149,6 +152,7 @@ export async function buildImpactReport( } return buildFullImpactReport({ + analysis, projectFiles, changedFiles, changedSymbols, diff --git a/src/impact/reportCompact.ts b/src/impact/reportCompact.ts index f42304b6..fab856c4 100644 --- a/src/impact/reportCompact.ts +++ b/src/impact/reportCompact.ts @@ -1,4 +1,5 @@ import type { FileId } from "../types.js"; +import type { AnalysisSummary } from "../analysisSummary.js"; import { buildOptionalExportSummary, buildOptionalReexportChains, @@ -23,7 +24,9 @@ import type { ImpactSurfaceArea, } from "./types.js"; -export type CompactImpactReportParts = ImpactReportPartsBase; +export type CompactImpactReportParts = ImpactReportPartsBase & { + analysis?: AnalysisSummary; +}; export function buildCompactImpactReport(parts: CompactImpactReportParts): CompactImpactReport { const context = buildCompactSerializerContext(parts); @@ -31,6 +34,7 @@ export function buildCompactImpactReport(parts: CompactImpactReportParts): Compa return { schemaVersion: IMPACT_SCHEMA_VERSION, format: "compact", + ...(parts.analysis ? { analysis: parts.analysis } : {}), ...(parts.projectFiles ? { projectFiles: parts.projectFiles } : {}), files: context.files, changedFiles: parts.changedFiles.map((fileChange) => ({ diff --git a/src/impact/reportFull.ts b/src/impact/reportFull.ts index a663bddf..cacdd166 100644 --- a/src/impact/reportFull.ts +++ b/src/impact/reportFull.ts @@ -1,4 +1,5 @@ import type { FileId } from "../types.js"; +import type { AnalysisSummary } from "../analysisSummary.js"; import { buildOptionalExportSummary, buildOptionalReexportChains, @@ -11,6 +12,7 @@ import { IMPACT_SCHEMA_VERSION } from "./types.js"; import type { ImpactCycle, ImpactDiagnostics, ImpactReport, ImpactSuggestion } from "./types.js"; export type FullImpactReportParts = ImpactReportPartsBase & { + analysis?: AnalysisSummary; diagnostics?: ImpactDiagnostics | undefined; warning?: string | undefined; }; @@ -19,6 +21,7 @@ export function buildFullImpactReport(parts: FullImpactReportParts): ImpactRepor const report: ImpactReport = { schemaVersion: IMPACT_SCHEMA_VERSION, format: "full", + ...(parts.analysis ? { analysis: parts.analysis } : {}), ...(parts.projectFiles ? { projectFiles: parts.projectFiles } : {}), changedFiles: parts.changedFiles, changedSymbols: parts.changedSymbols.map((symbol) => ({ diff --git a/src/impact/streaming.ts b/src/impact/streaming.ts index c9995715..7fe3f2da 100644 --- a/src/impact/streaming.ts +++ b/src/impact/streaming.ts @@ -3,7 +3,8 @@ * Allows incremental results to be emitted as they're discovered */ -import { type ProjectIndex } from "../indexer/types.js"; +import { type ProjectIndex, type BuildReport } from "../indexer/types.js"; +import { summarizeAnalysis } from "../analysisSummary.js"; import { IMPACT_SCHEMA_VERSION, type ImpactOptions, @@ -121,6 +122,7 @@ function createAsyncQueue(): AsyncQueue { } function buildLightStreamSummaryReport( + analysis: ImpactStreamSummaryReport["analysis"], normalizedChanges: FileChange[], changedSymbols: ChangedSymbol[], impactedItems: ImpactItem[], @@ -131,6 +133,7 @@ function buildLightStreamSummaryReport( return { schemaVersion: IMPACT_SCHEMA_VERSION, format: "stream-summary", + ...(analysis ? { analysis } : {}), changedFiles: normalizedChanges.map((change) => ({ file: displayFile(change.path), kind: change.kind, @@ -176,6 +179,10 @@ export function impactItemEmissionKey(item: ImpactItem, partial: boolean): strin ].join("|"); } +export type ImpactStreamingContext = { + buildReport?: BuildReport | undefined; +}; + /** * Stream impact analysis results as they are discovered. * @@ -191,11 +198,13 @@ export async function* analyzeImpactStreaming( projectRoot: string, index: ProjectIndex, options: ImpactStreamingOptions, + context: ImpactStreamingContext = {}, ): AsyncGenerator { try { const streamSummary = validateImpactStreamingOptions(options); const impactOptions = toImpactOptions(options); const displayFile = (filePath: string): string => toImpactReportFilePath(projectRoot, filePath); + const analysis = summarizeAnalysis({ index, report: context.buildReport ?? index.buildReport }); const projectFiles = index.projectFiles ?? (await discoverProjectFiles(projectRoot)); yield { type: "projectFiles", files: projectFiles }; @@ -312,6 +321,7 @@ export async function* analyzeImpactStreaming( const report = streamSummary === "light" ? buildLightStreamSummaryReport( + analysis, normalizedChanges, changedSymbols, impactedItems, @@ -328,6 +338,7 @@ export async function* analyzeImpactStreaming( impactedItems, diagnostics, diff.warning, + context, ); yield { @@ -362,6 +373,7 @@ async function buildFullStreamSummaryReport( impactedItems: ImpactItem[], diagnostics: ImpactStreamSummaryReport["diagnostics"], warning: string | undefined, + context: ImpactStreamingContext, ): Promise { const suggestions = await collectImpactReportSuggestions( projectRoot, @@ -377,7 +389,7 @@ async function buildFullStreamSummaryReport( changedSymbols, impactedItems, suggestions, - { ...options, compact: false, warning }, + { ...options, compact: false, warning, buildReport: context.buildReport }, diagnostics, ); if (fullReport.format !== "full") { @@ -386,6 +398,7 @@ async function buildFullStreamSummaryReport( return { schemaVersion: fullReport.schemaVersion, format: "stream-summary", + ...(fullReport.analysis ? { analysis: fullReport.analysis } : {}), changedFiles: fullReport.changedFiles, changedSymbols: fullReport.changedSymbols, impacted: fullReport.impacted, diff --git a/src/impact/types.ts b/src/impact/types.ts index 32eedc29..2c8ed9bb 100644 --- a/src/impact/types.ts +++ b/src/impact/types.ts @@ -1,3 +1,4 @@ +import type { AnalysisSummary } from "../analysisSummary.js"; import type { FileId, Range } from "../types.js"; import { type SymbolHandle, type SymbolDef } from "../indexer/types.js"; import { type ProjectFileInfo } from "../util/projectFiles.js"; @@ -253,6 +254,7 @@ export type ImpactDiagnostics = { export type ImpactStreamSummaryReport = { schemaVersion: number; format: "stream-summary"; + analysis?: AnalysisSummary; changedFiles: ImpactReport["changedFiles"]; changedSymbols: ChangedSymbol[]; impacted: ImpactItem[]; @@ -279,6 +281,7 @@ export const IMPACT_SCHEMA_VERSION = 1; export type ImpactReport = { schemaVersion: number; format: "full"; + analysis?: AnalysisSummary; projectFiles?: ProjectFileInfo[]; changedFiles: Array<{ file: FileId; @@ -315,6 +318,7 @@ export type ImpactReport = { export type CompactImpactReport = { schemaVersion: number; format: "compact"; + analysis?: AnalysisSummary; projectFiles?: ProjectFileInfo[]; files: FileId[]; // file index -> file path changedFiles: Array<{ diff --git a/src/index.ts b/src/index.ts index 61ecc9af..7fafd763 100644 --- a/src/index.ts +++ b/src/index.ts @@ -92,6 +92,7 @@ export { graphToTriples, type Triple, type TripleNode } from "./triples.js"; /** Core graph primitives shared across index, graph, and tool APIs. */ export type { Pos, Range, FileId, EdgeTo, Edge, Graph } from "./types.js"; +export type { AnalysisBackend, AnalysisMode, AnalysisSummary } from "./analysisSummary.js"; /** Project indexing, navigation, reference search, and API-surface analysis. */ export { @@ -174,6 +175,8 @@ export { type ICodeReviewSession, type SessionOptions, type SessionStatus, + type SessionStats, + type SessionStaleReason, } from "./session.js"; /** Preset helpers for build, impact, and session defaults. */ diff --git a/src/indexer/build-cache/project-snapshot.ts b/src/indexer/build-cache/project-snapshot.ts index b1348fc3..001a31ce 100644 --- a/src/indexer/build-cache/project-snapshot.ts +++ b/src/indexer/build-cache/project-snapshot.ts @@ -5,20 +5,45 @@ import type { Edge, EdgeTo, Graph, Pos, Range } from "../../types.js"; import { buildGraphAdjacency } from "../../graphs/adjacency.js"; import { buildReferenceCandidateIndex } from "../reference-candidates.js"; import type { ProjectFileInfo } from "../../util/projectFiles.js"; -import { - SymbolKind, - type BuildOptions, - type ExportEntry, - type ImportBinding, - type ModuleIndex, - type ProjectIndex, - type SymbolDef, +import { BloomFilter, BloomFilterCache } from "../../util/bloomFilter.js"; +import { summarizeAnalysis } from "../../analysisSummary.js"; +import type { AnalysisSummary } from "../../analysisSummary.js"; +import { SymbolKind } from "../types.js"; +import type { + BackendReport, + BuildOptions, + ExportEntry, + GraphReport, + ImportBinding, + ModuleIndex, + ProjectIndex, + SymbolDef, } from "../types.js"; import { cacheRoot } from "./module-cache.js"; import type { ManifestFileEntry } from "./manifest.js"; const SNAPSHOT_SYMBOL_KINDS = new Set(Object.values(SymbolKind)); -const PROJECT_SNAPSHOT_VERSION = 1; +const PROJECT_SNAPSHOT_VERSION = 2; +const BLOOM_FILTER_MIN_SIZE = 1_000; +const BLOOM_FILTER_MAX_SIZE = 1_000_000; +const BLOOM_FILTER_MIN_HASH_COUNT = 1; +const BLOOM_FILTER_MAX_HASH_COUNT = 10; + +type SerializedBloomFilter = { + size: number; + hashCount: number; + bitsBase64: string; +}; + +type SnapshotAnalysisReport = { + backend?: BackendReport; + graph?: GraphReport; +}; + +export type LoadedProjectIndexSnapshot = { + index: ProjectIndex; + analysisReport?: SnapshotAnalysisReport; +}; type ProjectIndexSnapshotPayload = { version: number; @@ -31,6 +56,9 @@ type ProjectIndexSnapshotPayload = { projectRoot?: string; nativeMode?: ProjectIndex["nativeMode"]; projectFiles?: ProjectFileInfo[]; + bloomFilters?: Record; + analysis?: AnalysisSummary; + analysisReport?: SnapshotAnalysisReport; }; export function projectSnapshotFilesSignature(entries: ReadonlyMap): string { @@ -58,7 +86,7 @@ export async function tryLoadProjectIndexSnapshot( projectRoot: string, opts: BuildOptions | undefined, filesSignature: string, -): Promise { +): Promise { if ((opts?.cache ?? "off") !== "disk") return null; try { const payload = JSON.parse(await fsp.readFile(projectSnapshotPath(projectRoot, opts), "utf8")) as unknown; @@ -74,7 +102,8 @@ export async function tryLoadProjectIndexSnapshot( edges: payload.graph.edges, }; const modules = new Map(payload.modules.map((moduleIndex) => [moduleIndex.file, moduleIndex])); - return { + const shouldHydrateBloomFilters = opts?.useBloomFilters ?? true; + const index: ProjectIndex = { graph, graphAdjacency: buildGraphAdjacency(graph), modules, @@ -83,10 +112,21 @@ export async function tryLoadProjectIndexSnapshot( ...(payload.nativeMode ? { nativeMode: payload.nativeMode } : {}), exportCache: new Map(), scopeCache: new Map(), + ...(shouldHydrateBloomFilters && payload.bloomFilters + ? { bloomFilters: deserializeBloomFilterCache(payload.bloomFilters) } + : {}), ...(payload.projectFiles ? { projectFiles: payload.projectFiles } : {}), referenceCandidates: buildReferenceCandidateIndex(modules), ...(opts?.cache ? { cacheMode: opts.cache, cacheRootDir: cacheRoot(projectRoot, opts) } : {}), }; + return { + index: { + ...index, + ...(payload.analysis ? { analysis: payload.analysis } : {}), + }, + ...(payload.analysis ? { analysis: payload.analysis } : {}), + ...(payload.analysisReport ? { analysisReport: payload.analysisReport } : {}), + }; } catch { return null; } @@ -99,6 +139,11 @@ export async function writeProjectIndexSnapshot( filesSignature: string, ): Promise { if ((opts?.cache ?? "off") !== "disk") return; + const serializedBloomFilters = index.bloomFilters + ? serializeBloomFilterCache(index.bloomFilters, index.byFile.keys()) + : undefined; + const snapshotAnalysisReport = analysisReportFromBuildReport(index.buildReport); + const snapshotAnalysis = index.buildReport ? summarizeAnalysis({ index, report: index.buildReport }) : index.analysis; const payload: ProjectIndexSnapshotPayload = { version: PROJECT_SNAPSHOT_VERSION, filesSignature, @@ -112,6 +157,9 @@ export async function writeProjectIndexSnapshot( ? { nativeMode: normalizedSnapshotNativeMode(index.nativeMode) } : {}), ...(index.projectFiles ? { projectFiles: index.projectFiles } : {}), + ...(serializedBloomFilters ? { bloomFilters: serializedBloomFilters } : {}), + ...(snapshotAnalysis ? { analysis: snapshotAnalysis } : {}), + ...(snapshotAnalysisReport ? { analysisReport: snapshotAnalysisReport } : {}), }; try { const snapshotPath = projectSnapshotPath(projectRoot, opts); @@ -187,11 +235,159 @@ function isProjectIndexSnapshotPayload(value: unknown): value is ProjectIndexSna payload.modules.every(isModuleIndex) && (payload.projectRoot === undefined || typeof payload.projectRoot === "string") && (payload.nativeMode === undefined || isSnapshotNativeMode(payload.nativeMode)) && + (payload.bloomFilters === undefined || isSerializedBloomFilterRecord(payload.bloomFilters)) && + (payload.analysis === undefined || isAnalysisSummary(payload.analysis)) && + (payload.analysisReport === undefined || isSnapshotAnalysisReport(payload.analysisReport)) && (payload.projectFiles === undefined || (Array.isArray(payload.projectFiles) && payload.projectFiles.every(isProjectFileInfo))) ); } +function analysisReportFromBuildReport(report: ProjectIndex["buildReport"]): SnapshotAnalysisReport | undefined { + if (!report?.backend && !report?.graph) { + return undefined; + } + return { + ...(report.backend ? { backend: report.backend } : {}), + ...(report.graph ? { graph: report.graph } : {}), + }; +} + +function isSnapshotAnalysisReport(value: unknown): value is SnapshotAnalysisReport { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const report = value as Partial; + return ( + (report.backend === undefined || isBackendReport(report.backend)) && + (report.graph === undefined || isGraphReport(report.graph)) + ); +} + +function isBackendReport(value: unknown): value is BackendReport { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const report = value as Partial; + return ( + !!report.native && + isNativeBackendReport(report.native) && + (report.parser === undefined || isParserBackendDegradationReport(report.parser)) + ); +} + +function isNativeBackendReport(value: unknown): value is BackendReport["native"] { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const report = value as Partial; + return ( + typeof report.available === "boolean" && + typeof report.enabled === "boolean" && + Array.isArray(report.supportedLanguageIds) && + report.supportedLanguageIds.every((languageId) => typeof languageId === "string") && + typeof report.filesUsed === "number" && + typeof report.filesFellBack === "number" && + isUnknownRecord(report.fallbackReasons) && + isUnknownRecord(report.byLanguage) && + Array.isArray(report.errors) + ); +} + +function isParserBackendDegradationReport(value: unknown): value is NonNullable { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const report = value as Partial>; + return typeof report.total === "number" && isNumberRecord(report.byLanguage) && Array.isArray(report.files); +} + +function isGraphReport(value: unknown): value is GraphReport { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const report = value as Partial; + return !!report.fallbackImportExtraction && isFallbackImportExtractionReport(report.fallbackImportExtraction); +} + +function isFallbackImportExtractionReport(value: unknown): value is GraphReport["fallbackImportExtraction"] { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const report = value as Partial; + return ( + typeof report.total === "number" && + isNumberRecord(report.byLanguage) && + isUnknownRecord(report.files) && + (report.byReason === undefined || isNumberRecord(report.byReason)) + ); +} + +function isUnknownRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function isNumberRecord(value: unknown): value is Record { + return isUnknownRecord(value) && Object.values(value).every((entry) => typeof entry === "number"); +} + +function isAnalysisSummary(value: unknown): value is AnalysisSummary { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const summary = value as Partial; + return ( + (summary.mode === "semantic" || summary.mode === "mixed" || summary.mode === "reduced") && + (summary.backend === "native" || + summary.backend === "mixed" || + summary.backend === "graph-only" || + summary.backend === "unknown") && + typeof summary.parserDegradedFiles === "number" && + typeof summary.fallbackImportExtractionFiles === "number" && + typeof summary.nativeFilesUsed === "number" && + typeof summary.nativeFilesFellBack === "number" && + typeof summary.label === "string" + ); +} + +function serializeBloomFilterCache( + cache: BloomFilterCache, + files: Iterable, +): Record | undefined { + const serialized: Record = {}; + for (const file of files) { + const filter = cache.get(file); + if (!filter) continue; + const metadata = filter.getMetadata(); + serialized[file] = { + size: metadata.size, + hashCount: metadata.hashCount, + bitsBase64: filter.toBuffer().toString("base64"), + }; + } + return Object.keys(serialized).length ? serialized : undefined; +} + +function deserializeBloomFilterCache(serialized: Record): BloomFilterCache { + const cache = new BloomFilterCache(); + for (const [file, filter] of Object.entries(serialized)) { + cache.set(file, BloomFilter.fromBuffer(Buffer.from(filter.bitsBase64, "base64"), filter.size, filter.hashCount)); + } + return cache; +} + +function isSerializedBloomFilterRecord(value: unknown): value is Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + return Object.values(value).every(isSerializedBloomFilter); +} + +function isSerializedBloomFilter(value: unknown): value is SerializedBloomFilter { + if (!value || typeof value !== "object") return false; + const filter = value as Partial; + if ( + typeof filter.size !== "number" || + !Number.isInteger(filter.size) || + filter.size < BLOOM_FILTER_MIN_SIZE || + filter.size > BLOOM_FILTER_MAX_SIZE || + typeof filter.hashCount !== "number" || + !Number.isInteger(filter.hashCount) || + filter.hashCount < BLOOM_FILTER_MIN_HASH_COUNT || + filter.hashCount > BLOOM_FILTER_MAX_HASH_COUNT || + typeof filter.bitsBase64 !== "string" + ) { + return false; + } + const maxBytes = Math.ceil(filter.size / 8); + const maxBase64Length = Math.ceil(maxBytes / 3) * 4; + return filter.bitsBase64.length === maxBase64Length; +} + function isModuleIndex(value: unknown): value is ModuleIndex { if (!value || typeof value !== "object") return false; const moduleIndex = value as Partial; diff --git a/src/indexer/build-index.ts b/src/indexer/build-index.ts index 1428a21d..02bc00a6 100644 --- a/src/indexer/build-index.ts +++ b/src/indexer/build-index.ts @@ -416,7 +416,7 @@ function createIndexBuildRunState( graphOptions = normalizeGraphOptions(opts?.graph), ): IndexBuildRunState { const report = opts?.report; - initNativeBackendReport(report); + if (report) initNativeBackendReport(report); const cacheMode = opts?.cache ?? "off"; return { normalizedProjectRoot: normalizePath(projectRoot), @@ -731,6 +731,7 @@ async function buildIndexFromFileListShared( parsedMap, bloomFilterCache, ...(projectFiles !== undefined ? { projectFiles } : {}), + buildReport: report, manifestEntries: manifestEntriesForIndex, }); if (manifestEntries) { @@ -998,15 +999,11 @@ export async function buildProjectIndexIncremental( }; invalidateCachedDependents(); if (fileReport) fileReport.changed = changedFiles.size; - if ( - !changedFiles.size && - !deletedTrackedFiles.size && - Object.keys(trackedEntries).length === Object.keys(manifest.files ?? {}).length && - !report - ) { + if (!changedFiles.size && !deletedTrackedFiles.size) { const filesSignature = projectSnapshotFilesSignature(new Map(Object.entries(trackedEntries))); - const snapshot = await tryLoadProjectIndexSnapshot(projectRoot, opts, filesSignature); - if (snapshot) { + const snapshotLoad = await tryLoadProjectIndexSnapshot(projectRoot, opts, filesSignature); + if (snapshotLoad) { + const snapshot = snapshotLoad.index; snapshot.projectFiles = await discoverProjectFiles(projectRoot, { ...(opts?.logLevel ? { logLevel: opts.logLevel } : {}), }); @@ -1015,14 +1012,10 @@ export async function buildProjectIndexIncremental( snapshot.cacheMode = opts.cache; snapshot.cacheRootDir = cacheRoot(projectRoot, opts); } - if (opts?.useBloomFilters ?? true) { - const cache = new (await import("../util/bloomFilter.js")).BloomFilterCache(); - await mapLimit([...allFiles], conc, async (file) => { - const filter = await buildBloomFilterForFile(file); - if (filter) cache.set(file, filter); - }); - snapshot.bloomFilters = cache; + if (fileReport) { + fileReport.cached = allFiles.size; } + if (timings) timings.graphMs = 0; await writeIndexManifestSnapshot({ projectRoot, opts, @@ -1032,6 +1025,18 @@ export async function buildProjectIndexIncremental( manifestReport, }); if (timings) timings.totalMs = Math.round(performance.now() - totalStart); + if (report) { + if (snapshotLoad.analysisReport?.backend) { + report.backend = snapshotLoad.analysisReport.backend; + } + if (snapshotLoad.analysisReport?.graph) { + report.graph = snapshotLoad.analysisReport.graph; + } + if (!report.backend) { + initNativeBackendReport(report); + } + snapshot.buildReport = report; + } return snapshot; } } @@ -1174,6 +1179,7 @@ export async function buildProjectIndexIncremental( parsedMap, bloomFilterCache, manifestEntries: projectIndexManifestEntries(manifestEntries), + buildReport: report, }); await writeProjectIndexSnapshot(projectRoot, opts, index, projectSnapshotFilesSignature(manifestEntries)); return index; diff --git a/src/indexer/finalize.ts b/src/indexer/finalize.ts index dd1d6b46..e3d8a1dd 100644 --- a/src/indexer/finalize.ts +++ b/src/indexer/finalize.ts @@ -21,6 +21,7 @@ export async function finalizeProjectIndex(args: { bloomFilterCache: BloomFilterCache | undefined; projectFiles?: ProjectFileInfo[] | Promise; manifestEntries?: Map; + buildReport?: BuildReport | undefined; }): Promise { if (args.timings) args.timings.totalMs = Math.round(performance.now() - args.totalStart); const projectFiles = await (args.projectFiles ?? @@ -42,6 +43,7 @@ export async function finalizeProjectIndex(args: { projectFiles, referenceCandidates: buildReferenceCandidateIndex(args.modules), ...(args.manifestEntries ? { manifestEntries: args.manifestEntries } : {}), + ...(args.buildReport ? { buildReport: args.buildReport } : {}), ...(args.opts?.cache ? { cacheMode: args.opts.cache, cacheRootDir: cacheRoot(args.projectRoot, args.opts) } : {}), }; } diff --git a/src/indexer/types.ts b/src/indexer/types.ts index 13b86ba4..25ecb0b3 100644 --- a/src/indexer/types.ts +++ b/src/indexer/types.ts @@ -90,6 +90,15 @@ export type SqlNavigationCache = { basename: Map; }; }; +export type CachedAnalysisSummary = { + mode: "semantic" | "mixed" | "reduced"; + backend: "native" | "mixed" | "graph-only" | "unknown"; + parserDegradedFiles: number; + fallbackImportExtractionFiles: number; + nativeFilesUsed: number; + nativeFilesFellBack: number; + label: string; +}; export type ProjectIndex = { graph: Graph; @@ -107,6 +116,8 @@ export type ProjectIndex = { sqlNavigation?: SqlNavigationCache; manifestEntries?: Map; cacheMode?: BuildOptions["cache"]; + buildReport?: BuildReport; + analysis?: CachedAnalysisSummary; cacheRootDir?: string; }; diff --git a/src/review.ts b/src/review.ts index aea32376..da43c815 100644 --- a/src/review.ts +++ b/src/review.ts @@ -2,6 +2,7 @@ import { performance } from "node:perf_hooks"; import { findDuplicateContexts, type DuplicateGroup, type DuplicateUnitRef } from "./duplicates.js"; import type { FileId } from "./types.js"; import { buildProjectIndexIncremental } from "./indexer/build-index.js"; +import { summarizeAnalysis } from "./analysisSummary.js"; import { type IncrementalBuildOptions, type ProjectIndex, type SymbolDef } from "./indexer/types.js"; import { symbolId } from "./indexer/symbols.js"; import type { GraphBuildOptions } from "./graphs/types.js"; @@ -231,6 +232,9 @@ export async function buildReviewReport(projectRoot: string, opts: ReviewOptions const report: ReviewReport = { schemaVersion: REVIEW_SCHEMA_VERSION, status: "no_changes", + ...(reviewReport?.indexReport + ? { analysis: summarizeAnalysis({ nativeMode: appliedOptions.native, report: reviewReport.indexReport }) } + : {}), projectFiles, summary: { filesChanged: 0, symbolsChanged: 0, candidateTests: 0 }, riskSummary, @@ -329,6 +333,10 @@ export async function buildReviewReport(projectRoot: string, opts: ReviewOptions changedSymbolIds, candidateTests, graphDelta, + analysis: summarizeAnalysis({ + index, + ...(reviewReport?.indexReport ? { report: reviewReport.indexReport } : {}), + }), ...(sqlContext ? { sqlContext } : {}), diagnostics, riskRelevantParseFailures, diff --git a/src/review/report.ts b/src/review/report.ts index cc1738e6..e98520bd 100644 --- a/src/review/report.ts +++ b/src/review/report.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import type { AnalysisSummary } from "../analysisSummary.js"; import type { CandidateTestFile } from "../impact/context.js"; import { type ProjectIndex } from "../indexer/types.js"; import { collectSqlReviewContext, type SqlReviewContext } from "../sql/review.js"; @@ -62,6 +63,7 @@ export function assembleReviewReport(input: { changedSymbolIds: string[]; candidateTests: CandidateTestFile[]; graphDelta: Edge[]; + analysis?: AnalysisSummary; sqlContext?: SqlReviewContext; diagnostics: ReviewDiagnostics; riskRelevantParseFailures: number; @@ -70,6 +72,7 @@ export function assembleReviewReport(input: { const report: ReviewReport = { schemaVersion: REVIEW_SCHEMA_VERSION, status: "ok", + ...(input.analysis ? { analysis: input.analysis } : {}), projectFiles: input.projectFiles, summary: { filesChanged: input.summaries.length, diff --git a/src/review/types.ts b/src/review/types.ts index ab4a4406..ac444b0d 100644 --- a/src/review/types.ts +++ b/src/review/types.ts @@ -1,4 +1,5 @@ import type { CandidateTestFile } from "../impact/context.js"; +import type { AnalysisSummary } from "../analysisSummary.js"; import type { CallCompatibilityHint, FileChange } from "../impact/types.js"; import type { BuildReport, IncrementalBuildOptions, ProjectIndex } from "../indexer/types.js"; import type { SqlReviewContext } from "../sql/review.js"; @@ -41,6 +42,7 @@ export type ReviewReport = { status: "ok" | "no_changes"; base?: string; head?: string; + analysis?: AnalysisSummary; projectFiles?: ProjectFileInfo[]; summary: { filesChanged: number; diff --git a/src/session.ts b/src/session.ts index 46b609c4..fe43f52d 100644 --- a/src/session.ts +++ b/src/session.ts @@ -3,11 +3,14 @@ * Maintains warm caches across multiple queries for better agent UX */ +import fs from "node:fs"; import path from "node:path"; import { type ProjectIndex, type BuildOptions, + type BuildReport, type GoToResult, + type IncrementalBuildOptions, type Reference, type SymbolDef, } from "./indexer/types.js"; @@ -22,7 +25,9 @@ import { } from "./impact/index.js"; import { analyzeImpactStreaming, type ImpactStreamChunk } from "./impact/streaming.js"; import { getSessionPreset, mergePreset, type PresetName } from "./presets.js"; -import { resolveFilePathWithinRoot } from "./util/paths.js"; +import { hasDiscoveryOptions, loadCodegraphConfig, mergeDiscoveryOptions } from "./config.js"; +import { normalizePath, resolveFilePathWithinRoot } from "./util/paths.js"; +import { listProjectFiles } from "./util/projectFiles.js"; export type SessionOptions = { /** Project root directory */ @@ -43,6 +48,20 @@ export type SessionOptions = { export type SessionStatus = "initializing" | "ready" | "expired" | "error"; +export type SessionStaleReason = "tracked_files_changed" | "config_changed"; + +export type SessionStats = { + status: SessionStatus; + fileCount: number; + symbolCount: number; + lastActivity: Date; + timeUntilExpiration: number; + stale: boolean; + staleReason?: SessionStaleReason; + lastRefreshAt?: Date; + lastRefreshReason?: "initialization" | "manual" | "stale_check"; +}; + type SessionIdentity = { root: string; timeout: number; @@ -162,13 +181,7 @@ export interface ICodeReviewSession { goToDefinition(params: { file: string; line: number; column: number }): Promise; refresh(): Promise; dispose(): void; - getStats(): { - status: SessionStatus; - fileCount: number; - symbolCount: number; - lastActivity: Date; - timeUntilExpiration: number; - }; + getStats(): SessionStats; } /** @@ -176,7 +189,10 @@ export interface ICodeReviewSession { * Use this to avoid rebuilding the index for multiple queries */ export class CodeReviewSession implements ICodeReviewSession { + private static readonly STALE_CHECK_INTERVAL_MS = 5_000; + private index: ProjectIndex | null = null; + private buildReport: BuildReport | undefined; private status: SessionStatus = "initializing"; private lastActivity: number = Date.now(); private timeout: number; @@ -184,8 +200,20 @@ export class CodeReviewSession implements ICodeReviewSession { private buildOptions: BuildOptions | undefined; private incremental: boolean; private initPromise: Promise | null = null; + private refreshPromise: Promise | null = null; private identityFingerprint: string; private lifecycleVersion = 0; + private trackedFileSignatures = new Map(); + private configSignature: string | undefined; + private trackedDirectorySignatures = new Map(); + private staleReason: SessionStaleReason | undefined; + private forceFullRefreshOnNextStaleCheck = false; + private lastStaleCheckAt = 0; + private lastTrackedFileScanAt = 0; + private lastImpactProjectDriftCheckAt = 0; + private lastPassiveStaleCheckAt = 0; + private lastRefreshAt: number | undefined; + private lastRefreshReason: "initialization" | "manual" | "stale_check" | undefined; constructor(options: SessionOptions) { const identity = resolveSessionIdentity(options); @@ -210,12 +238,43 @@ export class CodeReviewSession implements ICodeReviewSession { getRoot(): string { return this.root; } + private async currentBuildOptions(): Promise { + const config = await loadCodegraphConfig(this.root); + const discovery = mergeDiscoveryOptions(config.discovery, this.buildOptions?.discovery); + if (!hasDiscoveryOptions(discovery)) { + return this.buildOptions; + } + return { ...this.buildOptions, discovery }; + } - private async buildIndex(): Promise { - if (this.incremental) { - return await buildProjectIndexIncremental(this.root, this.buildOptions); + private async buildIndex(options: { forceFull?: boolean } = {}): Promise<{ + index: ProjectIndex; + report: BuildReport; + projectFiles: string[]; + }> { + const currentBuildOptions = await this.currentBuildOptions(); + if (options.forceFull && this.incremental) { + const projectFiles = await this.currentProjectFiles(currentBuildOptions); + const report: BuildReport = { timings: {} }; + const buildOptions: IncrementalBuildOptions = { ...currentBuildOptions, files: projectFiles, report }; + const index = await buildProjectIndexIncremental(this.root, buildOptions); + return { index, report: index.buildReport ?? report, projectFiles }; } - return await buildProjectIndex(this.root, this.buildOptions); + if (options.forceFull) { + const report: BuildReport = { timings: {} }; + const buildOptions: BuildOptions = { ...currentBuildOptions, report }; + const index = await buildProjectIndex(this.root, buildOptions); + const projectFiles = this.indexedProjectFiles(index); + return { index, report: index.buildReport ?? report, projectFiles }; + } + const report: BuildReport = { timings: {} }; + const buildOptions: BuildOptions = { ...currentBuildOptions, report }; + const index = this.incremental + ? await buildProjectIndexIncremental(this.root, buildOptions) + : await buildProjectIndex(this.root, buildOptions); + const projectFiles = this.indexedProjectFiles(index); + const buildReport = index.buildReport ?? report; + return { index, report: buildReport, projectFiles }; } private createDisposedDuringOperationError(operation: string): Error { @@ -228,9 +287,216 @@ export class CodeReviewSession implements ICodeReviewSession { } } - private commitReadyIndex(index: ProjectIndex): void { + private trackedSignatureFromManifest(sig: string): string { + const parts = sig.split(":"); + if (parts.length >= 2) { + return `${parts[0]}:${parts[1]}`; + } + return sig; + } + + private statSignature(file: string): string { + try { + const stat = fs.statSync(file); + return `${stat.mtimeMs}:${stat.size}`; + } catch { + return "0:0"; + } + } + + private directorySignature(directory: string): string { + return this.statSignature(directory); + } + + private configFilePath(): string { + return path.join(this.root, "codegraph.config.json"); + } + + private async currentProjectFiles(buildOptions?: BuildOptions): Promise { + const discoveryOptions = { + ...buildOptions?.discovery, + ...(buildOptions?.logLevel ? { logLevel: buildOptions.logLevel } : {}), + }; + return await listProjectFiles(this.root, undefined, discoveryOptions); + } + + private indexedProjectFiles(index: ProjectIndex): string[] { + const files = index.manifestEntries ? [...index.manifestEntries.keys()] : [...index.byFile.keys()]; + return files.map((file) => { + if (path.isAbsolute(file)) { + return file; + } + return path.resolve(this.root, file); + }); + } + + private isPathInsideRoot(candidate: string): boolean { + const relative = path.relative(this.root, candidate); + return relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative)); + } + + private directoriesForProjectFiles(files: Iterable): Set { + const directories = new Set([this.root]); + for (const file of files) { + let directory = path.dirname(path.resolve(file)); + while (this.isPathInsideRoot(directory)) { + directories.add(directory); + if (directory === this.root) break; + const parent = path.dirname(directory); + if (parent === directory) break; + directory = parent; + } + } + return directories; + } + + private directorySignatures(files: Iterable): Map { + const signatures = new Map(); + for (const directory of this.directoriesForProjectFiles(files)) { + signatures.set(directory, this.directorySignature(directory)); + } + return signatures; + } + + private projectDirectoriesChanged(): boolean { + if (!this.trackedDirectorySignatures.size) { + return true; + } + for (const [directory, signature] of this.trackedDirectorySignatures) { + if (this.directorySignature(directory) !== signature) { + return true; + } + } + return false; + } + private captureFreshnessBaseline( + index: ProjectIndex, + reason: "initialization" | "manual" | "stale_check", + projectFiles: string[], + ): void { + const trackedEntries = index.manifestEntries?.size + ? [...index.manifestEntries].map(([file, entry]) => [file, this.trackedSignatureFromManifest(entry.sig)] as const) + : [...index.byFile.keys()].map((file) => [file, this.statSignature(file)] as const); + this.trackedFileSignatures = new Map(trackedEntries); + this.trackedDirectorySignatures = this.directorySignatures(projectFiles); + this.configSignature = this.statSignature(this.configFilePath()); + this.staleReason = undefined; + this.lastStaleCheckAt = Date.now(); + this.lastPassiveStaleCheckAt = 0; + this.lastTrackedFileScanAt = 0; + this.lastImpactProjectDriftCheckAt = 0; + this.lastRefreshAt = this.lastStaleCheckAt; + this.lastRefreshReason = reason; + } + + private refreshNeededFromTrackedFiles(): SessionStaleReason | undefined { + if (!this.trackedFileSignatures.size) { + const configSignature = this.statSignature(this.configFilePath()); + if (configSignature !== this.configSignature) { + return "config_changed"; + } + return undefined; + } + for (const [file, signature] of this.trackedFileSignatures) { + if (this.statSignature(file) !== signature) { + return "tracked_files_changed"; + } + } + const configSignature = this.statSignature(this.configFilePath()); + if (configSignature !== this.configSignature) { + return "config_changed"; + } + return undefined; + } + + private refreshNeededFromTrackedFile(file: string): SessionStaleReason | undefined { + const resolved = normalizePath(path.resolve(file)); + const signature = this.trackedFileSignatures.get(resolved) ?? this.trackedFileSignatures.get(file); + if (!signature) { + return undefined; + } + return this.statSignature(resolved) !== signature ? "tracked_files_changed" : undefined; + } + + private checkForStaleness(options: { force?: boolean } = {}): void { + if (this.status !== "ready" || !this.index) return; + const now = Date.now(); + if (!options.force && now - this.lastPassiveStaleCheckAt < CodeReviewSession.STALE_CHECK_INTERVAL_MS) return; + this.lastPassiveStaleCheckAt = now; + + const configSignature = this.statSignature(this.configFilePath()); + if (configSignature !== this.configSignature) { + this.staleReason = "config_changed"; + this.forceFullRefreshOnNextStaleCheck = false; + return; + } + + const projectFilesChanged = this.projectDirectoriesChanged(); + this.staleReason = projectFilesChanged ? "tracked_files_changed" : undefined; + this.forceFullRefreshOnNextStaleCheck = projectFilesChanged; + } + + private checkForStalenessNow(options: { force?: boolean; file?: string; scanTrackedFiles?: boolean } = {}): void { + if (this.status !== "ready" || !this.index) return; + const now = Date.now(); + const targetReason = options.file ? this.refreshNeededFromTrackedFile(options.file) : undefined; + if (targetReason) { + this.lastStaleCheckAt = now; + this.staleReason = targetReason; + this.forceFullRefreshOnNextStaleCheck = false; + return; + } + + const trackedScanDue = + options.force || + (options.scanTrackedFiles && now - this.lastTrackedFileScanAt >= CodeReviewSession.STALE_CHECK_INTERVAL_MS); + const navigationProjectDriftDue = + options.force || + (!options.scanTrackedFiles && now - this.lastStaleCheckAt >= CodeReviewSession.STALE_CHECK_INTERVAL_MS); + const impactProjectDriftDue = + options.force || + (options.scanTrackedFiles && + now - this.lastImpactProjectDriftCheckAt >= CodeReviewSession.STALE_CHECK_INTERVAL_MS); + if (!trackedScanDue && !navigationProjectDriftDue && !impactProjectDriftDue) return; + + if (trackedScanDue) { + this.lastTrackedFileScanAt = now; + const trackedReason = this.refreshNeededFromTrackedFiles(); + if (trackedReason) { + this.staleReason = trackedReason; + this.forceFullRefreshOnNextStaleCheck = false; + return; + } + } + + if (!navigationProjectDriftDue && !impactProjectDriftDue) return; + if (navigationProjectDriftDue) { + this.lastStaleCheckAt = now; + } + if (impactProjectDriftDue) { + this.lastImpactProjectDriftCheckAt = now; + } + const configSignature = this.statSignature(this.configFilePath()); + if (configSignature !== this.configSignature) { + this.staleReason = "config_changed"; + this.forceFullRefreshOnNextStaleCheck = false; + return; + } + const projectFilesChanged = this.projectDirectoriesChanged(); + this.staleReason = projectFilesChanged ? "tracked_files_changed" : undefined; + this.forceFullRefreshOnNextStaleCheck = projectFilesChanged; + } + private commitReadyIndex( + index: ProjectIndex, + reason: "initialization" | "manual" | "stale_check", + report: BuildReport, + projectFiles: string[], + ): void { this.index = index; + this.buildReport = report; this.status = "ready"; + this.captureFreshnessBaseline(index, reason, projectFiles); + this.forceFullRefreshOnNextStaleCheck = false; this.touch(); } @@ -251,9 +517,9 @@ export class CodeReviewSession implements ICodeReviewSession { const previousStatus = this.status; try { this.status = "initializing"; - const nextIndex = await this.buildIndex(); + const nextBuild = await this.buildIndex(); this.assertLifecycleVersion(lifecycleVersion, "initialization"); - this.commitReadyIndex(nextIndex); + this.commitReadyIndex(nextBuild.index, "initialization", nextBuild.report, nextBuild.projectFiles); } catch (error) { if (this.lifecycleVersion === lifecycleVersion) { this.status = previousStatus === "expired" ? "expired" : "error"; @@ -276,6 +542,7 @@ export class CodeReviewSession implements ICodeReviewSession { */ isReady(): boolean { this.checkExpiration(); + this.checkForStaleness(); return this.status === "ready"; } @@ -284,6 +551,7 @@ export class CodeReviewSession implements ICodeReviewSession { */ getStatus(): SessionStatus { this.checkExpiration(); + this.checkForStaleness(); return this.status; } @@ -316,14 +584,41 @@ export class CodeReviewSession implements ICodeReviewSession { return this.index; } + private async ensureFreshIndex( + options: { force?: boolean; file?: string; scanTrackedFiles?: boolean } = {}, + ): Promise { + this.checkExpiration(); + if (this.refreshPromise) { + await this.refreshPromise; + return this.getIndex(); + } + this.checkForStalenessNow(options); + const index = this.getIndex(); + if (!this.staleReason) { + return index; + } + await this.refreshInternal("stale_check"); + return this.getIndex(); + } + + private async refreshForExistingUnindexedFile(file: string): Promise { + if (!fs.existsSync(file)) { + return undefined; + } + this.staleReason = "tracked_files_changed"; + this.forceFullRefreshOnNextStaleCheck = true; + await this.refreshInternal("stale_check"); + return this.getIndex(); + } + /** * Analyze impact from a diff * Results are cached in the warm index */ async analyzeImpact(options: ImpactOptions): Promise { - const index = this.getIndex(); + const index = await this.ensureFreshIndex({ scanTrackedFiles: true }); requireSessionImpactProvider(options); - return await analyzeImpactFromDiff(this.root, index, options); + return await analyzeImpactFromDiff(this.root, index, options, { buildReport: this.buildReport }); } /** @@ -331,53 +626,91 @@ export class CodeReviewSession implements ICodeReviewSession { * Better for agents as they can start processing immediately */ async *analyzeImpactStream(options: ImpactStreamingOptions): AsyncGenerator { - const index = this.getIndex(); + const index = await this.ensureFreshIndex({ scanTrackedFiles: true }); requireSessionImpactProvider(options); - yield* analyzeImpactStreaming(this.root, index, options); + yield* analyzeImpactStreaming(this.root, index, options, { buildReport: this.buildReport }); } /** * Find references to a symbol */ async findReferences(params: { file: string; line: number; column: number }): Promise { - const index = this.getIndex(); const resolved = resolveSessionFileInput(this.root, params.file, "Session file"); if (resolved.status === "error") { return resolved; } - return await findReferences(index, { + const index = await this.ensureFreshIndex({ file: resolved.file }); + const result = await findReferences(index, { ...params, file: resolved.file, }); + if (result.status === "not_found" && result.reason === "File not indexed") { + const refreshedIndex = await this.refreshForExistingUnindexedFile(resolved.file); + if (refreshedIndex) { + return await findReferences(refreshedIndex, { + ...params, + file: resolved.file, + }); + } + } + return result; } /** * Go to definition of a symbol */ async goToDefinition(params: { file: string; line: number; column: number }): Promise { - const index = this.getIndex(); const resolved = resolveSessionFileInput(this.root, params.file, "Session file"); if (resolved.status === "error") { return resolved; } - return await goToDefinition(index, { + const index = await this.ensureFreshIndex({ file: resolved.file }); + const result = await goToDefinition(index, { ...params, file: resolved.file, }); + if (result.status === "not_found" && result.reason === "File not indexed") { + const refreshedIndex = await this.refreshForExistingUnindexedFile(resolved.file); + if (refreshedIndex) { + return await goToDefinition(refreshedIndex, { + ...params, + file: resolved.file, + }); + } + } + return result; } /** - * Refresh the index (incremental rebuild) + * Refresh the index (manual full rebuild; stale checks full rebuild only after file-set drift) */ - async refresh(): Promise { + private async refreshInternal(refreshReason: "manual" | "stale_check"): Promise { + if (this.refreshPromise) { + await this.refreshPromise; + return; + } + const refreshPromise = this.performRefresh(refreshReason); + this.refreshPromise = refreshPromise; + try { + await refreshPromise; + } finally { + if (this.refreshPromise === refreshPromise) { + this.refreshPromise = null; + } + } + } + + private async performRefresh(refreshReason: "manual" | "stale_check"): Promise { const previousIndex = this.index; const previousStatus = this.status; const lifecycleVersion = this.lifecycleVersion; this.status = "initializing"; try { - const nextIndex = await this.buildIndex(); + const forceFull = + refreshReason === "manual" || (refreshReason === "stale_check" && this.forceFullRefreshOnNextStaleCheck); + const nextBuild = await this.buildIndex({ forceFull }); this.assertLifecycleVersion(lifecycleVersion, "refresh"); - this.commitReadyIndex(nextIndex); + this.commitReadyIndex(nextBuild.index, refreshReason, nextBuild.report, nextBuild.projectFiles); } catch (error) { if (this.lifecycleVersion !== lifecycleVersion) { throw error; @@ -392,6 +725,10 @@ export class CodeReviewSession implements ICodeReviewSession { } } + async refresh(): Promise { + await this.refreshInternal("manual"); + } + /** * Dispose of the session and free resources */ @@ -405,25 +742,27 @@ export class CodeReviewSession implements ICodeReviewSession { /** * Get session statistics */ - getStats(): { - status: SessionStatus; - fileCount: number; - symbolCount: number; - lastActivity: Date; - timeUntilExpiration: number; - } { + getStats(): SessionStats { this.checkExpiration(); + this.checkForStaleness(); const index = this.index; const fileCount = index?.byFile.size ?? 0; const symbolCount = index ? Array.from(index.byFile.values()).reduce((sum, mod) => sum + mod.locals.length, 0) : 0; + const ready = this.status === "ready"; + const staleReason = ready ? this.staleReason : undefined; + return { status: this.status, fileCount, symbolCount, lastActivity: new Date(this.lastActivity), timeUntilExpiration: this.status === "ready" ? Math.max(0, this.timeout - (Date.now() - this.lastActivity)) : 0, + stale: !!staleReason, + ...(staleReason ? { staleReason } : {}), + ...(this.lastRefreshAt ? { lastRefreshAt: new Date(this.lastRefreshAt) } : {}), + ...(this.lastRefreshReason ? { lastRefreshReason: this.lastRefreshReason } : {}), }; } } diff --git a/src/sqlite-driver.ts b/src/sqlite-driver.ts index a20133bc..b766ebcb 100644 --- a/src/sqlite-driver.ts +++ b/src/sqlite-driver.ts @@ -26,6 +26,9 @@ type NodeSqliteModule = { constants: SqliteConstants; }; type SqliteParameterInput = SqliteValue | readonly SqliteValue[]; +type ReadonlyAuthorizerDatabase = DatabaseSync & { + setAuthorizer?: (callback: (actionCode: number) => number) => void; +}; const requireNodeModule = createRequire(import.meta.url); let sqliteModule: NodeSqliteModule | undefined; @@ -98,7 +101,7 @@ export class SqliteStatement { } export class SqliteDatabase { - private readonly db: DatabaseSync; + private readonly db: ReadonlyAuthorizerDatabase; constructor(filePath: PathLike, options?: { readonly?: boolean }) { const sqlite = loadNodeSqlite(); @@ -108,7 +111,7 @@ export class SqliteDatabase { }); if (options?.readonly) { const { constants } = sqlite; - this.db.setAuthorizer((actionCode) => + this.db.setAuthorizer?.((actionCode) => isReadOnlyAllowedAction(actionCode, constants) ? constants.SQLITE_OK : constants.SQLITE_DENY, ); } diff --git a/tests/agent-search.test.ts b/tests/agent-search.test.ts index f98169f7..f91ff77c 100644 --- a/tests/agent-search.test.ts +++ b/tests/agent-search.test.ts @@ -12,6 +12,16 @@ import type { Edge, Graph, Range } from "../src/types.js"; import { countingSession } from "./helpers/agent.js"; import { isSymlinkUnavailable } from "./helpers/filesystem.js"; +const DEFAULT_ANALYSIS = { + mode: "semantic" as const, + backend: "unknown" as const, + parserDegradedFiles: 0, + fallbackImportExtractionFiles: 0, + nativeFilesUsed: 0, + nativeFilesFellBack: 0, + label: "semantic", +}; + async function mkRepo(): Promise { const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-agent-search-")); await fs.mkdir(path.join(root, "src")); @@ -89,10 +99,16 @@ function moduleIndex(file: string, locals: SymbolDef[]): ModuleIndex { }; } -function snapshotSession(snapshot: AgentProjectSnapshot): AgentSession { +function snapshotSession( + snapshot: Omit & { analysis?: AgentProjectSnapshot["analysis"] }, +): AgentSession { + const fullSnapshot: AgentProjectSnapshot = { + ...snapshot, + analysis: snapshot.analysis ?? DEFAULT_ANALYSIS, + }; return { - root: snapshot.root, - loadProject: async () => snapshot, + root: fullSnapshot.root, + loadProject: async () => fullSnapshot, invalidate: () => undefined, }; } @@ -113,6 +129,8 @@ describe("agent search", () => { expect(response.results[0]?.evidence.some((entry) => entry.source === "symbol")).toBeTruthy(); expect(response.results[0]?.neighbors.some((entry) => entry.file?.endsWith("src/api.ts"))).toBeTruthy(); expect(response.results[0]?.followUps.some((cmd) => cmd.includes("codegraph refs"))).toBeTruthy(); + expect(response.results[0]?.provenance.surface).toBe("code"); + expect(response.results[0]?.provenance.capability).toBe("semantic"); expect(response.results.some((result) => result.file.endsWith("src/auth.ts"))).toBeTruthy(); }); @@ -134,6 +152,16 @@ describe("agent search", () => { const response = await searchCodegraph({ root, query: "agent search", mode: "path", limit: 5 }); expect(response.results.some((result) => result.file === "docs/agent-search.md")).toBe(true); + expect(response.analysis).toMatchObject({ + mode: "reduced", + backend: "unknown", + label: "path-only", + }); + expect(response.results[0]?.provenance).toMatchObject({ + capability: "text", + analysisMode: "reduced", + backend: "unknown", + }); expect(buildSpy).not.toHaveBeenCalled(); }); @@ -175,6 +203,7 @@ describe("agent search", () => { index, fileGraph, symbolGraph: { nodes: new Map(), edges: [] }, + analysis: DEFAULT_ANALYSIS, }; }, invalidate: () => undefined, @@ -225,15 +254,15 @@ describe("agent search", () => { expect(symbolGraphSpy).not.toHaveBeenCalled(); }); - it("ranks exact documentation phrases above broader symbol matches for natural language", async () => { + it("keeps implementation results ahead of documentation phrases in hybrid mode", async () => { const root = await mkRepo(); const response = await searchCodegraph({ root, query: "call compatibility", mode: "hybrid", limit: 5 }); - expect(response.results[0]?.kind).toBe("chunk"); - expect(response.results[0]?.file).toBe("docs/agent-search.md"); - expect(response.results[0]?.rankReasons).toContain("exact phrase match in docs text"); + expect(response.results[0]?.kind).toBe("symbol"); + expect(response.results[0]?.label).toBe("callCompatibility"); expect(response.results.some((result) => result.label === "callCompatibility")).toBeTruthy(); + expect(response.results.some((result) => result.file === "docs/agent-search.md")).toBeTruthy(); }); it("keeps symbol-first ranking for identifier-like queries", async () => { diff --git a/tests/cache-invalidation.test.ts b/tests/cache-invalidation.test.ts index 4243faf2..5993d5a0 100644 --- a/tests/cache-invalidation.test.ts +++ b/tests/cache-invalidation.test.ts @@ -6,6 +6,7 @@ import fs from "node:fs"; import { DatabaseSync } from "node:sqlite"; import { buildProjectIndex, buildProjectIndexIncremental, type BuildReport } from "../src/index.js"; import * as indexer from "../src/indexer.js"; +import * as buildCache from "../src/indexer/build-cache.js"; import { MANIFEST_VERSION, summarizeBuildOptions, @@ -595,6 +596,59 @@ describe("Cache invalidation and strict hashing", () => { } }); + it("loads unchanged project snapshots when a build report is requested", async () => { + const root = await mkTmpDir("dg-incremental-project-snapshot-report-"); + const filePath = path.join(root, "foo.ts"); + await fsp.writeFile(filePath, `export const reportedSnap = 1;\n`, "utf8"); + + const initialReport: BuildReport = { timings: {} }; + await buildProjectIndex(root, { threads: 2, cache: "disk", report: initialReport }); + const snapshotPath = projectSnapshotPathFor(root); + await expect(fsp.stat(snapshotPath)).resolves.toBeTruthy(); + const snapshot = JSON.parse(await fsp.readFile(snapshotPath, "utf8")) as { + analysis?: { + backend?: unknown; + label?: unknown; + }; + analysisReport?: { + backend?: unknown; + graph?: unknown; + }; + }; + expect(typeof snapshot.analysis?.backend).toBe("string"); + expect(typeof snapshot.analysis?.label).toBe("string"); + expect(snapshot.analysisReport?.backend).toBeDefined(); + expect(snapshot.analysisReport?.graph).toBeDefined(); + + const db = new DatabaseSync(diskCacheDbPathFor(root)); + try { + db.prepare("UPDATE module_cache SET payload = ?").run("{bad json"); + } finally { + db.close(); + } + + const report: BuildReport = { timings: {} }; + const prepSpy = vi.spyOn(filePrep, "prepareSourceInput"); + try { + const incremental = await buildProjectIndexIncremental(root, { + threads: 2, + cache: "disk", + report, + }); + + expect(prepSpy).not.toHaveBeenCalled(); + expect(incremental.buildReport).toBe(report); + expect(incremental.analysis?.backend).toBe(snapshot.analysis?.backend); + expect(incremental.analysis?.label).toBe(snapshot.analysis?.label); + expect(report.backend).toEqual(snapshot.analysisReport?.backend); + expect(report.graph).toEqual(snapshot.analysisReport?.graph); + const moduleIndex = incremental.byFile.get(normalize(filePath)); + expect(moduleIndex?.locals.some((local) => local.localName === "reportedSnap")).toBe(true); + } finally { + prepSpy.mockRestore(); + } + }); + it("falls back when the project snapshot payload is malformed", async () => { const root = await mkTmpDir("dg-incremental-bad-project-snapshot-"); const filePath = path.join(root, "foo.ts"); @@ -908,6 +962,130 @@ describe("Cache invalidation and strict hashing", () => { expect(nextModule?.locals.some((l) => l.localName === "next")).toBe(true); }); + it("reuses persisted bloom filters from the project snapshot on unchanged incremental loads", async () => { + const root = await mkTmpDir("dg-snapshot-bloom-reuse-"); + const alphaPath = path.join(root, "alpha.ts"); + const betaPath = path.join(root, "beta.ts"); + await fsp.writeFile(alphaPath, "export const alphaValue = 1;\n", "utf8"); + await fsp.writeFile( + betaPath, + 'import { alphaValue } from "./alpha";\nexport const betaValue = alphaValue;\n', + "utf8", + ); + + await buildProjectIndex(root, { threads: 2, cache: "disk", useBloomFilters: true }); + + const bloomSpy = vi.spyOn(buildCache, "buildBloomFilterForFile"); + + const incremental = await buildProjectIndexIncremental(root, { + threads: 2, + cache: "disk", + useBloomFilters: true, + }); + + expect(bloomSpy).not.toHaveBeenCalled(); + expect(incremental.bloomFilters?.size()).toBe(2); + expect(incremental.bloomFilters?.get(normalize(alphaPath))?.mightContain("alphaValue")).toBe(true); + + bloomSpy.mockRestore(); + }); + + it("does not hydrate persisted bloom filters when bloom filters are disabled", async () => { + const root = await mkTmpDir("dg-snapshot-bloom-disabled-"); + const entryPath = path.join(root, "entry.ts"); + await fsp.writeFile(entryPath, "export const disabledBloom = 1;\n", "utf8"); + + await buildProjectIndex(root, { threads: 2, cache: "disk", useBloomFilters: true }); + + const incremental = await buildProjectIndexIncremental(root, { + threads: 2, + cache: "disk", + useBloomFilters: false, + }); + + expect( + incremental.byFile.get(normalize(entryPath))?.locals.some((local) => local.localName === "disabledBloom"), + ).toBe(true); + expect(incremental.bloomFilters).toBeUndefined(); + }); + + it("falls back when project snapshot bloom filters are malformed", async () => { + const root = await mkTmpDir("dg-snapshot-bloom-malformed-"); + const entryPath = path.join(root, "entry.ts"); + await fsp.writeFile(entryPath, "export const guarded = 1;\n", "utf8"); + + await buildProjectIndex(root, { threads: 2, cache: "disk", useBloomFilters: true }); + const snapshotPath = projectSnapshotPathFor(root); + const snapshot = JSON.parse(await fsp.readFile(snapshotPath, "utf8")) as { + bloomFilters?: Record; + }; + snapshot.bloomFilters = { + [normalize(entryPath)]: { + size: 1_000, + hashCount: 3, + bitsBase64: "AAAA", + }, + }; + await fsp.writeFile(snapshotPath, JSON.stringify(snapshot), "utf8"); + + const rebuilt = await buildProjectIndexIncremental(root, { + threads: 2, + cache: "disk", + useBloomFilters: true, + }); + + expect(rebuilt.byFile.get(normalize(entryPath))?.locals.some((local) => local.localName === "guarded")).toBe(true); + expect(rebuilt.bloomFilters?.get(normalize(entryPath))?.mightContain("guarded")).toBe(true); + }); + + it("falls back from older project snapshot versions and rewrites the current schema", async () => { + const root = await mkTmpDir("dg-snapshot-version-upgrade-"); + const entryPath = path.join(root, "entry.ts"); + await fsp.writeFile(entryPath, "export const versioned = 1;\n", "utf8"); + + const initial = await buildProjectIndex(root, { threads: 2, cache: "disk", useBloomFilters: true }); + const snapshotPath = projectSnapshotPathFor(root); + const originalSnapshot = JSON.parse(await fsp.readFile(snapshotPath, "utf8")) as { + version: number; + filesSignature: string; + graph: unknown; + modules: unknown; + projectRoot?: string; + nativeMode?: string; + projectFiles?: unknown; + }; + + await fsp.writeFile( + snapshotPath, + JSON.stringify({ + version: 1, + filesSignature: originalSnapshot.filesSignature, + graph: originalSnapshot.graph, + modules: originalSnapshot.modules, + ...(originalSnapshot.projectRoot ? { projectRoot: originalSnapshot.projectRoot } : {}), + ...(originalSnapshot.nativeMode ? { nativeMode: originalSnapshot.nativeMode } : {}), + ...(originalSnapshot.projectFiles ? { projectFiles: originalSnapshot.projectFiles } : {}), + }), + "utf8", + ); + + const rebuilt = await buildProjectIndexIncremental(root, { + threads: 2, + cache: "disk", + useBloomFilters: true, + }); + const rewrittenSnapshot = JSON.parse(await fsp.readFile(snapshotPath, "utf8")) as { + version: number; + bloomFilters?: Record; + }; + + expect(initial.byFile.has(normalize(entryPath))).toBe(true); + expect(rebuilt.byFile.has(normalize(entryPath))).toBe(true); + expect(rebuilt.bloomFilters?.get(normalize(entryPath))?.mightContain("versioned")).toBe(true); + expect(rewrittenSnapshot.version).toBe(2); + expect(rewrittenSnapshot.bloomFilters?.[normalize(entryPath)]).toBeDefined(); + }); + it("clears stale negative resolve caches when requested", async () => { const root = await mkTmpDir("dg-resolve-cache-clear-"); const main = path.join(root, "main.ts"); diff --git a/tests/cli-regressions.test.ts b/tests/cli-regressions.test.ts index e7a85d9e..ed6a6053 100644 --- a/tests/cli-regressions.test.ts +++ b/tests/cli-regressions.test.ts @@ -136,7 +136,9 @@ describe("CLI regressions", () => { expect(source).not.toContain('from "./cli/graph.js"'); expect(source).not.toContain('from "./cli/mcp.js"'); expect(source).not.toContain('from "./cli/sql.js"'); - expect(sqliteDriverSource).not.toMatch(/import\s*\{[^}]*\b(?:constants|DatabaseSync)\b[^}]*\}\s*from\s*[\"']node:sqlite[\"']/s); + expect(sqliteDriverSource).not.toMatch( + /import\s*\{[^}]*\b(?:constants|DatabaseSync)\b[^}]*\}\s*from\s*[\"']node:sqlite[\"']/s, + ); expect(sqliteDriverSource).toMatch(/requireNodeModule\(\s*[\"']node:sqlite[\"']\s*\)/); }); diff --git a/tests/impact-cli.test.ts b/tests/impact-cli.test.ts index cc4aa9b3..c2bb4035 100644 --- a/tests/impact-cli.test.ts +++ b/tests/impact-cli.test.ts @@ -99,6 +99,7 @@ describe("impact CLI output", () => { async () => { const stdout = await runImpactCliSubprocess(["impact", sampleRoot, "--provider", "raw"]); const report = JSON.parse(stdout); + expect(report.analysis?.label).toBeTruthy(); expect(report.changedFiles).toHaveLength(1); expect(report.changedFiles[0]?.file).toBe("utils.ts"); }, @@ -110,6 +111,7 @@ describe("impact CLI output", () => { async () => { const stdout = await runImpactCli(["impact", sampleRoot, "--provider", "raw", "--pretty"]); expect(stdout).toContain("Impact Analysis Report"); + expect(stdout).toContain("Analysis:"); expect(stdout).toContain("Changed files: 1"); expect(stdout).toContain("Changed symbols:"); }, diff --git a/tests/impact-streaming.test.ts b/tests/impact-streaming.test.ts index 1114ef5f..8df28438 100644 --- a/tests/impact-streaming.test.ts +++ b/tests/impact-streaming.test.ts @@ -75,6 +75,7 @@ index 1234567..abcdef0 100644 provider: "raw", diffText, }); + expect(report.analysis?.label).toBeTruthy(); const streamedItems: string[] = []; const streamedChangedSymbols: string[] = []; @@ -133,6 +134,7 @@ index 1234567..abcdef0 100644 expect(complete.report).toBeDefined(); expect(complete.report.schemaVersion).toBe(1); expect(complete.report.format).toBe("stream-summary"); + expect(complete.report.analysis?.label).toBeTruthy(); expect(complete.report.changedFiles.length).toBeGreaterThan(0); expect("oldFile" in complete.report.changedFiles[0]!).toBe(false); expect(complete.report.changedSymbols.length).toBeGreaterThan(0); diff --git a/tests/impact.test.ts b/tests/impact.test.ts index b53c516b..d7b956df 100644 --- a/tests/impact.test.ts +++ b/tests/impact.test.ts @@ -6,7 +6,9 @@ import { SymbolKind } from "../src/index.js"; import { parseUnifiedDiff } from "../src/impact/parse.js"; import { analyzeImpactFromDiff, listCandidateTestFiles } from "../src/impact/index.js"; import { buildImpactReport } from "../src/impact/report.js"; +import { summarizeAnalysis } from "../src/analysisSummary.js"; import { CompactImpactReport, type ImpactItem } from "../src/impact/types.js"; +import type { BuildReport, ProjectIndex } from "../src/indexer/types.js"; import type { Range } from "../src/types.js"; import { createTestIndex } from "./test-utils.js"; import { buildProjectIndexFromFiles } from "../src/index.js"; @@ -335,10 +337,39 @@ index 1234567..abcdef0 100644 +} `; - const report = await analyzeImpactFromDiff(samplePath, index, { - provider: "raw", - diffText, - }); + const buildReport: BuildReport = { + timings: {}, + backend: { + native: { + available: true, + enabled: true, + supportedLanguageIds: ["typescript"], + filesUsed: 1, + filesFellBack: 0, + fallbackReasons: {}, + byLanguage: {}, + errors: [], + }, + }, + graph: { + fallbackImportExtraction: { + total: 0, + byLanguage: {}, + files: {}, + }, + }, + }; + const report = await analyzeImpactFromDiff( + samplePath, + index, + { + provider: "raw", + diffText, + }, + { buildReport }, + ); + expect(report.analysis?.label).toBe("native semantic"); + expect(report.analysis?.backend).toBe("native"); expect(report).toBeDefined(); expect(report.changedFiles).toHaveLength(1); @@ -347,6 +378,33 @@ index 1234567..abcdef0 100644 expect(Array.isArray(report.impacted)).toBe(true); }); + it("should report graph-only analysis when native mode is disabled", () => { + const report: BuildReport = { + timings: {}, + backend: { + native: { + available: true, + enabled: false, + supportedLanguageIds: [], + filesUsed: 0, + filesFellBack: 0, + fallbackReasons: {}, + byLanguage: {}, + errors: [], + }, + }, + }; + + const summary = summarizeAnalysis({ + index: { nativeMode: "off" } as ProjectIndex, + report, + }); + + expect(summary.backend).toBe("graph-only"); + expect(summary.mode).toBe("reduced"); + expect(summary.label).toBe("reduced graph-only"); + }); + it("rejects raw diff files outside the project root", async () => { const index = await createTestIndex("typescript"); const samplePath = path.resolve(process.cwd(), "tests", "samples", "typescript"); diff --git a/tests/review.test.ts b/tests/review.test.ts index 6fee545a..5e9bbde7 100644 --- a/tests/review.test.ts +++ b/tests/review.test.ts @@ -6,7 +6,7 @@ import fsp from "node:fs/promises"; import { buildProjectIndex, buildProjectIndexFromFiles, buildReviewReport } from "../src/index.js"; import * as indexerBuild from "../src/indexer/build-index.js"; import * as indexerNavigation from "../src/indexer/navigation.js"; -import type { IncrementalBuildOptions, SymbolDef } from "../src/indexer/types.js"; +import type { BuildReport, IncrementalBuildOptions, SymbolDef } from "../src/indexer/types.js"; import * as impactMap from "../src/impact/map.js"; import { runGit } from "./helpers/git.js"; @@ -433,6 +433,42 @@ describe("Review report", () => { expect(report.head).toBe("HEAD"); }); + it("reports graph-only analysis for no-change native-off reviews", async () => { + const root = await mkTmpDir("dg-review-git-no-changes-native-off-"); + runGit(root, ["init"]); + runGit(root, ["config", "user.email", "test@git.local"]); + runGit(root, ["config", "user.name", "Codegraph Bot"]); + await fsp.writeFile(path.join(root, "tracked.ts"), `export const value = 1;\n`, "utf8"); + runGit(root, ["add", "."]); + runGit(root, ["commit", "-m", "initial"]); + const indexReport: BuildReport = { + timings: {}, + backend: { + native: { + available: true, + enabled: false, + supportedLanguageIds: [], + filesUsed: 0, + filesFellBack: 0, + fallbackReasons: {}, + byLanguage: {}, + errors: [], + }, + }, + }; + + const report = await buildReviewReport(root, { + gitBase: "HEAD", + native: "off", + report: { timings: {}, indexReport }, + }); + + expect(report.status).toBe("no_changes"); + expect(report.analysis?.backend).toBe("graph-only"); + expect(report.analysis?.mode).toBe("reduced"); + expect(report.analysis?.label).toBe("reduced graph-only"); + }); + it("applies discovery filters to changed git comparisons", async () => { const root = await mkTmpDir("dg-review-git-changed-discovery-"); runGit(root, ["init"]); diff --git a/tests/session.test.ts b/tests/session.test.ts index fa2b13e5..23777176 100644 --- a/tests/session.test.ts +++ b/tests/session.test.ts @@ -1,10 +1,11 @@ -import { describe, test, expect, beforeAll, afterAll, beforeEach, vi } from "vitest"; +import { describe, test, expect, beforeAll, afterAll, afterEach, beforeEach, vi } from "vitest"; import type { ICodeReviewSession } from "../src/index.js"; -import type { BuildOptions } from "../src/indexer/types.js"; +import type { BuildOptions, BuildReport } from "../src/indexer/types.js"; import { CodeReviewSession, SessionManager, createCodeReviewSession } from "../src/session.js"; import * as indexerBuild from "../src/indexer/build-index.js"; import path from "node:path"; import os from "node:os"; +import fs from "node:fs"; import fsp from "node:fs/promises"; import { resolveFilePathFromRoot } from "../src/util.js"; @@ -33,6 +34,19 @@ afterAll(async () => { } }); +afterEach(() => { + vi.useRealTimers(); +}); + +function setSessionClock(): void { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); +} + +function advancePastStaleInterval(): void { + vi.setSystemTime(Date.now() + 5_001); +} + describe("CodeReviewSession", () => { let sharedReadySession: CodeReviewSession | undefined; @@ -68,6 +82,32 @@ describe("CodeReviewSession", () => { expect(session.isReady()).toBe(true); }); + test("should request build reports during default initialization", async () => { + const root = await fsp.mkdtemp(path.join(os.tmpdir(), "dg-session-default-report-")); + await fsp.writeFile(path.join(root, "main.ts"), "export const value = 1;\n", "utf8"); + const originalBuild = indexerBuild.buildProjectIndexIncremental; + let requestedReport: BuildReport | undefined; + const buildSpy = vi.spyOn(indexerBuild, "buildProjectIndexIncremental").mockImplementation(async (...args) => { + requestedReport = args[1]?.report; + return await originalBuild(...args); + }); + + try { + const session = await createCodeReviewSession({ + root, + buildOptions: { cache: "memory", useBloomFilters: true }, + }); + + expect(session.getStatus()).toBe("ready"); + expect(requestedReport?.timings).toBeDefined(); + expect(buildSpy).toHaveBeenCalledTimes(1); + session.dispose(); + } finally { + buildSpy.mockRestore(); + await fsp.rm(root, { recursive: true, force: true }); + } + }); + test("should provide session statistics", async () => { const session = readySession(); @@ -78,6 +118,8 @@ describe("CodeReviewSession", () => { expect(stats.symbolCount).toBeGreaterThan(0); expect(stats.lastActivity).toBeInstanceOf(Date); expect(stats.timeUntilExpiration).toBeGreaterThan(0); + expect(stats.stale).toBe(false); + expect(stats.lastRefreshReason).toBe("initialization"); }); test("should expose analyzeImpactStream on the session interface", async () => { @@ -232,6 +274,641 @@ index 1234567..abcdef0 100644 } }); + test("should mark stale sessions and auto-refresh before serving navigation", async () => { + const root = await fsp.mkdtemp(path.join(os.tmpdir(), "dg-session-stale-")); + try { + await fsp.writeFile( + path.join(root, "utils.ts"), + "export function helper(value: string) { return value; }\n", + "utf8", + ); + await fsp.writeFile( + path.join(root, "main.ts"), + "import { helper } from './utils';\nexport const ok = helper('token');\n", + "utf8", + ); + const session = await createCodeReviewSession({ + root, + buildOptions: { cache: "memory", useBloomFilters: true }, + }); + await fsp.writeFile( + path.join(root, "utils.ts"), + "export function helper(value: string) { return value.trim(); }\n", + "utf8", + ); + + const buildSpy = vi.spyOn(indexerBuild, "buildProjectIndexIncremental"); + try { + const result = await session.findReferences({ + file: path.join(root, "utils.ts"), + line: 1, + column: 17, + }); + expect(result.status).toBe("ok"); + expect(session.getStats().stale).toBe(false); + expect(session.getStats().lastRefreshReason).toBe("stale_check"); + expect(buildSpy).toHaveBeenCalledTimes(1); + } finally { + buildSpy.mockRestore(); + } + } finally { + await fsp.rm(root, { recursive: true, force: true }); + } + }); + + test("should throttle full stale scans while checking the navigation target", async () => { + const root = await fsp.mkdtemp(path.join(os.tmpdir(), "dg-session-stale-throttle-")); + try { + await fsp.writeFile(path.join(root, "utils.ts"), "export function helper() { return 1; }\n", "utf8"); + await fsp.writeFile( + path.join(root, "main.ts"), + "import { helper } from './utils';\nexport const value = helper();\n", + "utf8", + ); + const session = await createCodeReviewSession({ + root, + buildOptions: { cache: "memory", useBloomFilters: true }, + }); + + const statSpy = vi.spyOn(fs, "statSync"); + try { + const first = await session.goToDefinition({ + file: path.join(root, "main.ts"), + line: 2, + column: 22, + }); + const second = await session.goToDefinition({ + file: path.join(root, "main.ts"), + line: 2, + column: 22, + }); + + expect(first.status).toBe("ok"); + expect(second.status).toBe("ok"); + expect(statSpy).toHaveBeenCalledTimes(2); + } finally { + statSpy.mockRestore(); + } + } finally { + await fsp.rm(root, { recursive: true, force: true }); + } + }); + + test("should avoid full tracked-file scans after the stale interval", async () => { + const root = await fsp.mkdtemp(path.join(os.tmpdir(), "dg-session-stale-cheap-")); + try { + const exports = Array.from({ length: 20 }, (_, index) => `export const value${index} = ${index};\n`); + await Promise.all( + exports.map((source, index) => fsp.writeFile(path.join(root, `dep${index}.ts`), source, "utf8")), + ); + await fsp.writeFile( + path.join(root, "main.ts"), + "import { value0 } from './dep0';\nexport const value = value0;\n", + "utf8", + ); + setSessionClock(); + const session = await createCodeReviewSession({ + root, + buildOptions: { cache: "memory", useBloomFilters: true }, + }); + advancePastStaleInterval(); + + const statSpy = vi.spyOn(fs, "statSync"); + try { + const result = await session.goToDefinition({ + file: path.join(root, "main.ts"), + line: 2, + column: 22, + }); + + expect(result.status).toBe("ok"); + expect(statSpy.mock.calls.length).toBeLessThan(10); + } finally { + statSpy.mockRestore(); + } + } finally { + await fsp.rm(root, { recursive: true, force: true }); + } + }); + + test("should run tracked-file stale scans before impact analysis", async () => { + const root = await fsp.mkdtemp(path.join(os.tmpdir(), "dg-session-impact-stale-")); + try { + const utilsPath = path.join(root, "utils.ts"); + const mainPath = path.join(root, "main.ts"); + await fsp.writeFile(utilsPath, "export function helper() { return 1; }\n", "utf8"); + await fsp.writeFile(mainPath, "import { helper } from './utils';\nexport const value = helper();\n", "utf8"); + setSessionClock(); + const session = await createCodeReviewSession({ + root, + buildOptions: { cache: "memory", useBloomFilters: true }, + }); + const navigation = await session.goToDefinition({ + file: mainPath, + line: 2, + column: 22, + }); + expect(navigation.status).toBe("ok"); + await fsp.writeFile(utilsPath, "export function helper() { return 42; }\n", "utf8"); + advancePastStaleInterval(); + expect(session.getStats().status).toBe("ready"); + + const buildSpy = vi.spyOn(indexerBuild, "buildProjectIndexIncremental"); + try { + await session.analyzeImpact({ + provider: "raw", + diffText: `diff --git a/main.ts b/main.ts +index 1234567..abcdef0 100644 +--- a/main.ts ++++ b/main.ts +@@ -1,2 +1,2 @@ + import { helper } from './utils'; +-export const value = helper(); ++export const value = helper() + 1; +`, + }); + + expect(session.getStats().stale).toBe(false); + expect(session.getStats().lastRefreshReason).toBe("stale_check"); + expect(buildSpy).toHaveBeenCalledTimes(1); + } finally { + buildSpy.mockRestore(); + session.dispose(); + } + } finally { + await fsp.rm(root, { recursive: true, force: true }); + } + }); + + test("should run tracked-file stale scans before streaming impact analysis", async () => { + const root = await fsp.mkdtemp(path.join(os.tmpdir(), "dg-session-impact-stream-stale-")); + try { + const utilsPath = path.join(root, "utils.ts"); + const mainPath = path.join(root, "main.ts"); + await fsp.writeFile(utilsPath, "export function helper() { return 1; }\n", "utf8"); + await fsp.writeFile(mainPath, "import { helper } from './utils';\nexport const value = helper();\n", "utf8"); + setSessionClock(); + const session = await createCodeReviewSession({ + root, + buildOptions: { cache: "memory", useBloomFilters: true }, + }); + const navigation = await session.goToDefinition({ + file: mainPath, + line: 2, + column: 22, + }); + expect(navigation.status).toBe("ok"); + await fsp.writeFile(utilsPath, "export function helper() { return 42; }\n", "utf8"); + advancePastStaleInterval(); + expect(session.getStats().status).toBe("ready"); + + const buildSpy = vi.spyOn(indexerBuild, "buildProjectIndexIncremental"); + try { + for await (const chunk of session.analyzeImpactStream({ + provider: "raw", + diffText: `diff --git a/main.ts b/main.ts +index 1234567..abcdef0 100644 +--- a/main.ts ++++ b/main.ts +@@ -1,2 +1,2 @@ + import { helper } from './utils'; +-export const value = helper(); ++export const value = helper() + 1; +`, + streamSummary: "light", + })) { + if (chunk.type === "complete") { + break; + } + } + + expect(session.getStats().stale).toBe(false); + expect(session.getStats().lastRefreshReason).toBe("stale_check"); + expect(buildSpy).toHaveBeenCalledTimes(1); + } finally { + buildSpy.mockRestore(); + session.dispose(); + } + } finally { + await fsp.rm(root, { recursive: true, force: true }); + } + }); + + test("should throttle tracked-file stale scans across repeated impact calls", async () => { + const root = await fsp.mkdtemp(path.join(os.tmpdir(), "dg-session-impact-scan-throttle-")); + try { + const exports = Array.from({ length: 20 }, (_, index) => `export const value${index} = ${index};\n`); + await Promise.all( + exports.map((source, index) => fsp.writeFile(path.join(root, `dep${index}.ts`), source, "utf8")), + ); + const mainPath = path.join(root, "main.ts"); + await fsp.writeFile(mainPath, "import { value0 } from './dep0';\nexport const value = value0;\n", "utf8"); + setSessionClock(); + const session = await createCodeReviewSession({ + root, + buildOptions: { cache: "memory", useBloomFilters: true }, + }); + const diffText = `diff --git a/main.ts b/main.ts +index 1234567..abcdef0 100644 +--- a/main.ts ++++ b/main.ts +@@ -1,2 +1,2 @@ + import { value0 } from './dep0'; +-export const value = value0; ++export const value = value0 + 1; +`; + advancePastStaleInterval(); + + const statSpy = vi.spyOn(fs, "statSync"); + const buildSpy = vi.spyOn(indexerBuild, "buildProjectIndexIncremental"); + try { + await session.analyzeImpact({ provider: "raw", diffText }); + const broadScanStatCalls = statSpy.mock.calls.length; + statSpy.mockClear(); + + await session.analyzeImpact({ provider: "raw", diffText }); + + expect(buildSpy).not.toHaveBeenCalled(); + expect(broadScanStatCalls).toBeGreaterThan(statSpy.mock.calls.length); + expect(statSpy.mock.calls.length).toBeLessThan(10); + } finally { + buildSpy.mockRestore(); + statSpy.mockRestore(); + session.dispose(); + } + } finally { + await fsp.rm(root, { recursive: true, force: true }); + } + }); + + test("should not let impact postpone navigation directory checks", async () => { + const root = await fsp.mkdtemp(path.join(os.tmpdir(), "dg-session-impact-navigation-timer-")); + try { + const mainPath = path.join(root, "main.ts"); + const latePath = path.join(root, "late.ts"); + await fsp.writeFile(mainPath, "import { late } from './late';\nexport const value = late();\n", "utf8"); + setSessionClock(); + const session = await createCodeReviewSession({ + root, + buildOptions: { cache: "memory", useBloomFilters: true }, + }); + advancePastStaleInterval(); + + await session.analyzeImpact({ + provider: "raw", + diffText: `diff --git a/main.ts b/main.ts +index 1234567..abcdef0 100644 +--- a/main.ts ++++ b/main.ts +@@ -1,2 +1,2 @@ + import { late } from './late'; +-export const value = late(); ++export const value = late() + 1; +`, + }); + await fsp.writeFile(latePath, "export function late() { return 1; }\n", "utf8"); + await fsp.utimes(root, new Date(), new Date(Date.now() + 10_000)); + + const buildSpy = vi.spyOn(indexerBuild, "buildProjectIndexIncremental"); + try { + const result = await session.goToDefinition({ + file: mainPath, + line: 2, + column: 22, + }); + + expect(result.status).toBe("ok"); + if (result.status === "ok") { + expect(result.definition.file).toBe(path.resolve(latePath)); + expect(result.definition.localName).toBe("late"); + } + expect(session.getStats().lastRefreshReason).toBe("stale_check"); + expect(buildSpy).toHaveBeenCalledTimes(1); + } finally { + buildSpy.mockRestore(); + session.dispose(); + } + } finally { + await fsp.rm(root, { recursive: true, force: true }); + } + }); + + test("should not let passive status checks postpone navigation directory checks", async () => { + const root = await fsp.mkdtemp(path.join(os.tmpdir(), "dg-session-status-navigation-timer-")); + try { + const mainPath = path.join(root, "main.ts"); + const latePath = path.join(root, "late.ts"); + await fsp.writeFile(mainPath, "import { late } from './late';\nexport const value = late();\n", "utf8"); + setSessionClock(); + const session = await createCodeReviewSession({ + root, + buildOptions: { cache: "memory", useBloomFilters: true }, + }); + + advancePastStaleInterval(); + expect(session.getStats().status).toBe("ready"); + await fsp.writeFile(latePath, "export function late() { return 1; }\n", "utf8"); + await fsp.utimes(root, new Date(), new Date(Date.now() + 10_000)); + + const buildSpy = vi.spyOn(indexerBuild, "buildProjectIndexIncremental"); + try { + const result = await session.goToDefinition({ + file: mainPath, + line: 2, + column: 22, + }); + + expect(result.status).toBe("ok"); + if (result.status === "ok") { + expect(result.definition.file).toBe(path.resolve(latePath)); + expect(result.definition.localName).toBe("late"); + } + expect(session.getStats().lastRefreshReason).toBe("stale_check"); + expect(buildSpy).toHaveBeenCalledTimes(1); + } finally { + buildSpy.mockRestore(); + session.dispose(); + } + } finally { + await fsp.rm(root, { recursive: true, force: true }); + } + }); + + test("should refresh impact analysis when a source file is added", async () => { + const root = await fsp.mkdtemp(path.join(os.tmpdir(), "dg-session-impact-added-file-")); + try { + const mainPath = path.join(root, "main.ts"); + await fsp.writeFile(mainPath, "export const value = 1;\n", "utf8"); + setSessionClock(); + const session = await createCodeReviewSession({ + root, + buildOptions: { cache: "memory", useBloomFilters: true }, + }); + await fsp.writeFile(path.join(root, "late.ts"), "export const late = 1;\n", "utf8"); + await fsp.utimes(root, new Date(), new Date(Date.now() + 10_000)); + advancePastStaleInterval(); + + const buildSpy = vi.spyOn(indexerBuild, "buildProjectIndexIncremental"); + try { + await session.analyzeImpact({ + provider: "raw", + diffText: `diff --git a/main.ts b/main.ts +index 1234567..abcdef0 100644 +--- a/main.ts ++++ b/main.ts +@@ -1 +1 @@ +-export const value = 1; ++export const value = 2; +`, + }); + + expect(session.getStats().lastRefreshReason).toBe("stale_check"); + expect(buildSpy).toHaveBeenCalledTimes(1); + } finally { + buildSpy.mockRestore(); + session.dispose(); + } + } finally { + await fsp.rm(root, { recursive: true, force: true }); + } + }); + + test("should refresh streaming impact analysis when a source file is added", async () => { + const root = await fsp.mkdtemp(path.join(os.tmpdir(), "dg-session-impact-stream-added-file-")); + try { + const mainPath = path.join(root, "main.ts"); + await fsp.writeFile(mainPath, "export const value = 1;\n", "utf8"); + setSessionClock(); + const session = await createCodeReviewSession({ + root, + buildOptions: { cache: "memory", useBloomFilters: true }, + }); + await fsp.writeFile(path.join(root, "late.ts"), "export const late = 1;\n", "utf8"); + await fsp.utimes(root, new Date(), new Date(Date.now() + 10_000)); + advancePastStaleInterval(); + + const buildSpy = vi.spyOn(indexerBuild, "buildProjectIndexIncremental"); + try { + for await (const chunk of session.analyzeImpactStream({ + provider: "raw", + diffText: `diff --git a/main.ts b/main.ts +index 1234567..abcdef0 100644 +--- a/main.ts ++++ b/main.ts +@@ -1 +1 @@ +-export const value = 1; ++export const value = 2; +`, + streamSummary: "light", + })) { + if (chunk.type === "complete") { + break; + } + } + + expect(session.getStats().lastRefreshReason).toBe("stale_check"); + expect(buildSpy).toHaveBeenCalledTimes(1); + } finally { + buildSpy.mockRestore(); + session.dispose(); + } + } finally { + await fsp.rm(root, { recursive: true, force: true }); + } + }); + + test("should refresh navigation when config changes", async () => { + const root = await fsp.mkdtemp(path.join(os.tmpdir(), "dg-session-config-navigation-")); + try { + const mainPath = path.join(root, "main.ts"); + await fsp.writeFile(mainPath, "export function helper() { return 1; }\n", "utf8"); + await fsp.writeFile( + path.join(root, "codegraph.config.json"), + JSON.stringify({ discovery: { includeGlobs: ["main.ts"] } }), + "utf8", + ); + setSessionClock(); + const session = await createCodeReviewSession({ + root, + buildOptions: { cache: "memory", useBloomFilters: true }, + }); + await fsp.writeFile( + path.join(root, "codegraph.config.json"), + JSON.stringify({ discovery: { includeGlobs: ["other.ts"] } }), + "utf8", + ); + advancePastStaleInterval(); + + const buildSpy = vi.spyOn(indexerBuild, "buildProjectIndexIncremental"); + try { + const result = await session.goToDefinition({ + file: mainPath, + line: 1, + column: 17, + }); + + expect(result.status).toBe("not_found"); + expect(session.getStats().lastRefreshReason).toBe("stale_check"); + expect(buildSpy.mock.calls.length).toBeGreaterThan(0); + } finally { + buildSpy.mockRestore(); + session.dispose(); + } + } finally { + await fsp.rm(root, { recursive: true, force: true }); + } + }); + + test("should auto-refresh before navigation when a new source file is added", async () => { + const root = await fsp.mkdtemp(path.join(os.tmpdir(), "dg-session-added-file-")); + try { + await fsp.writeFile( + path.join(root, "main.ts"), + "import { late } from './late';\nexport const value = late();\n", + "utf8", + ); + const session = await createCodeReviewSession({ + root, + buildOptions: { cache: "memory", useBloomFilters: true }, + }); + + await fsp.writeFile( + path.join(root, "late.ts"), + "export function late() { return 1; }\nexport const value = late();\n", + "utf8", + ); + + const buildSpy = vi.spyOn(indexerBuild, "buildProjectIndexIncremental"); + try { + const result = await session.goToDefinition({ + file: path.join(root, "late.ts"), + line: 2, + column: 22, + }); + expect(result.status).toBe("ok"); + expect(session.getStats().stale).toBe(false); + expect(session.getStats().lastRefreshReason).toBe("stale_check"); + expect(buildSpy).toHaveBeenCalledTimes(1); + } finally { + buildSpy.mockRestore(); + } + } finally { + await fsp.rm(root, { recursive: true, force: true }); + } + }); + + test("should include new source files on manual refresh", async () => { + const root = await fsp.mkdtemp(path.join(os.tmpdir(), "dg-session-manual-added-file-")); + try { + await fsp.writeFile( + path.join(root, "main.ts"), + "import { late } from './late';\nexport const value = late();\n", + "utf8", + ); + const session = await createCodeReviewSession({ + root, + buildOptions: { cache: "memory", useBloomFilters: true }, + }); + + await fsp.writeFile( + path.join(root, "late.ts"), + "export function late() { return 1; }\nexport const value = late();\n", + "utf8", + ); + + const buildSpy = vi.spyOn(indexerBuild, "buildProjectIndexIncremental"); + try { + await session.refresh(); + const result = await session.goToDefinition({ + file: path.join(root, "late.ts"), + line: 2, + column: 22, + }); + expect(result.status).toBe("ok"); + expect(session.getStats().lastRefreshReason).toBe("manual"); + expect(buildSpy).toHaveBeenCalledTimes(1); + } finally { + buildSpy.mockRestore(); + } + } finally { + await fsp.rm(root, { recursive: true, force: true }); + } + }); + + test("should reload config discovery options before refresh builds", async () => { + const root = await fsp.mkdtemp(path.join(os.tmpdir(), "dg-session-config-refresh-")); + const configPath = path.join(root, "codegraph.config.json"); + try { + await fsp.writeFile(configPath, JSON.stringify({ discovery: { includeGlobs: ["main.ts"] } }, null, 2), "utf8"); + await fsp.writeFile( + path.join(root, "main.ts"), + "import { late } from './late';\nexport const value = late();\n", + "utf8", + ); + await fsp.writeFile(path.join(root, "late.ts"), "export function late() { return 1; }\n", "utf8"); + const session = await createCodeReviewSession({ + root, + buildOptions: { cache: "memory", useBloomFilters: true }, + }); + expect(session.getStats().fileCount).toBe(1); + + await fsp.writeFile(configPath, JSON.stringify({ discovery: { includeGlobs: ["*.ts"] } }, null, 2), "utf8"); + await session.refresh(); + + expect(session.getStats().fileCount).toBe(2); + const result = await session.goToDefinition({ + file: path.join(root, "main.ts"), + line: 2, + column: 22, + }); + expect(result.status).toBe("ok"); + } finally { + await fsp.rm(root, { recursive: true, force: true }); + } + }); + + test("should use full builds for force refreshes when incremental is disabled", async () => { + const root = await fsp.mkdtemp(path.join(os.tmpdir(), "dg-session-manual-full-refresh-")); + try { + await fsp.writeFile( + path.join(root, "main.ts"), + "import { late } from './late';\nexport const value = late();\n", + "utf8", + ); + const session = await createCodeReviewSession({ + root, + incremental: false, + buildOptions: { cache: "memory", useBloomFilters: true }, + }); + + await fsp.writeFile( + path.join(root, "late.ts"), + "export function late() { return 1; }\nexport const value = late();\n", + "utf8", + ); + + const fullBuildSpy = vi.spyOn(indexerBuild, "buildProjectIndex"); + const incrementalBuildSpy = vi.spyOn(indexerBuild, "buildProjectIndexIncremental"); + try { + await session.refresh(); + const result = await session.goToDefinition({ + file: path.join(root, "late.ts"), + line: 2, + column: 22, + }); + expect(result.status).toBe("ok"); + expect(session.getStats().lastRefreshReason).toBe("manual"); + expect(fullBuildSpy).toHaveBeenCalledTimes(1); + expect(incrementalBuildSpy).not.toHaveBeenCalled(); + } finally { + fullBuildSpy.mockRestore(); + incrementalBuildSpy.mockRestore(); + } + } finally { + await fsp.rm(root, { recursive: true, force: true }); + } + }); + test("should expire after timeout", async () => { const session = new CodeReviewSession({ root: sampleRoot, @@ -249,6 +926,26 @@ index 1234567..abcdef0 100644 expect(session.isReady()).toBe(false); }); + test("should omit stale metadata after disposal", async () => { + const session = await createCodeReviewSession({ + root: sampleRoot, + buildOptions: sampleBuildOptions(), + }); + + Object.defineProperty(session, "staleReason", { + configurable: true, + value: "tracked_files_changed", + writable: true, + }); + + session.dispose(); + + const stats = session.getStats(); + expect(stats.status).toBe("expired"); + expect(stats.stale).toBe(false); + expect(stats.staleReason).toBeUndefined(); + }); + test("should re-initialize after expiration", async () => { const session = new CodeReviewSession({ root: sampleRoot, @@ -338,6 +1035,61 @@ index 1234567..abcdef0 100644 } }); + test("should await in-flight refreshes for concurrent navigation", async () => { + const root = await fsp.mkdtemp(path.join(os.tmpdir(), "dg-session-concurrent-refresh-")); + try { + const utilsPath = path.join(root, "utils.ts"); + const mainPath = path.join(root, "main.ts"); + await fsp.writeFile(utilsPath, "export function helper(value: string) { return value; }\n", "utf8"); + await fsp.writeFile(mainPath, "import { helper } from './utils';\nexport const ok = helper('token');\n", "utf8"); + const session = await createCodeReviewSession({ + root, + buildOptions: { cache: "memory", useBloomFilters: true }, + }); + await fsp.writeFile(utilsPath, "export function helper(value: string) { return value.trim(); }\n", "utf8"); + + const originalBuild = indexerBuild.buildProjectIndexIncremental; + let releaseBuild: (() => void) | null = null; + let markBuildStarted: (() => void) | null = null; + const buildGate = new Promise((resolve) => { + releaseBuild = resolve; + }); + const buildStarted = new Promise((resolve) => { + markBuildStarted = resolve; + }); + const buildSpy = vi.spyOn(indexerBuild, "buildProjectIndexIncremental").mockImplementation(async (...args) => { + markBuildStarted?.(); + await buildGate; + return await originalBuild(...args); + }); + + try { + const first = session.findReferences({ + file: utilsPath, + line: 1, + column: 17, + }); + await buildStarted; + const second = session.goToDefinition({ + file: mainPath, + line: 2, + column: 19, + }); + + releaseBuild?.(); + const [firstResult, secondResult] = await Promise.all([first, second]); + + expect(firstResult.status).toBe("ok"); + expect(secondResult.status).toBe("ok"); + expect(buildSpy).toHaveBeenCalledTimes(1); + } finally { + buildSpy.mockRestore(); + } + } finally { + await fsp.rm(root, { recursive: true, force: true }); + } + }); + test("should throw error when used after expiration", async () => { const session = await createCodeReviewSession({ root: sampleRoot, @@ -553,7 +1305,8 @@ describe("SessionManager", () => { }); expect(session.getStatus()).toBe("ready"); - await new Promise((resolve) => setTimeout(resolve, 150)); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(Date.now() + 150); expect(session.getStatus()).toBe("expired"); const buildSpy = vi @@ -860,7 +1613,16 @@ describe("SessionManager", () => { const buildGate = new Promise((resolve) => { releaseBuild = resolve; }); + let buildStarts = 0; + let markAllBuildsStarted: (() => void) | null = null; + const allBuildsStarted = new Promise((resolve) => { + markAllBuildsStarted = resolve; + }); const buildSpy = vi.spyOn(indexerBuild, "buildProjectIndexIncremental").mockImplementation(async (...args) => { + buildStarts += 1; + if (buildStarts === 2) { + markAllBuildsStarted?.(); + } await buildGate; return await originalBuild(...args); }); @@ -883,8 +1645,7 @@ describe("SessionManager", () => { }, ]); - await Promise.resolve(); - await Promise.resolve(); + await allBuildsStarted; expect(buildSpy).toHaveBeenCalledTimes(2);