From 21d5849f09d2d851a2daa2f2ee0b9838bbd58ab2 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Sat, 27 Jun 2026 16:59:48 -0400 Subject: [PATCH 01/18] fix: tighten review-first workflows and cache reuse Make review the default daily path, surface analysis metadata across search, explain, impact, and review, and keep long-lived sessions fresher and faster. Also persist snapshot bloom filters and make SQLite readonly access compatible with Node builds that omit setAuthorizer(). Co-authored-by: Cursor --- README.md | 64 +++++--- codegraph-skill/codegraph/SKILL.md | 4 +- docs/agent-workflows.md | 24 ++- docs/cli.md | 16 +- docs/how-it-works.md | 2 + docs/library-api.md | 11 +- .../2026-06-27-roi-implementation-progress.md | 94 ++++++++++++ src/agent-tools.ts | 14 +- src/agent/explain.ts | 5 + src/agent/search.ts | 106 ++++++++++++-- src/agent/session.ts | 9 +- src/analysisSummary.ts | 94 ++++++++++++ src/cli/help.ts | 37 +++-- src/cli/impact.ts | 4 + src/cli/review.ts | 3 + src/impact/report.ts | 4 + src/impact/reportCompact.ts | 6 +- src/impact/reportFull.ts | 3 + src/impact/streaming.ts | 6 + src/impact/types.ts | 4 + src/index.ts | 1 + src/indexer/build-cache/project-snapshot.ts | 59 +++++++- src/indexer/build-index.ts | 8 - src/review.ts | 6 + src/review/report.ts | 3 + src/review/types.ts | 2 + src/session.ts | 138 +++++++++++++++--- src/sqlite-driver.ts | 7 +- tests/agent-search.test.ts | 33 ++++- tests/cache-invalidation.test.ts | 77 ++++++++++ tests/cli-regressions.test.ts | 4 +- tests/impact-cli.test.ts | 2 + tests/impact-streaming.test.ts | 2 + tests/impact.test.ts | 1 + tests/session.test.ts | 51 +++++++ 35 files changed, 790 insertions(+), 114 deletions(-) create mode 100644 docs/plans/2026-06-27-roi-implementation-progress.md create mode 100644 src/analysisSummary.ts diff --git a/README.md b/README.md index ddbf93f7..698cc63f 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,25 @@ 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: ```bash +# daily worktree review +node ./dist/cli.js review --base HEAD --head WORKTREE --summary + # initial 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 +124,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 +# review-first workflow 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 +183,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 +337,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 { diff --git a/codegraph-skill/codegraph/SKILL.md b/codegraph-skill/codegraph/SKILL.md index 54853d7c..b05016a6 100644 --- a/codegraph-skill/codegraph/SKILL.md +++ b/codegraph-skill/codegraph/SKILL.md @@ -26,7 +26,7 @@ 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. +For PR, worktree, or sweeping review tasks, start with `codegraph review --base HEAD --head WORKTREE --summary`. Use `codegraph impact --base HEAD --head WORKTREE --pretty` when you need the broader blast radius map. Then choose the smallest useful follow-up: @@ -52,6 +52,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..3b3a3c06 100644 --- a/docs/agent-workflows.md +++ b/docs/agent-workflows.md @@ -6,14 +6,22 @@ Use Codegraph for structural repo questions: architecture, dependency direction, ## Start here +For current edits, start with one compact review packet: + +```bash +codegraph review --base HEAD --head WORKTREE --summary +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 +63,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 +91,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 and impact calls auto-refresh before serving results when tracked files drift. + +For library callers performing repeated navigation or impact work, use sessions like this: ```ts import { createCodeReviewSession } from "@lzehrung/codegraph"; diff --git a/docs/cli.md b/docs/cli.md index b6397880..dc48c71e 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -10,6 +10,12 @@ 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: + +- current edits: `codegraph review --base HEAD --head WORKTREE --summary` +- unfamiliar repo: `codegraph orient --root . --budget small --pretty` +- follow-up anchor: `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 +51,12 @@ Cache and manifest reuse is rooted at `--root`. Reusing a project root lets comm ### Dependency graphs ```bash +# First-pass review 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 +127,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 +245,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..a56708bb 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 also watch tracked file and config signatures. When the working tree drifts, the next navigation or impact call refreshes the cached snapshot 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..aba4244b --- /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` passes. + +## 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..7c1879cd 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, "graph", "high", { + mode: "semantic", + 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: "semantic", + backend: "unknown", + parserDegradedFiles: 0, + fallbackImportExtractionFiles: 0, + nativeFilesUsed: 0, + nativeFilesFellBack: 0, + label: "semantic", + }, 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..f897ad58 --- /dev/null +++ b/src/analysisSummary.ts @@ -0,0 +1,94 @@ +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; report?: BuildReport }): AnalysisBackend { + 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 (input.index?.nativeMode === "off") { + 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; report?: BuildReport }): AnalysisSummary { + 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..045fddf1 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,25 @@ Output Options: --output Write to file instead of stdout --stdout Write default graph output to stdout +Recommended first commands: + codegraph review --base HEAD --head WORKTREE --summary + codegraph orient --root . --budget small --pretty + codegraph search "auth user" --json + codegraph explain src/auth.ts --json + 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 +92,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 +149,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..8811b031 100644 --- a/src/cli/impact.ts +++ b/src/cli/impact.ts @@ -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}`); 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/report.ts b/src/impact/report.ts index 1fcafbb2..9d0843fe 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 { summarizeAnalysis } from "../analysisSummary.js"; import type { FileChange, ChangedSymbol, @@ -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 }); // 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..84af9969 100644 --- a/src/impact/streaming.ts +++ b/src/impact/streaming.ts @@ -4,6 +4,7 @@ */ import { type ProjectIndex } 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, @@ -196,6 +199,7 @@ export async function* analyzeImpactStreaming( const streamSummary = validateImpactStreamingOptions(options); const impactOptions = toImpactOptions(options); const displayFile = (filePath: string): string => toImpactReportFilePath(projectRoot, filePath); + const analysis = summarizeAnalysis({ index }); const projectFiles = index.projectFiles ?? (await discoverProjectFiles(projectRoot)); yield { type: "projectFiles", files: projectFiles }; @@ -312,6 +316,7 @@ export async function* analyzeImpactStreaming( const report = streamSummary === "light" ? buildLightStreamSummaryReport( + analysis, normalizedChanges, changedSymbols, impactedItems, @@ -386,6 +391,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..7f0f8644 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 { diff --git a/src/indexer/build-cache/project-snapshot.ts b/src/indexer/build-cache/project-snapshot.ts index b1348fc3..c0b5cefe 100644 --- a/src/indexer/build-cache/project-snapshot.ts +++ b/src/indexer/build-cache/project-snapshot.ts @@ -5,6 +5,7 @@ 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 { BloomFilter, BloomFilterCache } from "../../util/bloomFilter.js"; import { SymbolKind, type BuildOptions, @@ -18,7 +19,13 @@ 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; + +type SerializedBloomFilter = { + size: number; + hashCount: number; + bitsBase64: string; +}; type ProjectIndexSnapshotPayload = { version: number; @@ -31,6 +38,7 @@ type ProjectIndexSnapshotPayload = { projectRoot?: string; nativeMode?: ProjectIndex["nativeMode"]; projectFiles?: ProjectFileInfo[]; + bloomFilters?: Record; }; export function projectSnapshotFilesSignature(entries: ReadonlyMap): string { @@ -83,6 +91,7 @@ export async function tryLoadProjectIndexSnapshot( ...(payload.nativeMode ? { nativeMode: payload.nativeMode } : {}), exportCache: new Map(), scopeCache: new Map(), + ...(payload.bloomFilters ? { bloomFilters: deserializeBloomFilterCache(payload.bloomFilters) } : {}), ...(payload.projectFiles ? { projectFiles: payload.projectFiles } : {}), referenceCandidates: buildReferenceCandidateIndex(modules), ...(opts?.cache ? { cacheMode: opts.cache, cacheRootDir: cacheRoot(projectRoot, opts) } : {}), @@ -99,6 +108,9 @@ 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 payload: ProjectIndexSnapshotPayload = { version: PROJECT_SNAPSHOT_VERSION, filesSignature, @@ -112,6 +124,7 @@ export async function writeProjectIndexSnapshot( ? { nativeMode: normalizedSnapshotNativeMode(index.nativeMode) } : {}), ...(index.projectFiles ? { projectFiles: index.projectFiles } : {}), + ...(serializedBloomFilters ? { bloomFilters: serializedBloomFilters } : {}), }; try { const snapshotPath = projectSnapshotPath(projectRoot, opts); @@ -187,11 +200,55 @@ 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.projectFiles === undefined || (Array.isArray(payload.projectFiles) && payload.projectFiles.every(isProjectFileInfo))) ); } +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; + return ( + typeof filter.size === "number" && + Number.isFinite(filter.size) && + typeof filter.hashCount === "number" && + Number.isFinite(filter.hashCount) && + typeof filter.bitsBase64 === "string" + ); +} + 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..e598b4b8 100644 --- a/src/indexer/build-index.ts +++ b/src/indexer/build-index.ts @@ -1015,14 +1015,6 @@ 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; - } await writeIndexManifestSnapshot({ projectRoot, opts, diff --git a/src/review.ts b/src/review.ts index aea32376..745fda8a 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,7 @@ export async function buildReviewReport(projectRoot: string, opts: ReviewOptions const report: ReviewReport = { schemaVersion: REVIEW_SCHEMA_VERSION, status: "no_changes", + ...(reviewReport?.indexReport ? { analysis: summarizeAnalysis({ report: reviewReport.indexReport }) } : {}), projectFiles, summary: { filesChanged: 0, symbolsChanged: 0, candidateTests: 0 }, riskSummary, @@ -329,6 +331,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..5b1060cd 100644 --- a/src/session.ts +++ b/src/session.ts @@ -3,6 +3,7 @@ * Maintains warm caches across multiple queries for better agent UX */ +import fs from "node:fs"; import path from "node:path"; import { type ProjectIndex, @@ -43,6 +44,20 @@ export type SessionOptions = { export type SessionStatus = "initializing" | "ready" | "expired" | "error"; +type SessionStaleReason = "tracked_files_changed" | "config_changed"; + +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 +177,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,6 +185,8 @@ 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 status: SessionStatus = "initializing"; private lastActivity: number = Date.now(); @@ -186,6 +197,12 @@ export class CodeReviewSession implements ICodeReviewSession { private initPromise: Promise | null = null; private identityFingerprint: string; private lifecycleVersion = 0; + private trackedFileSignatures = new Map(); + private configSignature: string | undefined; + private staleReason: SessionStaleReason | undefined; + private lastStaleCheckAt = 0; + private lastRefreshAt: number | undefined; + private lastRefreshReason: "initialization" | "manual" | "stale_check" | undefined; constructor(options: SessionOptions) { const identity = resolveSessionIdentity(options); @@ -228,9 +245,71 @@ 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 configFilePath(): string { + return path.join(this.root, "codegraph.config.json"); + } + + private captureFreshnessBaseline(index: ProjectIndex, reason: "initialization" | "manual" | "stale_check"): void { + const trackedEntries = index.manifestEntries + ? [...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.configSignature = this.statSignature(this.configFilePath()); + this.staleReason = undefined; + this.lastStaleCheckAt = Date.now(); + 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 checkForStaleness(): void { + if (this.status !== "ready" || !this.index) return; + const now = Date.now(); + if (now - this.lastStaleCheckAt < CodeReviewSession.STALE_CHECK_INTERVAL_MS) return; + this.lastStaleCheckAt = now; + this.staleReason = this.refreshNeededFromTrackedFiles(); + } + + private commitReadyIndex(index: ProjectIndex, reason: "initialization" | "manual" | "stale_check"): void { this.index = index; this.status = "ready"; + this.captureFreshnessBaseline(index, reason); this.touch(); } @@ -253,7 +332,7 @@ export class CodeReviewSession implements ICodeReviewSession { this.status = "initializing"; const nextIndex = await this.buildIndex(); this.assertLifecycleVersion(lifecycleVersion, "initialization"); - this.commitReadyIndex(nextIndex); + this.commitReadyIndex(nextIndex, "initialization"); } catch (error) { if (this.lifecycleVersion === lifecycleVersion) { this.status = previousStatus === "expired" ? "expired" : "error"; @@ -276,6 +355,7 @@ export class CodeReviewSession implements ICodeReviewSession { */ isReady(): boolean { this.checkExpiration(); + this.checkForStaleness(); return this.status === "ready"; } @@ -284,6 +364,7 @@ export class CodeReviewSession implements ICodeReviewSession { */ getStatus(): SessionStatus { this.checkExpiration(); + this.checkForStaleness(); return this.status; } @@ -309,6 +390,7 @@ export class CodeReviewSession implements ICodeReviewSession { */ private getIndex(): ProjectIndex { this.checkExpiration(); + this.checkForStaleness(); if (this.status !== "ready" || !this.index) { throw new Error(`Session not ready (status: ${this.status})`); } @@ -316,12 +398,21 @@ export class CodeReviewSession implements ICodeReviewSession { return this.index; } + private async ensureFreshIndex(): Promise { + const index = this.getIndex(); + if (!this.staleReason) { + return index; + } + 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(); requireSessionImpactProvider(options); return await analyzeImpactFromDiff(this.root, index, options); } @@ -331,7 +422,7 @@ 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(); requireSessionImpactProvider(options); yield* analyzeImpactStreaming(this.root, index, options); } @@ -340,7 +431,7 @@ export class CodeReviewSession implements ICodeReviewSession { * Find references to a symbol */ async findReferences(params: { file: string; line: number; column: number }): Promise { - const index = this.getIndex(); + const index = await this.ensureFreshIndex(); const resolved = resolveSessionFileInput(this.root, params.file, "Session file"); if (resolved.status === "error") { return resolved; @@ -355,7 +446,7 @@ export class CodeReviewSession implements ICodeReviewSession { * Go to definition of a symbol */ async goToDefinition(params: { file: string; line: number; column: number }): Promise { - const index = this.getIndex(); + const index = await this.ensureFreshIndex(); const resolved = resolveSessionFileInput(this.root, params.file, "Session file"); if (resolved.status === "error") { return resolved; @@ -369,7 +460,7 @@ export class CodeReviewSession implements ICodeReviewSession { /** * Refresh the index (incremental rebuild) */ - async refresh(): Promise { + private async refreshInternal(refreshReason: "manual" | "stale_check"): Promise { const previousIndex = this.index; const previousStatus = this.status; const lifecycleVersion = this.lifecycleVersion; @@ -377,7 +468,7 @@ export class CodeReviewSession implements ICodeReviewSession { try { const nextIndex = await this.buildIndex(); this.assertLifecycleVersion(lifecycleVersion, "refresh"); - this.commitReadyIndex(nextIndex); + this.commitReadyIndex(nextIndex, refreshReason); } catch (error) { if (this.lifecycleVersion !== lifecycleVersion) { throw error; @@ -392,6 +483,10 @@ export class CodeReviewSession implements ICodeReviewSession { } } + async refresh(): Promise { + await this.refreshInternal("manual"); + } + /** * Dispose of the session and free resources */ @@ -405,14 +500,9 @@ 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; @@ -424,6 +514,10 @@ export class CodeReviewSession implements ICodeReviewSession { symbolCount, lastActivity: new Date(this.lastActivity), timeUntilExpiration: this.status === "ready" ? Math.max(0, this.timeout - (Date.now() - this.lastActivity)) : 0, + stale: !!this.staleReason, + ...(this.staleReason ? { staleReason: this.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..8dc53752 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(); }); @@ -175,6 +193,7 @@ describe("agent search", () => { index, fileGraph, symbolGraph: { nodes: new Map(), edges: [] }, + analysis: DEFAULT_ANALYSIS, }; }, invalidate: () => undefined, @@ -225,15 +244,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..b7e941b0 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, @@ -908,6 +909,82 @@ 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("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..ea3a3610 100644 --- a/tests/impact.test.ts +++ b/tests/impact.test.ts @@ -339,6 +339,7 @@ index 1234567..abcdef0 100644 provider: "raw", diffText, }); + expect(report.analysis?.label).toBeTruthy(); expect(report).toBeDefined(); expect(report.changedFiles).toHaveLength(1); diff --git a/tests/session.test.ts b/tests/session.test.ts index fa2b13e5..5e5f9b33 100644 --- a/tests/session.test.ts +++ b/tests/session.test.ts @@ -78,6 +78,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 +234,55 @@ 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 }, + }); + const freshness = session as { lastStaleCheckAt: number }; + + await fsp.writeFile( + path.join(root, "utils.ts"), + "export function helper(value: string) { return value.trim(); }\n", + "utf8", + ); + freshness.lastStaleCheckAt = 0; + + const staleStats = session.getStats(); + expect(staleStats.stale).toBe(true); + expect(staleStats.staleReason).toBe("tracked_files_changed"); + + 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 expire after timeout", async () => { const session = new CodeReviewSession({ root: sampleRoot, From 51fe6102d4f813687cdde3b3da93bb7c463f2263 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Sat, 27 Jun 2026 17:37:02 -0400 Subject: [PATCH 02/18] Fix review freshness and analysis metadata --- src/agent/search.ts | 8 +- src/analysisSummary.ts | 10 +- src/cli/impact.ts | 12 +-- src/impact/index.ts | 9 +- src/impact/report.ts | 6 +- src/impact/streaming.ts | 13 ++- src/indexer/build-index.ts | 10 +- src/indexer/finalize.ts | 2 + src/indexer/types.ts | 1 + src/session.ts | 197 ++++++++++++++++++++++++++++++++----- tests/agent-search.test.ts | 10 ++ tests/impact.test.ts | 39 +++++++- tests/session.test.ts | 83 ++++++++++++++-- 13 files changed, 339 insertions(+), 61 deletions(-) diff --git a/src/agent/search.ts b/src/agent/search.ts index 7c1879cd..08ee5b96 100644 --- a/src/agent/search.ts +++ b/src/agent/search.ts @@ -309,8 +309,8 @@ function searchPathOnly(root: string, files: readonly string[], request: AgentSe kind: "file", label: relFile, file: relFile, - provenance: createSearchProvenance(relFile, "graph", "high", { - mode: "semantic", + provenance: createSearchProvenance(relFile, "text", "high", { + mode: "reduced", backend: "unknown", }), }); @@ -330,13 +330,13 @@ function searchPathOnly(root: string, files: readonly string[], request: AgentSe mode: "path", root, analysis: { - mode: "semantic", + mode: "reduced", backend: "unknown", parserDegradedFiles: 0, fallbackImportExtractionFiles: 0, nativeFilesUsed: 0, nativeFilesFellBack: 0, - label: "semantic", + label: "path-only", }, limits: { results: limit, diff --git a/src/analysisSummary.ts b/src/analysisSummary.ts index f897ad58..ffd9db24 100644 --- a/src/analysisSummary.ts +++ b/src/analysisSummary.ts @@ -14,7 +14,10 @@ export type AnalysisSummary = { label: string; }; -function deriveAnalysisBackend(input: { index?: ProjectIndex; report?: BuildReport }): AnalysisBackend { +function deriveAnalysisBackend(input: { + index?: ProjectIndex | undefined; + report?: BuildReport | undefined; +}): AnalysisBackend { const nativeReport = input.report?.backend?.native; const parserFallbackCount = input.report?.backend?.parser?.total ?? 0; const importFallbackCount = input.report?.graph?.fallbackImportExtraction.total ?? 0; @@ -70,7 +73,10 @@ export function formatAnalysisSummaryLabel(summary: AnalysisSummary): string { return details.length ? `mixed semantics (${details.join(", ")})` : "mixed semantics"; } -export function summarizeAnalysis(input: { index?: ProjectIndex; report?: BuildReport }): AnalysisSummary { +export function summarizeAnalysis(input: { + index?: ProjectIndex | undefined; + report?: BuildReport | undefined; +}): AnalysisSummary { const parserDegradedFiles = input.report?.backend?.parser?.total ?? 0; const fallbackImportExtractionFiles = input.report?.graph?.fallbackImportExtraction.total ?? 0; const nativeFilesUsed = input.report?.backend?.native.filesUsed ?? 0; diff --git a/src/cli/impact.ts b/src/cli/impact.ts index 8811b031..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, @@ -482,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/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 9d0843fe..ac612b89 100644 --- a/src/impact/report.ts +++ b/src/impact/report.ts @@ -1,6 +1,6 @@ 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, @@ -35,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)); @@ -45,7 +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 }); + const analysis = summarizeAnalysis({ index, report: options.buildReport ?? index.buildReport }); // Build changedFiles summary const changedFiles = normalizedDiffFiles.map((fileChange) => ({ diff --git a/src/impact/streaming.ts b/src/impact/streaming.ts index 84af9969..7fe3f2da 100644 --- a/src/impact/streaming.ts +++ b/src/impact/streaming.ts @@ -3,7 +3,7 @@ * 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, @@ -179,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. * @@ -194,12 +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 }); + const analysis = summarizeAnalysis({ index, report: context.buildReport ?? index.buildReport }); const projectFiles = index.projectFiles ?? (await discoverProjectFiles(projectRoot)); yield { type: "projectFiles", files: projectFiles }; @@ -333,6 +338,7 @@ export async function* analyzeImpactStreaming( impactedItems, diagnostics, diff.warning, + context, ); yield { @@ -367,6 +373,7 @@ async function buildFullStreamSummaryReport( impactedItems: ImpactItem[], diagnostics: ImpactStreamSummaryReport["diagnostics"], warning: string | undefined, + context: ImpactStreamingContext, ): Promise { const suggestions = await collectImpactReportSuggestions( projectRoot, @@ -382,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") { diff --git a/src/indexer/build-index.ts b/src/indexer/build-index.ts index e598b4b8..a73d2a8b 100644 --- a/src/indexer/build-index.ts +++ b/src/indexer/build-index.ts @@ -415,7 +415,7 @@ function createIndexBuildRunState( opts: BuildOptions | undefined, graphOptions = normalizeGraphOptions(opts?.graph), ): IndexBuildRunState { - const report = opts?.report; + const report = opts?.report ?? { timings: {} }; initNativeBackendReport(report); const cacheMode = opts?.cache ?? "off"; return { @@ -731,6 +731,7 @@ async function buildIndexFromFileListShared( parsedMap, bloomFilterCache, ...(projectFiles !== undefined ? { projectFiles } : {}), + buildReport: report, manifestEntries: manifestEntriesForIndex, }); if (manifestEntries) { @@ -998,12 +999,7 @@ 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 && !opts?.report) { const filesSignature = projectSnapshotFilesSignature(new Map(Object.entries(trackedEntries))); const snapshot = await tryLoadProjectIndexSnapshot(projectRoot, opts, filesSignature); if (snapshot) { 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..bfee7b34 100644 --- a/src/indexer/types.ts +++ b/src/indexer/types.ts @@ -107,6 +107,7 @@ export type ProjectIndex = { sqlNavigation?: SqlNavigationCache; manifestEntries?: Map; cacheMode?: BuildOptions["cache"]; + buildReport?: BuildReport; cacheRootDir?: string; }; diff --git a/src/session.ts b/src/session.ts index 5b1060cd..f595edd8 100644 --- a/src/session.ts +++ b/src/session.ts @@ -8,7 +8,9 @@ import path from "node:path"; import { type ProjectIndex, type BuildOptions, + type BuildReport, type GoToResult, + type IncrementalBuildOptions, type Reference, type SymbolDef, } from "./indexer/types.js"; @@ -24,6 +26,7 @@ import { import { analyzeImpactStreaming, type ImpactStreamChunk } from "./impact/streaming.js"; import { getSessionPreset, mergePreset, type PresetName } from "./presets.js"; import { resolveFilePathWithinRoot } from "./util/paths.js"; +import { listProjectFiles } from "./util/projectFiles.js"; export type SessionOptions = { /** Project root directory */ @@ -188,6 +191,7 @@ 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; @@ -199,7 +203,9 @@ export class CodeReviewSession implements ICodeReviewSession { 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 lastRefreshAt: number | undefined; private lastRefreshReason: "initialization" | "manual" | "stale_check" | undefined; @@ -227,12 +233,25 @@ export class CodeReviewSession implements ICodeReviewSession { getRoot(): string { return this.root; } - - 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[]; + }> { + if (options.forceFull) { + const projectFiles = await this.currentProjectFiles(); + const report: BuildReport = { timings: {} }; + const buildOptions: IncrementalBuildOptions = { ...this.buildOptions, files: projectFiles, report }; + const index = await buildProjectIndexIncremental(this.root, buildOptions); + return { index, report: index.buildReport ?? report, projectFiles }; } - return await buildProjectIndex(this.root, this.buildOptions); + const buildOptions: BuildOptions = { ...this.buildOptions }; + const index = this.incremental + ? await buildProjectIndexIncremental(this.root, buildOptions) + : await buildProjectIndex(this.root, buildOptions); + const projectFiles = this.indexedProjectFiles(index); + const report = index.buildReport ?? { timings: {} }; + return { index, report, projectFiles }; } private createDisposedDuringOperationError(operation: string): Error { @@ -262,15 +281,87 @@ export class CodeReviewSession implements ICodeReviewSession { } } + private directorySignature(directory: string): string { + const stat = this.statSignature(directory); + try { + const entries = fs + .readdirSync(directory, { withFileTypes: true }) + .filter((entry) => entry.name !== ".codegraph-cache" && entry.name !== ".git") + .map((entry) => `${entry.isDirectory() ? "d" : "f"}:${entry.name}`) + .sort() + .join("\n"); + return `${stat}\n${entries}`; + } catch { + return stat; + } + } + private configFilePath(): string { return path.join(this.root, "codegraph.config.json"); } - private captureFreshnessBaseline(index: ProjectIndex, reason: "initialization" | "manual" | "stale_check"): void { + private async currentProjectFiles(): Promise { + const discoveryOptions = { + ...this.buildOptions?.discovery, + ...(this.buildOptions?.logLevel ? { logLevel: this.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 directoriesForProjectFiles(files: Iterable): Set { + const directories = new Set([this.root]); + for (const file of files) { + let directory = path.dirname(path.resolve(file)); + while (directory.startsWith(this.root)) { + 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 ? [...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(); @@ -298,18 +389,46 @@ export class CodeReviewSession implements ICodeReviewSession { return undefined; } - private checkForStaleness(): void { + private checkForStaleness(options: { force?: boolean } = {}): void { if (this.status !== "ready" || !this.index) return; const now = Date.now(); - if (now - this.lastStaleCheckAt < CodeReviewSession.STALE_CHECK_INTERVAL_MS) return; + if (!options.force && now - this.lastStaleCheckAt < CodeReviewSession.STALE_CHECK_INTERVAL_MS) return; this.lastStaleCheckAt = now; - this.staleReason = this.refreshNeededFromTrackedFiles(); + const trackedReason = this.refreshNeededFromTrackedFiles(); + if (trackedReason) { + this.staleReason = trackedReason; + 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"): void { + private checkForStalenessNow(): void { + if (this.status !== "ready" || !this.index) return; + this.lastStaleCheckAt = Date.now(); + const trackedReason = this.refreshNeededFromTrackedFiles(); + if (trackedReason) { + this.staleReason = trackedReason; + 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); + this.captureFreshnessBaseline(index, reason, projectFiles); + this.forceFullRefreshOnNextStaleCheck = false; this.touch(); } @@ -330,9 +449,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, "initialization"); + this.commitReadyIndex(nextBuild.index, "initialization", nextBuild.report, nextBuild.projectFiles); } catch (error) { if (this.lifecycleVersion === lifecycleVersion) { this.status = previousStatus === "expired" ? "expired" : "error"; @@ -399,6 +518,8 @@ export class CodeReviewSession implements ICodeReviewSession { } private async ensureFreshIndex(): Promise { + this.checkExpiration(); + this.checkForStalenessNow(); const index = this.getIndex(); if (!this.staleReason) { return index; @@ -407,6 +528,16 @@ export class CodeReviewSession implements ICodeReviewSession { 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 @@ -414,7 +545,7 @@ export class CodeReviewSession implements ICodeReviewSession { async analyzeImpact(options: ImpactOptions): Promise { const index = await this.ensureFreshIndex(); requireSessionImpactProvider(options); - return await analyzeImpactFromDiff(this.root, index, options); + return await analyzeImpactFromDiff(this.root, index, options, { buildReport: this.buildReport }); } /** @@ -424,41 +555,61 @@ export class CodeReviewSession implements ICodeReviewSession { async *analyzeImpactStream(options: ImpactStreamingOptions): AsyncGenerator { const index = await this.ensureFreshIndex(); 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 = await this.ensureFreshIndex(); const resolved = resolveSessionFileInput(this.root, params.file, "Session file"); if (resolved.status === "error") { return resolved; } - return await findReferences(index, { + const index = await this.ensureFreshIndex(); + 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 = await this.ensureFreshIndex(); const resolved = resolveSessionFileInput(this.root, params.file, "Session file"); if (resolved.status === "error") { return resolved; } - return await goToDefinition(index, { + const index = await this.ensureFreshIndex(); + 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) */ private async refreshInternal(refreshReason: "manual" | "stale_check"): Promise { const previousIndex = this.index; @@ -466,9 +617,11 @@ export class CodeReviewSession implements ICodeReviewSession { 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, refreshReason); + this.commitReadyIndex(nextBuild.index, refreshReason, nextBuild.report, nextBuild.projectFiles); } catch (error) { if (this.lifecycleVersion !== lifecycleVersion) { throw error; diff --git a/tests/agent-search.test.ts b/tests/agent-search.test.ts index 8dc53752..f91ff77c 100644 --- a/tests/agent-search.test.ts +++ b/tests/agent-search.test.ts @@ -152,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(); }); diff --git a/tests/impact.test.ts b/tests/impact.test.ts index ea3a3610..da556c0b 100644 --- a/tests/impact.test.ts +++ b/tests/impact.test.ts @@ -7,6 +7,7 @@ import { parseUnifiedDiff } from "../src/impact/parse.js"; import { analyzeImpactFromDiff, listCandidateTestFiles } from "../src/impact/index.js"; import { buildImpactReport } from "../src/impact/report.js"; import { CompactImpactReport, type ImpactItem } from "../src/impact/types.js"; +import type { BuildReport } 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,11 +336,39 @@ index 1234567..abcdef0 100644 +} `; - const report = await analyzeImpactFromDiff(samplePath, index, { - provider: "raw", - diffText, - }); - expect(report.analysis?.label).toBeTruthy(); + 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); diff --git a/tests/session.test.ts b/tests/session.test.ts index 5e5f9b33..ebb787b2 100644 --- a/tests/session.test.ts +++ b/tests/session.test.ts @@ -251,18 +251,11 @@ index 1234567..abcdef0 100644 root, buildOptions: { cache: "memory", useBloomFilters: true }, }); - const freshness = session as { lastStaleCheckAt: number }; - await fsp.writeFile( path.join(root, "utils.ts"), "export function helper(value: string) { return value.trim(); }\n", "utf8", ); - freshness.lastStaleCheckAt = 0; - - const staleStats = session.getStats(); - expect(staleStats.stale).toBe(true); - expect(staleStats.staleReason).toBe("tracked_files_changed"); const buildSpy = vi.spyOn(indexerBuild, "buildProjectIndexIncremental"); try { @@ -283,6 +276,82 @@ index 1234567..abcdef0 100644 } }); + 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 expire after timeout", async () => { const session = new CodeReviewSession({ root: sampleRoot, From 0dccb0261cb7f16bc65c5817d90786ab5e468359 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Sat, 27 Jun 2026 22:25:16 -0400 Subject: [PATCH 03/18] Address PR review feedback --- src/analysisSummary.ts | 8 +++--- src/indexer/build-cache/project-snapshot.ts | 27 ++++++++++++++----- src/session.ts | 15 ++--------- tests/cache-invalidation.test.ts | 29 +++++++++++++++++++++ 4 files changed, 55 insertions(+), 24 deletions(-) diff --git a/src/analysisSummary.ts b/src/analysisSummary.ts index ffd9db24..f2afa356 100644 --- a/src/analysisSummary.ts +++ b/src/analysisSummary.ts @@ -20,7 +20,7 @@ function deriveAnalysisBackend(input: { }): AnalysisBackend { const nativeReport = input.report?.backend?.native; const parserFallbackCount = input.report?.backend?.parser?.total ?? 0; - const importFallbackCount = input.report?.graph?.fallbackImportExtraction.total ?? 0; + const importFallbackCount = input.report?.graph?.fallbackImportExtraction?.total ?? 0; if (nativeReport) { if (!nativeReport.filesUsed && (parserFallbackCount || importFallbackCount)) { return "graph-only"; @@ -78,9 +78,9 @@ export function summarizeAnalysis(input: { report?: BuildReport | undefined; }): AnalysisSummary { 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 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, diff --git a/src/indexer/build-cache/project-snapshot.ts b/src/indexer/build-cache/project-snapshot.ts index c0b5cefe..c3363046 100644 --- a/src/indexer/build-cache/project-snapshot.ts +++ b/src/indexer/build-cache/project-snapshot.ts @@ -20,6 +20,10 @@ import type { ManifestFileEntry } from "./manifest.js"; const SNAPSHOT_SYMBOL_KINDS = new Set(Object.values(SymbolKind)); 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; @@ -240,13 +244,22 @@ function isSerializedBloomFilterRecord(value: unknown): value is Record; - return ( - typeof filter.size === "number" && - Number.isFinite(filter.size) && - typeof filter.hashCount === "number" && - Number.isFinite(filter.hashCount) && - typeof filter.bitsBase64 === "string" - ); + 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 { diff --git a/src/session.ts b/src/session.ts index f595edd8..d91013a6 100644 --- a/src/session.ts +++ b/src/session.ts @@ -282,18 +282,7 @@ export class CodeReviewSession implements ICodeReviewSession { } private directorySignature(directory: string): string { - const stat = this.statSignature(directory); - try { - const entries = fs - .readdirSync(directory, { withFileTypes: true }) - .filter((entry) => entry.name !== ".codegraph-cache" && entry.name !== ".git") - .map((entry) => `${entry.isDirectory() ? "d" : "f"}:${entry.name}`) - .sort() - .join("\n"); - return `${stat}\n${entries}`; - } catch { - return stat; - } + return this.statSignature(directory); } private configFilePath(): string { @@ -357,7 +346,7 @@ export class CodeReviewSession implements ICodeReviewSession { reason: "initialization" | "manual" | "stale_check", projectFiles: string[], ): void { - const trackedEntries = index.manifestEntries + 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); diff --git a/tests/cache-invalidation.test.ts b/tests/cache-invalidation.test.ts index b7e941b0..38efbc56 100644 --- a/tests/cache-invalidation.test.ts +++ b/tests/cache-invalidation.test.ts @@ -937,6 +937,35 @@ describe("Cache invalidation and strict hashing", () => { bloomSpy.mockRestore(); }); + 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: 2_000_000, + hashCount: 99, + 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"); From 87abb5558b0b76fb245b6b289962b17e88751818 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Sat, 27 Jun 2026 23:05:09 -0400 Subject: [PATCH 04/18] Validate bloom snapshot size exactly --- src/indexer/build-cache/project-snapshot.ts | 2 +- tests/cache-invalidation.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/indexer/build-cache/project-snapshot.ts b/src/indexer/build-cache/project-snapshot.ts index c3363046..c197b03d 100644 --- a/src/indexer/build-cache/project-snapshot.ts +++ b/src/indexer/build-cache/project-snapshot.ts @@ -259,7 +259,7 @@ function isSerializedBloomFilter(value: unknown): value is SerializedBloomFilter } const maxBytes = Math.ceil(filter.size / 8); const maxBase64Length = Math.ceil(maxBytes / 3) * 4; - return filter.bitsBase64.length <= maxBase64Length; + return filter.bitsBase64.length === maxBase64Length; } function isModuleIndex(value: unknown): value is ModuleIndex { diff --git a/tests/cache-invalidation.test.ts b/tests/cache-invalidation.test.ts index 38efbc56..dad2e12f 100644 --- a/tests/cache-invalidation.test.ts +++ b/tests/cache-invalidation.test.ts @@ -949,8 +949,8 @@ describe("Cache invalidation and strict hashing", () => { }; snapshot.bloomFilters = { [normalize(entryPath)]: { - size: 2_000_000, - hashCount: 99, + size: 1_000, + hashCount: 3, bitsBase64: "AAAA", }, }; From ff7d9b963531fc92a374474d8332133e53078cfc Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Sun, 28 Jun 2026 00:13:04 -0400 Subject: [PATCH 05/18] Respect bloom and report opt-outs --- src/indexer/build-cache/project-snapshot.ts | 5 ++++- src/indexer/build-index.ts | 4 ++-- tests/cache-invalidation.test.ts | 19 +++++++++++++++++++ 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/indexer/build-cache/project-snapshot.ts b/src/indexer/build-cache/project-snapshot.ts index c197b03d..51bb17a5 100644 --- a/src/indexer/build-cache/project-snapshot.ts +++ b/src/indexer/build-cache/project-snapshot.ts @@ -86,6 +86,7 @@ export async function tryLoadProjectIndexSnapshot( edges: payload.graph.edges, }; const modules = new Map(payload.modules.map((moduleIndex) => [moduleIndex.file, moduleIndex])); + const shouldHydrateBloomFilters = opts?.useBloomFilters ?? true; return { graph, graphAdjacency: buildGraphAdjacency(graph), @@ -95,7 +96,9 @@ export async function tryLoadProjectIndexSnapshot( ...(payload.nativeMode ? { nativeMode: payload.nativeMode } : {}), exportCache: new Map(), scopeCache: new Map(), - ...(payload.bloomFilters ? { bloomFilters: deserializeBloomFilterCache(payload.bloomFilters) } : {}), + ...(shouldHydrateBloomFilters && payload.bloomFilters + ? { bloomFilters: deserializeBloomFilterCache(payload.bloomFilters) } + : {}), ...(payload.projectFiles ? { projectFiles: payload.projectFiles } : {}), referenceCandidates: buildReferenceCandidateIndex(modules), ...(opts?.cache ? { cacheMode: opts.cache, cacheRootDir: cacheRoot(projectRoot, opts) } : {}), diff --git a/src/indexer/build-index.ts b/src/indexer/build-index.ts index a73d2a8b..d45b705f 100644 --- a/src/indexer/build-index.ts +++ b/src/indexer/build-index.ts @@ -415,8 +415,8 @@ function createIndexBuildRunState( opts: BuildOptions | undefined, graphOptions = normalizeGraphOptions(opts?.graph), ): IndexBuildRunState { - const report = opts?.report ?? { timings: {} }; - initNativeBackendReport(report); + const report = opts?.report; + if (report) initNativeBackendReport(report); const cacheMode = opts?.cache ?? "off"; return { normalizedProjectRoot: normalizePath(projectRoot), diff --git a/tests/cache-invalidation.test.ts b/tests/cache-invalidation.test.ts index dad2e12f..8edcbc6f 100644 --- a/tests/cache-invalidation.test.ts +++ b/tests/cache-invalidation.test.ts @@ -937,6 +937,25 @@ describe("Cache invalidation and strict hashing", () => { 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"); From dd9ca7dbb073156123868664e28cfdce6d8a090f Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Sun, 28 Jun 2026 00:33:14 -0400 Subject: [PATCH 06/18] Keep passive session staleness checks cheap --- src/session.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/session.ts b/src/session.ts index d91013a6..cf7d90b2 100644 --- a/src/session.ts +++ b/src/session.ts @@ -383,12 +383,14 @@ export class CodeReviewSession implements ICodeReviewSession { const now = Date.now(); if (!options.force && now - this.lastStaleCheckAt < CodeReviewSession.STALE_CHECK_INTERVAL_MS) return; this.lastStaleCheckAt = now; - const trackedReason = this.refreshNeededFromTrackedFiles(); - if (trackedReason) { - this.staleReason = trackedReason; + + 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; @@ -498,7 +500,6 @@ export class CodeReviewSession implements ICodeReviewSession { */ private getIndex(): ProjectIndex { this.checkExpiration(); - this.checkForStaleness(); if (this.status !== "ready" || !this.index) { throw new Error(`Session not ready (status: ${this.status})`); } From 7defa332a35e5f98541f699474d3707941d1d94e Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Sun, 28 Jun 2026 01:02:59 -0400 Subject: [PATCH 07/18] Preserve refresh and snapshot cache modes --- src/indexer/build-index.ts | 9 ++++++- src/session.ts | 9 ++++++- tests/cache-invalidation.test.ts | 33 +++++++++++++++++++++++++ tests/session.test.ts | 42 ++++++++++++++++++++++++++++++++ 4 files changed, 91 insertions(+), 2 deletions(-) diff --git a/src/indexer/build-index.ts b/src/indexer/build-index.ts index d45b705f..62cc68e5 100644 --- a/src/indexer/build-index.ts +++ b/src/indexer/build-index.ts @@ -999,7 +999,7 @@ export async function buildProjectIndexIncremental( }; invalidateCachedDependents(); if (fileReport) fileReport.changed = changedFiles.size; - if (!changedFiles.size && !deletedTrackedFiles.size && !opts?.report) { + if (!changedFiles.size && !deletedTrackedFiles.size) { const filesSignature = projectSnapshotFilesSignature(new Map(Object.entries(trackedEntries))); const snapshot = await tryLoadProjectIndexSnapshot(projectRoot, opts, filesSignature); if (snapshot) { @@ -1011,6 +1011,10 @@ export async function buildProjectIndexIncremental( snapshot.cacheMode = opts.cache; snapshot.cacheRootDir = cacheRoot(projectRoot, opts); } + if (fileReport) { + fileReport.cached = allFiles.size; + } + if (timings) timings.graphMs = 0; await writeIndexManifestSnapshot({ projectRoot, opts, @@ -1020,6 +1024,9 @@ export async function buildProjectIndexIncremental( manifestReport, }); if (timings) timings.totalMs = Math.round(performance.now() - totalStart); + if (report) { + snapshot.buildReport = report; + } return snapshot; } } diff --git a/src/session.ts b/src/session.ts index cf7d90b2..59f8abf7 100644 --- a/src/session.ts +++ b/src/session.ts @@ -238,13 +238,20 @@ export class CodeReviewSession implements ICodeReviewSession { report: BuildReport; projectFiles: string[]; }> { - if (options.forceFull) { + if (options.forceFull && this.incremental) { const projectFiles = await this.currentProjectFiles(); const report: BuildReport = { timings: {} }; const buildOptions: IncrementalBuildOptions = { ...this.buildOptions, files: projectFiles, report }; const index = await buildProjectIndexIncremental(this.root, buildOptions); return { index, report: index.buildReport ?? report, projectFiles }; } + if (options.forceFull) { + const report: BuildReport = { timings: {} }; + const buildOptions: BuildOptions = { ...this.buildOptions, report }; + const index = await buildProjectIndex(this.root, buildOptions); + const projectFiles = this.indexedProjectFiles(index); + return { index, report: index.buildReport ?? report, projectFiles }; + } const buildOptions: BuildOptions = { ...this.buildOptions }; const index = this.incremental ? await buildProjectIndexIncremental(this.root, buildOptions) diff --git a/tests/cache-invalidation.test.ts b/tests/cache-invalidation.test.ts index 8edcbc6f..3814b0c5 100644 --- a/tests/cache-invalidation.test.ts +++ b/tests/cache-invalidation.test.ts @@ -596,6 +596,39 @@ 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"); + + await buildProjectIndex(root, { threads: 2, cache: "disk" }); + await expect(fsp.stat(projectSnapshotPathFor(root))).resolves.toBeTruthy(); + + 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); + 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"); diff --git a/tests/session.test.ts b/tests/session.test.ts index ebb787b2..a3ed7062 100644 --- a/tests/session.test.ts +++ b/tests/session.test.ts @@ -352,6 +352,48 @@ index 1234567..abcdef0 100644 } }); + 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, From 5f92e1f9fdb2e2928dcab20fe8360e14b0e42c38 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Sun, 28 Jun 2026 11:31:45 -0400 Subject: [PATCH 08/18] Throttle session freshness checks --- src/session.ts | 46 +++++++++++++++++++++++++-------- tests/session.test.ts | 59 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 10 deletions(-) diff --git a/src/session.ts b/src/session.ts index 59f8abf7..b723a2e7 100644 --- a/src/session.ts +++ b/src/session.ts @@ -25,7 +25,7 @@ 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 { normalizePath, resolveFilePathWithinRoot } from "./util/paths.js"; import { listProjectFiles } from "./util/projectFiles.js"; export type SessionOptions = { @@ -314,11 +314,16 @@ export class CodeReviewSession implements ICodeReviewSession { }); } + 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 (directory.startsWith(this.root)) { + while (this.isPathInsideRoot(directory)) { directories.add(directory); if (directory === this.root) break; const parent = path.dirname(directory); @@ -385,6 +390,15 @@ export class CodeReviewSession implements ICodeReviewSession { 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(); @@ -403,9 +417,18 @@ export class CodeReviewSession implements ICodeReviewSession { this.forceFullRefreshOnNextStaleCheck = projectFilesChanged; } - private checkForStalenessNow(): void { + private checkForStalenessNow(options: { force?: boolean; file?: string } = {}): void { if (this.status !== "ready" || !this.index) return; - this.lastStaleCheckAt = Date.now(); + 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; + } + if (!options.force && now - this.lastStaleCheckAt < CodeReviewSession.STALE_CHECK_INTERVAL_MS) return; + this.lastStaleCheckAt = now; const trackedReason = this.refreshNeededFromTrackedFiles(); if (trackedReason) { this.staleReason = trackedReason; @@ -514,9 +537,9 @@ export class CodeReviewSession implements ICodeReviewSession { return this.index; } - private async ensureFreshIndex(): Promise { + private async ensureFreshIndex(options: { force?: boolean; file?: string } = {}): Promise { this.checkExpiration(); - this.checkForStalenessNow(); + this.checkForStalenessNow(options); const index = this.getIndex(); if (!this.staleReason) { return index; @@ -563,7 +586,7 @@ export class CodeReviewSession implements ICodeReviewSession { if (resolved.status === "error") { return resolved; } - const index = await this.ensureFreshIndex(); + const index = await this.ensureFreshIndex({ file: resolved.file }); const result = await findReferences(index, { ...params, file: resolved.file, @@ -588,7 +611,7 @@ export class CodeReviewSession implements ICodeReviewSession { if (resolved.status === "error") { return resolved; } - const index = await this.ensureFreshIndex(); + const index = await this.ensureFreshIndex({ file: resolved.file }); const result = await goToDefinition(index, { ...params, file: resolved.file, @@ -658,14 +681,17 @@ export class CodeReviewSession implements ICodeReviewSession { 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: !!this.staleReason, - ...(this.staleReason ? { staleReason: this.staleReason } : {}), + stale: !!staleReason, + ...(staleReason ? { staleReason } : {}), ...(this.lastRefreshAt ? { lastRefreshAt: new Date(this.lastRefreshAt) } : {}), ...(this.lastRefreshReason ? { lastRefreshReason: this.lastRefreshReason } : {}), }; diff --git a/tests/session.test.ts b/tests/session.test.ts index a3ed7062..08225ece 100644 --- a/tests/session.test.ts +++ b/tests/session.test.ts @@ -5,6 +5,7 @@ import { CodeReviewSession, SessionManager, createCodeReviewSession } from "../s 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"; @@ -276,6 +277,44 @@ index 1234567..abcdef0 100644 } }); + 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 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 { @@ -411,6 +450,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, From 52d510f4f30007ca0918a472fdefa2bb45551cd8 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Sun, 28 Jun 2026 12:41:00 -0400 Subject: [PATCH 09/18] Harden session refresh concurrency --- src/index.ts | 2 ++ src/session.ts | 25 ++++++++++++++++++-- tests/session.test.ts | 55 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index 7f0f8644..7fafd763 100644 --- a/src/index.ts +++ b/src/index.ts @@ -175,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/session.ts b/src/session.ts index b723a2e7..4221d243 100644 --- a/src/session.ts +++ b/src/session.ts @@ -47,9 +47,9 @@ export type SessionOptions = { export type SessionStatus = "initializing" | "ready" | "expired" | "error"; -type SessionStaleReason = "tracked_files_changed" | "config_changed"; +export type SessionStaleReason = "tracked_files_changed" | "config_changed"; -type SessionStats = { +export type SessionStats = { status: SessionStatus; fileCount: number; symbolCount: number; @@ -199,6 +199,7 @@ 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(); @@ -539,6 +540,10 @@ export class CodeReviewSession implements ICodeReviewSession { private async ensureFreshIndex(options: { force?: boolean; file?: string } = {}): Promise { this.checkExpiration(); + if (this.refreshPromise) { + await this.refreshPromise; + return this.getIndex(); + } this.checkForStalenessNow(options); const index = this.getIndex(); if (!this.staleReason) { @@ -632,6 +637,22 @@ export class CodeReviewSession implements ICodeReviewSession { * Refresh the index (manual full rebuild; stale checks full rebuild only after file-set drift) */ 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; diff --git a/tests/session.test.ts b/tests/session.test.ts index 08225ece..617abcc4 100644 --- a/tests/session.test.ts +++ b/tests/session.test.ts @@ -559,6 +559,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, From 58d8ea926793c46bd2ba8306c9176c8a9cd8d385 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Sun, 28 Jun 2026 13:06:41 -0400 Subject: [PATCH 10/18] Reload session discovery config --- src/indexer/build-index.ts | 1 + src/session.ts | 25 +++++++++++++----- tests/cache-invalidation.test.ts | 1 + tests/session.test.ts | 44 ++++++++++++++++++++++++++++++-- 4 files changed, 62 insertions(+), 9 deletions(-) diff --git a/src/indexer/build-index.ts b/src/indexer/build-index.ts index 62cc68e5..ba90a277 100644 --- a/src/indexer/build-index.ts +++ b/src/indexer/build-index.ts @@ -1025,6 +1025,7 @@ export async function buildProjectIndexIncremental( }); if (timings) timings.totalMs = Math.round(performance.now() - totalStart); if (report) { + initNativeBackendReport(report); snapshot.buildReport = report; } return snapshot; diff --git a/src/session.ts b/src/session.ts index 4221d243..fdcafcf1 100644 --- a/src/session.ts +++ b/src/session.ts @@ -25,6 +25,7 @@ import { } from "./impact/index.js"; import { analyzeImpactStreaming, type ImpactStreamChunk } from "./impact/streaming.js"; import { getSessionPreset, mergePreset, type PresetName } from "./presets.js"; +import { hasDiscoveryOptions, loadCodegraphConfig, mergeDiscoveryOptions } from "./config.js"; import { normalizePath, resolveFilePathWithinRoot } from "./util/paths.js"; import { listProjectFiles } from "./util/projectFiles.js"; @@ -234,26 +235,36 @@ 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(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(); + const projectFiles = await this.currentProjectFiles(currentBuildOptions); const report: BuildReport = { timings: {} }; - const buildOptions: IncrementalBuildOptions = { ...this.buildOptions, files: projectFiles, report }; + const buildOptions: IncrementalBuildOptions = { ...currentBuildOptions, files: projectFiles, report }; const index = await buildProjectIndexIncremental(this.root, buildOptions); return { index, report: index.buildReport ?? report, projectFiles }; } if (options.forceFull) { const report: BuildReport = { timings: {} }; - const buildOptions: BuildOptions = { ...this.buildOptions, report }; + 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 buildOptions: BuildOptions = { ...this.buildOptions }; + const buildOptions: BuildOptions = { ...currentBuildOptions }; const index = this.incremental ? await buildProjectIndexIncremental(this.root, buildOptions) : await buildProjectIndex(this.root, buildOptions); @@ -297,10 +308,10 @@ export class CodeReviewSession implements ICodeReviewSession { return path.join(this.root, "codegraph.config.json"); } - private async currentProjectFiles(): Promise { + private async currentProjectFiles(buildOptions?: BuildOptions): Promise { const discoveryOptions = { - ...this.buildOptions?.discovery, - ...(this.buildOptions?.logLevel ? { logLevel: this.buildOptions.logLevel } : {}), + ...buildOptions?.discovery, + ...(buildOptions?.logLevel ? { logLevel: buildOptions.logLevel } : {}), }; return await listProjectFiles(this.root, undefined, discoveryOptions); } diff --git a/tests/cache-invalidation.test.ts b/tests/cache-invalidation.test.ts index 3814b0c5..7df47e7e 100644 --- a/tests/cache-invalidation.test.ts +++ b/tests/cache-invalidation.test.ts @@ -622,6 +622,7 @@ describe("Cache invalidation and strict hashing", () => { expect(prepSpy).not.toHaveBeenCalled(); expect(incremental.buildReport).toBe(report); + expect(report.backend?.native.available).toEqual(expect.any(Boolean)); const moduleIndex = incremental.byFile.get(normalize(filePath)); expect(moduleIndex?.locals.some((local) => local.localName === "reportedSnap")).toBe(true); } finally { diff --git a/tests/session.test.ts b/tests/session.test.ts index 617abcc4..e307cb59 100644 --- a/tests/session.test.ts +++ b/tests/session.test.ts @@ -391,6 +391,38 @@ index 1234567..abcdef0 100644 } }); + 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 { @@ -1136,7 +1168,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); }); @@ -1159,8 +1200,7 @@ describe("SessionManager", () => { }, ]); - await Promise.resolve(); - await Promise.resolve(); + await allBuildsStarted; expect(buildSpy).toHaveBeenCalledTimes(2); From f24b9034374b26fc5aa21bb9fff9b1516270a0bc Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Sun, 28 Jun 2026 15:41:47 -0400 Subject: [PATCH 11/18] Capture reports for session builds --- src/session.ts | 7 ++++--- tests/session.test.ts | 28 +++++++++++++++++++++++++++- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/session.ts b/src/session.ts index fdcafcf1..6675341e 100644 --- a/src/session.ts +++ b/src/session.ts @@ -264,13 +264,14 @@ export class CodeReviewSession implements ICodeReviewSession { const projectFiles = this.indexedProjectFiles(index); return { index, report: index.buildReport ?? report, projectFiles }; } - const buildOptions: BuildOptions = { ...currentBuildOptions }; + 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 report = index.buildReport ?? { timings: {} }; - return { index, report, projectFiles }; + const buildReport = index.buildReport ?? report; + return { index, report: buildReport, projectFiles }; } private createDisposedDuringOperationError(operation: string): Error { diff --git a/tests/session.test.ts b/tests/session.test.ts index e307cb59..d46ceaad 100644 --- a/tests/session.test.ts +++ b/tests/session.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect, beforeAll, afterAll, 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"; @@ -69,6 +69,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(); From 91cd16110183f04bbf0058000c1ec71e870d224e Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Sun, 28 Jun 2026 18:38:08 -0400 Subject: [PATCH 12/18] Avoid session full stale scans --- src/session.ts | 12 +++++++----- tests/session.test.ts | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/src/session.ts b/src/session.ts index 6675341e..17078aaa 100644 --- a/src/session.ts +++ b/src/session.ts @@ -442,11 +442,13 @@ export class CodeReviewSession implements ICodeReviewSession { } if (!options.force && now - this.lastStaleCheckAt < CodeReviewSession.STALE_CHECK_INTERVAL_MS) return; this.lastStaleCheckAt = now; - const trackedReason = this.refreshNeededFromTrackedFiles(); - if (trackedReason) { - this.staleReason = trackedReason; - this.forceFullRefreshOnNextStaleCheck = false; - return; + if (options.force) { + const trackedReason = this.refreshNeededFromTrackedFiles(); + if (trackedReason) { + this.staleReason = trackedReason; + this.forceFullRefreshOnNextStaleCheck = false; + return; + } } const projectFilesChanged = this.projectDirectoriesChanged(); this.staleReason = projectFilesChanged ? "tracked_files_changed" : undefined; diff --git a/tests/session.test.ts b/tests/session.test.ts index d46ceaad..28a019de 100644 --- a/tests/session.test.ts +++ b/tests/session.test.ts @@ -341,6 +341,46 @@ index 1234567..abcdef0 100644 } }); + 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", + ); + const session = await createCodeReviewSession({ + root, + buildOptions: { cache: "memory", useBloomFilters: true }, + }); + Object.defineProperty(session, "lastStaleCheckAt", { + configurable: true, + value: 0, + writable: true, + }); + + 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 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 { From 17aca662fb59ae6e28932f50a3d89316815660ac Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Sun, 28 Jun 2026 22:25:09 -0400 Subject: [PATCH 13/18] fix: address review workflow feedback --- README.md | 11 +- codegraph-skill/codegraph/SKILL.md | 8 +- docs/agent-workflows.md | 9 +- docs/cli.md | 7 +- docs/how-it-works.md | 2 +- .../2026-06-27-roi-implementation-progress.md | 2 +- src/analysisSummary.ts | 5 +- src/cli/help.ts | 2 +- src/indexer/build-cache/project-snapshot.ts | 169 ++++++++++++++++++ src/indexer/build-index.ts | 7 + src/session.ts | 30 +++- tests/cache-invalidation.test.ts | 42 ++++- tests/impact.test.ts | 30 +++- tests/session.test.ts | 167 +++++++++++++++++ 14 files changed, 463 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 698cc63f..2c54c94f 100644 --- a/README.md +++ b/README.md @@ -77,13 +77,16 @@ 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 the default workflow: +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 -# daily worktree review +# compact reviewer handoff for current edits node ./dist/cli.js review --base HEAD --head WORKTREE --summary -# initial repo orientation with next-step suggestions +# 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 @@ -124,7 +127,7 @@ 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 -# review-first workflow for current edits +# fastest code-review handoff for current edits codegraph review --base HEAD --head WORKTREE --summary codegraph impact --base HEAD --head WORKTREE --pretty diff --git a/codegraph-skill/codegraph/SKILL.md b/codegraph-skill/codegraph/SKILL.md index b05016a6..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`. Use `codegraph impact --base HEAD --head WORKTREE --pretty` when you need the broader blast radius map. - Then choose the smallest useful follow-up: - packet: `codegraph packet get --pretty` diff --git a/docs/agent-workflows.md b/docs/agent-workflows.md index 3b3a3c06..2882c3df 100644 --- a/docs/agent-workflows.md +++ b/docs/agent-workflows.md @@ -6,10 +6,15 @@ Use Codegraph for structural repo questions: architecture, dependency direction, ## Start here -For current edits, start with one compact review packet: +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 ``` @@ -96,7 +101,7 @@ For agents performing code reviews or making multiple queries, use sessions to m - 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 and impact calls auto-refresh before serving results when tracked files drift. +The local review session refreshes manually with `refresh()` and records stale-snapshot metadata in `getStats()`. Navigation auto-refreshes for config changes, added or removed files, or edits to the requested file; 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: diff --git a/docs/cli.md b/docs/cli.md index dc48c71e..413d3304 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -12,9 +12,10 @@ Numeric options such as `--limit`, `--threads`, `--depth`, `--max-refs`, and tok Default workflow: -- current edits: `codegraph review --base HEAD --head WORKTREE --summary` +- 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` -- follow-up anchor: `codegraph search "" --json` then `codegraph explain ` +- targeted follow-up: `codegraph search "" --json` then `codegraph explain ` ## Runtime selection @@ -51,7 +52,7 @@ Cache and manifest reuse is rooted at `--root`. Reusing a project root lets comm ### Dependency graphs ```bash -# First-pass review for current local edits +# Fast code-review handoff for current local edits codegraph review --base HEAD --head WORKTREE --summary codegraph impact --base HEAD --head WORKTREE --pretty diff --git a/docs/how-it-works.md b/docs/how-it-works.md index a56708bb..70d3b370 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -118,7 +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 also watch tracked file and config signatures. When the working tree drifts, the next navigation or impact call refreshes the cached snapshot before serving results, and `getStats()` exposes stale/refresh metadata for callers that want to surface it. +- 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/plans/2026-06-27-roi-implementation-progress.md b/docs/plans/2026-06-27-roi-implementation-progress.md index aba4244b..d7c60e64 100644 --- a/docs/plans/2026-06-27-roi-implementation-progress.md +++ b/docs/plans/2026-06-27-roi-implementation-progress.md @@ -81,7 +81,7 @@ All ROI plan checklist items are complete. ### Repo-wide validation -- `npm run check` passes. +- `npm run check` passed in the implementation checkout; rerun it in the target native-build environment before merge. ## Working tree notes diff --git a/src/analysisSummary.ts b/src/analysisSummary.ts index f2afa356..ff8e113d 100644 --- a/src/analysisSummary.ts +++ b/src/analysisSummary.ts @@ -18,6 +18,9 @@ function deriveAnalysisBackend(input: { index?: ProjectIndex | undefined; report?: BuildReport | undefined; }): AnalysisBackend { + if (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; @@ -32,7 +35,7 @@ function deriveAnalysisBackend(input: { return "native"; } } - if (input.index?.nativeMode === "off") { + if (parserFallbackCount || importFallbackCount) { return "graph-only"; } return "unknown"; diff --git a/src/cli/help.ts b/src/cli/help.ts index 045fddf1..758e9b11 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -66,7 +66,7 @@ Output Options: Recommended first commands: codegraph review --base HEAD --head WORKTREE --summary - codegraph orient --root . --budget small --pretty + codegraph impact --base HEAD --head WORKTREE --pretty codegraph search "auth user" --json codegraph explain src/auth.ts --json diff --git a/src/indexer/build-cache/project-snapshot.ts b/src/indexer/build-cache/project-snapshot.ts index 51bb17a5..206313f4 100644 --- a/src/indexer/build-cache/project-snapshot.ts +++ b/src/indexer/build-cache/project-snapshot.ts @@ -9,6 +9,7 @@ import { BloomFilter, BloomFilterCache } from "../../util/bloomFilter.js"; import { SymbolKind, type BuildOptions, + type BuildReport, type ExportEntry, type ImportBinding, type ModuleIndex, @@ -31,6 +32,9 @@ type SerializedBloomFilter = { bitsBase64: string; }; +type SnapshotBuildReport = Pick; +type SnapshotParserBackendDegradationReport = NonNullable["parser"]>; + type ProjectIndexSnapshotPayload = { version: number; filesSignature: string; @@ -43,6 +47,7 @@ type ProjectIndexSnapshotPayload = { nativeMode?: ProjectIndex["nativeMode"]; projectFiles?: ProjectFileInfo[]; bloomFilters?: Record; + buildReport?: SnapshotBuildReport; }; export function projectSnapshotFilesSignature(entries: ReadonlyMap): string { @@ -102,6 +107,7 @@ export async function tryLoadProjectIndexSnapshot( ...(payload.projectFiles ? { projectFiles: payload.projectFiles } : {}), referenceCandidates: buildReferenceCandidateIndex(modules), ...(opts?.cache ? { cacheMode: opts.cache, cacheRootDir: cacheRoot(projectRoot, opts) } : {}), + ...(payload.buildReport ? { buildReport: { timings: {}, ...payload.buildReport } } : {}), }; } catch { return null; @@ -118,6 +124,7 @@ export async function writeProjectIndexSnapshot( const serializedBloomFilters = index.bloomFilters ? serializeBloomFilterCache(index.bloomFilters, index.byFile.keys()) : undefined; + const snapshotReport = snapshotBuildReport(index.buildReport); const payload: ProjectIndexSnapshotPayload = { version: PROJECT_SNAPSHOT_VERSION, filesSignature, @@ -132,6 +139,7 @@ export async function writeProjectIndexSnapshot( : {}), ...(index.projectFiles ? { projectFiles: index.projectFiles } : {}), ...(serializedBloomFilters ? { bloomFilters: serializedBloomFilters } : {}), + ...(snapshotReport ? { buildReport: snapshotReport } : {}), }; try { const snapshotPath = projectSnapshotPath(projectRoot, opts); @@ -142,6 +150,20 @@ export async function writeProjectIndexSnapshot( } } +function snapshotBuildReport(report: BuildReport | undefined): SnapshotBuildReport | undefined { + if (!report) { + return undefined; + } + const snapshot: SnapshotBuildReport = {}; + if (report.backend) { + snapshot.backend = report.backend; + } + if (report.graph) { + snapshot.graph = report.graph; + } + return snapshot.backend || snapshot.graph ? snapshot : undefined; +} + function projectSnapshotPath(projectRoot: string, opts: BuildOptions | undefined): string { return path.join(cacheRoot(projectRoot, opts), "project-index-snapshot.json"); } @@ -208,11 +230,158 @@ function isProjectIndexSnapshotPayload(value: unknown): value is ProjectIndexSna (payload.projectRoot === undefined || typeof payload.projectRoot === "string") && (payload.nativeMode === undefined || isSnapshotNativeMode(payload.nativeMode)) && (payload.bloomFilters === undefined || isSerializedBloomFilterRecord(payload.bloomFilters)) && + (payload.buildReport === undefined || isSnapshotBuildReport(payload.buildReport)) && (payload.projectFiles === undefined || (Array.isArray(payload.projectFiles) && payload.projectFiles.every(isProjectFileInfo))) ); } +function isSnapshotBuildReport(value: unknown): value is SnapshotBuildReport { + 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 NonNullable { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const report = value as Partial>; + return ( + report.native !== undefined && + isNativeBackendReport(report.native) && + (report.parser === undefined || isParserBackendDegradationReport(report.parser)) + ); +} + +function isNativeBackendReport(value: unknown): value is NonNullable["native"] { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const report = value as Partial["native"]>; + 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" && + isNumberRecord(report.fallbackReasons) && + isNativeLanguageReportRecord(report.byLanguage) && + Array.isArray(report.errors) && + report.errors.every(isNativeBackendError) && + (report.loadError === undefined || typeof report.loadError === "string") + ); +} + +function isNativeLanguageReportRecord( + value: unknown, +): value is NonNullable["native"]["byLanguage"] { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + return Object.values(value).every(isNativeBackendLanguageReport); +} + +function isNativeBackendLanguageReport( + value: unknown, +): value is NonNullable["native"]["byLanguage"][string] { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const report = value as Partial["native"]["byLanguage"][string]>; + return ( + typeof report.filesSeen === "number" && + typeof report.filesUsed === "number" && + typeof report.filesFellBack === "number" && + isNumberRecord(report.fallbackReasons) && + (report.normalizedQueryKinds === undefined || + (Array.isArray(report.normalizedQueryKinds) && + report.normalizedQueryKinds.every((kind) => typeof kind === "string"))) && + (report.skippedQueryKinds === undefined || + (Array.isArray(report.skippedQueryKinds) && report.skippedQueryKinds.every((kind) => typeof kind === "string"))) + ); +} + +function isNativeBackendError(value: unknown): boolean { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const error = value as { + file?: unknown; + languageId?: unknown; + reason?: unknown; + message?: unknown; + }; + return ( + typeof error.file === "string" && + typeof error.languageId === "string" && + typeof error.reason === "string" && + typeof error.message === "string" + ); +} + +function isParserBackendDegradationReport(value: unknown): value is SnapshotParserBackendDegradationReport { + 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) && + report.files.every(isParserBackendDegradationFile) + ); +} + +function isParserBackendDegradationFile(value: unknown): boolean { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const file = value as { + file?: unknown; + languageId?: unknown; + nativeFallbackReason?: unknown; + nativeError?: unknown; + jsError?: unknown; + }; + return ( + typeof file.file === "string" && + typeof file.languageId === "string" && + (file.nativeFallbackReason === undefined || typeof file.nativeFallbackReason === "string") && + (file.nativeError === undefined || typeof file.nativeError === "string") && + (file.jsError === undefined || typeof file.jsError === "string") + ); +} + +function isGraphReport(value: unknown): value is NonNullable { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const report = value as Partial>; + return ( + report.fallbackImportExtraction !== undefined && isFallbackImportExtractionReport(report.fallbackImportExtraction) + ); +} + +function isFallbackImportExtractionReport( + value: unknown, +): value is NonNullable["fallbackImportExtraction"] { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const report = value as Partial["fallbackImportExtraction"]>; + return ( + typeof report.total === "number" && + isNumberRecord(report.byLanguage) && + isFallbackImportExtractionFileRecord(report.files) && + (report.byReason === undefined || isNumberRecord(report.byReason)) + ); +} + +function isFallbackImportExtractionFileRecord( + value: unknown, +): value is NonNullable["fallbackImportExtraction"]["files"] { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + return Object.values(value).every(isFallbackImportExtractionFile); +} + +function isFallbackImportExtractionFile(value: unknown): boolean { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const file = value as { language?: unknown; reason?: unknown }; + return typeof file.language === "string" && typeof file.reason === "string"; +} + +function isNumberRecord(value: unknown): value is Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + return Object.values(value).every((entry) => typeof entry === "number"); +} + function serializeBloomFilterCache( cache: BloomFilterCache, files: Iterable, diff --git a/src/indexer/build-index.ts b/src/indexer/build-index.ts index ba90a277..51b38970 100644 --- a/src/indexer/build-index.ts +++ b/src/indexer/build-index.ts @@ -1026,6 +1026,12 @@ export async function buildProjectIndexIncremental( if (timings) timings.totalMs = Math.round(performance.now() - totalStart); if (report) { initNativeBackendReport(report); + if (snapshot.buildReport?.backend) { + report.backend = snapshot.buildReport.backend; + } + if (snapshot.buildReport?.graph) { + report.graph = snapshot.buildReport.graph; + } snapshot.buildReport = report; } return snapshot; @@ -1170,6 +1176,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/session.ts b/src/session.ts index 17078aaa..06a00cba 100644 --- a/src/session.ts +++ b/src/session.ts @@ -209,6 +209,7 @@ export class CodeReviewSession implements ICodeReviewSession { private staleReason: SessionStaleReason | undefined; private forceFullRefreshOnNextStaleCheck = false; private lastStaleCheckAt = 0; + private lastTrackedFileScanAt = 0; private lastRefreshAt: number | undefined; private lastRefreshReason: "initialization" | "manual" | "stale_check" | undefined; @@ -379,6 +380,7 @@ export class CodeReviewSession implements ICodeReviewSession { this.configSignature = this.statSignature(this.configFilePath()); this.staleReason = undefined; this.lastStaleCheckAt = Date.now(); + this.lastTrackedFileScanAt = 0; this.lastRefreshAt = this.lastStaleCheckAt; this.lastRefreshReason = reason; } @@ -430,7 +432,7 @@ export class CodeReviewSession implements ICodeReviewSession { this.forceFullRefreshOnNextStaleCheck = projectFilesChanged; } - private checkForStalenessNow(options: { force?: boolean; file?: string } = {}): void { + 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; @@ -440,9 +442,18 @@ export class CodeReviewSession implements ICodeReviewSession { this.forceFullRefreshOnNextStaleCheck = false; return; } - if (!options.force && now - this.lastStaleCheckAt < CodeReviewSession.STALE_CHECK_INTERVAL_MS) return; - this.lastStaleCheckAt = now; - if (options.force) { + + const trackedScanDue = + options.force || + (options.scanTrackedFiles && now - this.lastTrackedFileScanAt >= CodeReviewSession.STALE_CHECK_INTERVAL_MS); + const cheapCheckDue = + options.force || + options.scanTrackedFiles || + now - this.lastStaleCheckAt >= CodeReviewSession.STALE_CHECK_INTERVAL_MS; + if (!trackedScanDue && !cheapCheckDue) return; + + if (trackedScanDue) { + this.lastTrackedFileScanAt = now; const trackedReason = this.refreshNeededFromTrackedFiles(); if (trackedReason) { this.staleReason = trackedReason; @@ -450,6 +461,9 @@ export class CodeReviewSession implements ICodeReviewSession { return; } } + + if (!cheapCheckDue) return; + this.lastStaleCheckAt = now; const projectFilesChanged = this.projectDirectoriesChanged(); this.staleReason = projectFilesChanged ? "tracked_files_changed" : undefined; this.forceFullRefreshOnNextStaleCheck = projectFilesChanged; @@ -552,7 +566,9 @@ export class CodeReviewSession implements ICodeReviewSession { return this.index; } - private async ensureFreshIndex(options: { force?: boolean; file?: string } = {}): Promise { + private async ensureFreshIndex( + options: { force?: boolean; file?: string; scanTrackedFiles?: boolean } = {}, + ): Promise { this.checkExpiration(); if (this.refreshPromise) { await this.refreshPromise; @@ -582,7 +598,7 @@ export class CodeReviewSession implements ICodeReviewSession { * Results are cached in the warm index */ async analyzeImpact(options: ImpactOptions): Promise { - const index = await this.ensureFreshIndex(); + const index = await this.ensureFreshIndex({ scanTrackedFiles: true }); requireSessionImpactProvider(options); return await analyzeImpactFromDiff(this.root, index, options, { buildReport: this.buildReport }); } @@ -592,7 +608,7 @@ export class CodeReviewSession implements ICodeReviewSession { * Better for agents as they can start processing immediately */ async *analyzeImpactStream(options: ImpactStreamingOptions): AsyncGenerator { - const index = await this.ensureFreshIndex(); + const index = await this.ensureFreshIndex({ scanTrackedFiles: true }); requireSessionImpactProvider(options); yield* analyzeImpactStreaming(this.root, index, options, { buildReport: this.buildReport }); } diff --git a/tests/cache-invalidation.test.ts b/tests/cache-invalidation.test.ts index 7df47e7e..abe06851 100644 --- a/tests/cache-invalidation.test.ts +++ b/tests/cache-invalidation.test.ts @@ -602,7 +602,43 @@ describe("Cache invalidation and strict hashing", () => { await fsp.writeFile(filePath, `export const reportedSnap = 1;\n`, "utf8"); await buildProjectIndex(root, { threads: 2, cache: "disk" }); - await expect(fsp.stat(projectSnapshotPathFor(root))).resolves.toBeTruthy(); + const snapshotPath = projectSnapshotPathFor(root); + await expect(fsp.stat(snapshotPath)).resolves.toBeTruthy(); + const snapshot = JSON.parse(await fsp.readFile(snapshotPath, "utf8")) as Record; + snapshot.buildReport = { + backend: { + native: { + available: true, + enabled: true, + supportedLanguageIds: ["typescript"], + filesUsed: 7, + filesFellBack: 1, + fallbackReasons: { queryFailure: 1 }, + byLanguage: { + typescript: { + filesSeen: 8, + filesUsed: 7, + filesFellBack: 1, + fallbackReasons: { queryFailure: 1 }, + }, + }, + errors: [], + }, + }, + graph: { + fallbackImportExtraction: { + total: 2, + byLanguage: { typescript: 2 }, + files: { + [normalize(filePath)]: { + language: "typescript", + reason: "parse_error", + }, + }, + }, + }, + }; + await fsp.writeFile(snapshotPath, JSON.stringify(snapshot), "utf8"); const db = new DatabaseSync(diskCacheDbPathFor(root)); try { @@ -622,7 +658,9 @@ describe("Cache invalidation and strict hashing", () => { expect(prepSpy).not.toHaveBeenCalled(); expect(incremental.buildReport).toBe(report); - expect(report.backend?.native.available).toEqual(expect.any(Boolean)); + expect(report.backend?.native.filesUsed).toBe(7); + expect(report.backend?.native.filesFellBack).toBe(1); + expect(report.graph?.fallbackImportExtraction.total).toBe(2); const moduleIndex = incremental.byFile.get(normalize(filePath)); expect(moduleIndex?.locals.some((local) => local.localName === "reportedSnap")).toBe(true); } finally { diff --git a/tests/impact.test.ts b/tests/impact.test.ts index da556c0b..d7b956df 100644 --- a/tests/impact.test.ts +++ b/tests/impact.test.ts @@ -6,8 +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 } from "../src/indexer/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"; @@ -377,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/session.test.ts b/tests/session.test.ts index 28a019de..b5ea1844 100644 --- a/tests/session.test.ts +++ b/tests/session.test.ts @@ -381,6 +381,173 @@ index 1234567..abcdef0 100644 } }); + 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"); + 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"); + Object.defineProperty(session, "lastStaleCheckAt", { + configurable: true, + value: Date.now(), + writable: true, + }); + Object.defineProperty(session, "lastTrackedFileScanAt", { + configurable: true, + value: 0, + writable: true, + }); + + 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"); + 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"); + Object.defineProperty(session, "lastStaleCheckAt", { + configurable: true, + value: Date.now(), + writable: true, + }); + Object.defineProperty(session, "lastTrackedFileScanAt", { + configurable: true, + value: 0, + writable: true, + }); + + 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"); + 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; +`; + Object.defineProperty(session, "lastTrackedFileScanAt", { + configurable: true, + value: 0, + writable: true, + }); + + 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 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 { From 7807abbf59dd597d4951f2aa8baaf76fd21d61f7 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Sun, 28 Jun 2026 22:39:45 -0400 Subject: [PATCH 14/18] fix: close review verification regressions --- docs/agent-workflows.md | 2 +- src/analysisSummary.ts | 4 ++- src/cli/help.ts | 7 ++++-- src/review.ts | 4 ++- src/session.ts | 3 +-- tests/review.test.ts | 38 +++++++++++++++++++++++++++- tests/session.test.ts | 55 +++++++++++++++++++++++++++++++++++++++++ 7 files changed, 105 insertions(+), 8 deletions(-) diff --git a/docs/agent-workflows.md b/docs/agent-workflows.md index 2882c3df..26640c18 100644 --- a/docs/agent-workflows.md +++ b/docs/agent-workflows.md @@ -101,7 +101,7 @@ For agents performing code reviews or making multiple queries, use sessions to m - 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 auto-refreshes for config changes, added or removed files, or edits to the requested file; impact calls add an interval-throttled tracked-file scan before computing the report. +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: diff --git a/src/analysisSummary.ts b/src/analysisSummary.ts index ff8e113d..4bd7bddf 100644 --- a/src/analysisSummary.ts +++ b/src/analysisSummary.ts @@ -17,8 +17,9 @@ export type AnalysisSummary = { function deriveAnalysisBackend(input: { index?: ProjectIndex | undefined; report?: BuildReport | undefined; + nativeMode?: ProjectIndex["nativeMode"] | undefined; }): AnalysisBackend { - if (input.index?.nativeMode === "off") { + if (input.nativeMode === "off" || input.index?.nativeMode === "off") { return "graph-only"; } const nativeReport = input.report?.backend?.native; @@ -78,6 +79,7 @@ export function formatAnalysisSummaryLabel(summary: AnalysisSummary): string { export function summarizeAnalysis(input: { index?: ProjectIndex | undefined; + nativeMode?: ProjectIndex["nativeMode"] | undefined; report?: BuildReport | undefined; }): AnalysisSummary { const parserDegradedFiles = input.report?.backend?.parser?.total ?? 0; diff --git a/src/cli/help.ts b/src/cli/help.ts index 758e9b11..6d9297a8 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -64,12 +64,15 @@ Output Options: --output Write to file instead of stdout --stdout Write default graph output to stdout -Recommended first commands: +Recommended review commands: codegraph review --base HEAD --head WORKTREE --summary - codegraph impact --base HEAD --head WORKTREE --pretty + 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 review --base HEAD --head WORKTREE --summary codegraph orient ./src --budget small --pretty diff --git a/src/review.ts b/src/review.ts index 745fda8a..da43c815 100644 --- a/src/review.ts +++ b/src/review.ts @@ -232,7 +232,9 @@ export async function buildReviewReport(projectRoot: string, opts: ReviewOptions const report: ReviewReport = { schemaVersion: REVIEW_SCHEMA_VERSION, status: "no_changes", - ...(reviewReport?.indexReport ? { analysis: summarizeAnalysis({ report: reviewReport.indexReport }) } : {}), + ...(reviewReport?.indexReport + ? { analysis: summarizeAnalysis({ nativeMode: appliedOptions.native, report: reviewReport.indexReport }) } + : {}), projectFiles, summary: { filesChanged: 0, symbolsChanged: 0, candidateTests: 0 }, riskSummary, diff --git a/src/session.ts b/src/session.ts index 06a00cba..9b8e959b 100644 --- a/src/session.ts +++ b/src/session.ts @@ -448,8 +448,7 @@ export class CodeReviewSession implements ICodeReviewSession { (options.scanTrackedFiles && now - this.lastTrackedFileScanAt >= CodeReviewSession.STALE_CHECK_INTERVAL_MS); const cheapCheckDue = options.force || - options.scanTrackedFiles || - now - this.lastStaleCheckAt >= CodeReviewSession.STALE_CHECK_INTERVAL_MS; + (!options.scanTrackedFiles && now - this.lastStaleCheckAt >= CodeReviewSession.STALE_CHECK_INTERVAL_MS); if (!trackedScanDue && !cheapCheckDue) return; if (trackedScanDue) { 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 b5ea1844..2e5854a4 100644 --- a/tests/session.test.ts +++ b/tests/session.test.ts @@ -548,6 +548,61 @@ index 1234567..abcdef0 100644 } }); + 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"); + const session = await createCodeReviewSession({ + root, + buildOptions: { cache: "memory", useBloomFilters: true }, + }); + Object.defineProperty(session, "lastStaleCheckAt", { + configurable: true, + value: 0, + writable: true, + }); + Object.defineProperty(session, "lastTrackedFileScanAt", { + configurable: true, + value: 0, + writable: true, + }); + + 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"); + + const buildSpy = vi.spyOn(indexerBuild, "buildProjectIndexIncremental"); + try { + const result = await session.goToDefinition({ + file: mainPath, + line: 2, + column: 22, + }); + + expect(result.status).toBeDefined(); + 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 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 { From be733a95b45377f4a3f10b3a1c61cbc2612c7edb Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Sun, 28 Jun 2026 22:55:42 -0400 Subject: [PATCH 15/18] fix: preserve impact drift checks --- README.md | 4 +- docs/agent-workflows.md | 4 +- src/indexer/build-cache/project-snapshot.ts | 58 +++++--- src/indexer/build-index.ts | 20 ++- src/session.ts | 25 +++- tests/session.test.ts | 155 ++++++++++++++++++++ 6 files changed, 233 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 2c54c94f..40d21e39 100644 --- a/README.md +++ b/README.md @@ -401,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/docs/agent-workflows.md b/docs/agent-workflows.md index 26640c18..f71cfd91 100644 --- a/docs/agent-workflows.md +++ b/docs/agent-workflows.md @@ -365,11 +365,11 @@ 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, then add a ranked blast-radius map only when needed: ```bash -codegraph impact --base HEAD --head WORKTREE --pretty codegraph review --base HEAD --head WORKTREE --summary +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. diff --git a/src/indexer/build-cache/project-snapshot.ts b/src/indexer/build-cache/project-snapshot.ts index 206313f4..be990cba 100644 --- a/src/indexer/build-cache/project-snapshot.ts +++ b/src/indexer/build-cache/project-snapshot.ts @@ -32,8 +32,23 @@ type SerializedBloomFilter = { bitsBase64: string; }; -type SnapshotBuildReport = Pick; type SnapshotParserBackendDegradationReport = NonNullable["parser"]>; +type SnapshotNativeBackendReport = Pick< + NonNullable["native"], + "filesUsed" | "filesFellBack" | "fallbackReasons" | "byLanguage" +>; +type SnapshotBuildReport = { + backend?: { + native?: SnapshotNativeBackendReport; + parser?: SnapshotParserBackendDegradationReport; + }; + graph?: NonNullable; +}; + +export type LoadedProjectIndexSnapshot = { + index: ProjectIndex; + buildReport?: SnapshotBuildReport; +}; type ProjectIndexSnapshotPayload = { version: number; @@ -75,7 +90,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; @@ -92,7 +107,7 @@ export async function tryLoadProjectIndexSnapshot( }; const modules = new Map(payload.modules.map((moduleIndex) => [moduleIndex.file, moduleIndex])); const shouldHydrateBloomFilters = opts?.useBloomFilters ?? true; - return { + const index: ProjectIndex = { graph, graphAdjacency: buildGraphAdjacency(graph), modules, @@ -107,7 +122,10 @@ export async function tryLoadProjectIndexSnapshot( ...(payload.projectFiles ? { projectFiles: payload.projectFiles } : {}), referenceCandidates: buildReferenceCandidateIndex(modules), ...(opts?.cache ? { cacheMode: opts.cache, cacheRootDir: cacheRoot(projectRoot, opts) } : {}), - ...(payload.buildReport ? { buildReport: { timings: {}, ...payload.buildReport } } : {}), + }; + return { + index, + ...(payload.buildReport ? { buildReport: payload.buildReport } : {}), }; } catch { return null; @@ -156,7 +174,17 @@ function snapshotBuildReport(report: BuildReport | undefined): SnapshotBuildRepo } const snapshot: SnapshotBuildReport = {}; if (report.backend) { - snapshot.backend = report.backend; + const backend: NonNullable = {}; + backend.native = { + filesUsed: report.backend.native.filesUsed, + filesFellBack: report.backend.native.filesFellBack, + fallbackReasons: report.backend.native.fallbackReasons, + byLanguage: report.backend.native.byLanguage, + }; + if (report.backend.parser) { + backend.parser = report.backend.parser; + } + snapshot.backend = backend; } if (report.graph) { snapshot.graph = report.graph; @@ -245,31 +273,23 @@ function isSnapshotBuildReport(value: unknown): value is SnapshotBuildReport { ); } -function isBackendReport(value: unknown): value is NonNullable { +function isBackendReport(value: unknown): value is NonNullable { if (!value || typeof value !== "object" || Array.isArray(value)) return false; - const report = value as Partial>; + const report = value as Partial>; return ( - report.native !== undefined && - isNativeBackendReport(report.native) && + (report.native === undefined || isNativeBackendReport(report.native)) && (report.parser === undefined || isParserBackendDegradationReport(report.parser)) ); } -function isNativeBackendReport(value: unknown): value is NonNullable["native"] { +function isNativeBackendReport(value: unknown): value is SnapshotNativeBackendReport { if (!value || typeof value !== "object" || Array.isArray(value)) return false; - const report = value as Partial["native"]>; + 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" && isNumberRecord(report.fallbackReasons) && - isNativeLanguageReportRecord(report.byLanguage) && - Array.isArray(report.errors) && - report.errors.every(isNativeBackendError) && - (report.loadError === undefined || typeof report.loadError === "string") + isNativeLanguageReportRecord(report.byLanguage) ); } diff --git a/src/indexer/build-index.ts b/src/indexer/build-index.ts index 51b38970..e0e298aa 100644 --- a/src/indexer/build-index.ts +++ b/src/indexer/build-index.ts @@ -1001,8 +1001,9 @@ export async function buildProjectIndexIncremental( if (fileReport) fileReport.changed = changedFiles.size; 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 } : {}), }); @@ -1026,11 +1027,18 @@ export async function buildProjectIndexIncremental( if (timings) timings.totalMs = Math.round(performance.now() - totalStart); if (report) { initNativeBackendReport(report); - if (snapshot.buildReport?.backend) { - report.backend = snapshot.buildReport.backend; + const snapshotBackend = snapshotLoad.buildReport?.backend; + if (snapshotBackend?.native && report.backend) { + report.backend.native.filesUsed = snapshotBackend.native.filesUsed; + report.backend.native.filesFellBack = snapshotBackend.native.filesFellBack; + report.backend.native.fallbackReasons = snapshotBackend.native.fallbackReasons; + report.backend.native.byLanguage = snapshotBackend.native.byLanguage; } - if (snapshot.buildReport?.graph) { - report.graph = snapshot.buildReport.graph; + if (snapshotBackend?.parser && report.backend) { + report.backend.parser = snapshotBackend.parser; + } + if (snapshotLoad.buildReport?.graph) { + report.graph = snapshotLoad.buildReport.graph; } snapshot.buildReport = report; } diff --git a/src/session.ts b/src/session.ts index 9b8e959b..f4996d32 100644 --- a/src/session.ts +++ b/src/session.ts @@ -210,6 +210,7 @@ export class CodeReviewSession implements ICodeReviewSession { private forceFullRefreshOnNextStaleCheck = false; private lastStaleCheckAt = 0; private lastTrackedFileScanAt = 0; + private lastImpactProjectDriftCheckAt = 0; private lastRefreshAt: number | undefined; private lastRefreshReason: "initialization" | "manual" | "stale_check" | undefined; @@ -381,6 +382,7 @@ export class CodeReviewSession implements ICodeReviewSession { this.staleReason = undefined; this.lastStaleCheckAt = Date.now(); this.lastTrackedFileScanAt = 0; + this.lastImpactProjectDriftCheckAt = 0; this.lastRefreshAt = this.lastStaleCheckAt; this.lastRefreshReason = reason; } @@ -446,10 +448,14 @@ export class CodeReviewSession implements ICodeReviewSession { const trackedScanDue = options.force || (options.scanTrackedFiles && now - this.lastTrackedFileScanAt >= CodeReviewSession.STALE_CHECK_INTERVAL_MS); - const cheapCheckDue = + const navigationProjectDriftDue = options.force || (!options.scanTrackedFiles && now - this.lastStaleCheckAt >= CodeReviewSession.STALE_CHECK_INTERVAL_MS); - if (!trackedScanDue && !cheapCheckDue) return; + const impactProjectDriftDue = + options.force || + (options.scanTrackedFiles && + now - this.lastImpactProjectDriftCheckAt >= CodeReviewSession.STALE_CHECK_INTERVAL_MS); + if (!trackedScanDue && !navigationProjectDriftDue && !impactProjectDriftDue) return; if (trackedScanDue) { this.lastTrackedFileScanAt = now; @@ -461,8 +467,19 @@ export class CodeReviewSession implements ICodeReviewSession { } } - if (!cheapCheckDue) return; - this.lastStaleCheckAt = now; + 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; diff --git a/tests/session.test.ts b/tests/session.test.ts index 2e5854a4..9b554fe1 100644 --- a/tests/session.test.ts +++ b/tests/session.test.ts @@ -582,6 +582,7 @@ index 1234567..abcdef0 100644 `, }); 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 { @@ -603,6 +604,160 @@ index 1234567..abcdef0 100644 } }); + 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"); + 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)); + Object.defineProperty(session, "lastStaleCheckAt", { + configurable: true, + value: Date.now(), + writable: true, + }); + Object.defineProperty(session, "lastTrackedFileScanAt", { + configurable: true, + value: Date.now(), + writable: true, + }); + Object.defineProperty(session, "lastImpactProjectDriftCheckAt", { + configurable: true, + value: 0, + writable: true, + }); + + 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"); + 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)); + Object.defineProperty(session, "lastStaleCheckAt", { + configurable: true, + value: Date.now(), + writable: true, + }); + Object.defineProperty(session, "lastTrackedFileScanAt", { + configurable: true, + value: Date.now(), + writable: true, + }); + Object.defineProperty(session, "lastImpactProjectDriftCheckAt", { + configurable: true, + value: 0, + writable: true, + }); + + 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", + ); + 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", + ); + Object.defineProperty(session, "lastStaleCheckAt", { + configurable: true, + value: 0, + writable: true, + }); + + 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 { From 4a01be27d237fa9dfd53a985e8d3f5803100523e Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Sun, 28 Jun 2026 23:24:05 -0400 Subject: [PATCH 16/18] fix: cache analysis summaries safely --- src/analysisSummary.ts | 3 + src/indexer/build-cache/project-snapshot.ts | 213 +++----------------- src/indexer/build-index.ts | 13 -- src/indexer/types.ts | 10 + tests/cache-invalidation.test.ts | 49 +---- 5 files changed, 50 insertions(+), 238 deletions(-) diff --git a/src/analysisSummary.ts b/src/analysisSummary.ts index 4bd7bddf..138177d9 100644 --- a/src/analysisSummary.ts +++ b/src/analysisSummary.ts @@ -82,6 +82,9 @@ export function summarizeAnalysis(input: { 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; diff --git a/src/indexer/build-cache/project-snapshot.ts b/src/indexer/build-cache/project-snapshot.ts index be990cba..c4809f64 100644 --- a/src/indexer/build-cache/project-snapshot.ts +++ b/src/indexer/build-cache/project-snapshot.ts @@ -6,16 +6,10 @@ import { buildGraphAdjacency } from "../../graphs/adjacency.js"; import { buildReferenceCandidateIndex } from "../reference-candidates.js"; import type { ProjectFileInfo } from "../../util/projectFiles.js"; import { BloomFilter, BloomFilterCache } from "../../util/bloomFilter.js"; -import { - SymbolKind, - type BuildOptions, - type BuildReport, - type ExportEntry, - type ImportBinding, - type ModuleIndex, - type ProjectIndex, - type SymbolDef, -} from "../types.js"; +import { summarizeAnalysis } from "../../analysisSummary.js"; +import type { AnalysisSummary } from "../../analysisSummary.js"; +import { SymbolKind } from "../types.js"; +import type { BuildOptions, ExportEntry, ImportBinding, ModuleIndex, ProjectIndex, SymbolDef } from "../types.js"; import { cacheRoot } from "./module-cache.js"; import type { ManifestFileEntry } from "./manifest.js"; @@ -32,22 +26,9 @@ type SerializedBloomFilter = { bitsBase64: string; }; -type SnapshotParserBackendDegradationReport = NonNullable["parser"]>; -type SnapshotNativeBackendReport = Pick< - NonNullable["native"], - "filesUsed" | "filesFellBack" | "fallbackReasons" | "byLanguage" ->; -type SnapshotBuildReport = { - backend?: { - native?: SnapshotNativeBackendReport; - parser?: SnapshotParserBackendDegradationReport; - }; - graph?: NonNullable; -}; - export type LoadedProjectIndexSnapshot = { index: ProjectIndex; - buildReport?: SnapshotBuildReport; + analysis?: AnalysisSummary; }; type ProjectIndexSnapshotPayload = { @@ -62,7 +43,7 @@ type ProjectIndexSnapshotPayload = { nativeMode?: ProjectIndex["nativeMode"]; projectFiles?: ProjectFileInfo[]; bloomFilters?: Record; - buildReport?: SnapshotBuildReport; + analysis?: AnalysisSummary; }; export function projectSnapshotFilesSignature(entries: ReadonlyMap): string { @@ -124,8 +105,11 @@ export async function tryLoadProjectIndexSnapshot( ...(opts?.cache ? { cacheMode: opts.cache, cacheRootDir: cacheRoot(projectRoot, opts) } : {}), }; return { - index, - ...(payload.buildReport ? { buildReport: payload.buildReport } : {}), + index: { + ...index, + ...(payload.analysis ? { analysis: payload.analysis } : {}), + }, + ...(payload.analysis ? { analysis: payload.analysis } : {}), }; } catch { return null; @@ -142,7 +126,7 @@ export async function writeProjectIndexSnapshot( const serializedBloomFilters = index.bloomFilters ? serializeBloomFilterCache(index.bloomFilters, index.byFile.keys()) : undefined; - const snapshotReport = snapshotBuildReport(index.buildReport); + const snapshotAnalysis = index.buildReport ? summarizeAnalysis({ index, report: index.buildReport }) : index.analysis; const payload: ProjectIndexSnapshotPayload = { version: PROJECT_SNAPSHOT_VERSION, filesSignature, @@ -157,7 +141,7 @@ export async function writeProjectIndexSnapshot( : {}), ...(index.projectFiles ? { projectFiles: index.projectFiles } : {}), ...(serializedBloomFilters ? { bloomFilters: serializedBloomFilters } : {}), - ...(snapshotReport ? { buildReport: snapshotReport } : {}), + ...(snapshotAnalysis ? { analysis: snapshotAnalysis } : {}), }; try { const snapshotPath = projectSnapshotPath(projectRoot, opts); @@ -168,30 +152,6 @@ export async function writeProjectIndexSnapshot( } } -function snapshotBuildReport(report: BuildReport | undefined): SnapshotBuildReport | undefined { - if (!report) { - return undefined; - } - const snapshot: SnapshotBuildReport = {}; - if (report.backend) { - const backend: NonNullable = {}; - backend.native = { - filesUsed: report.backend.native.filesUsed, - filesFellBack: report.backend.native.filesFellBack, - fallbackReasons: report.backend.native.fallbackReasons, - byLanguage: report.backend.native.byLanguage, - }; - if (report.backend.parser) { - backend.parser = report.backend.parser; - } - snapshot.backend = backend; - } - if (report.graph) { - snapshot.graph = report.graph; - } - return snapshot.backend || snapshot.graph ? snapshot : undefined; -} - function projectSnapshotPath(projectRoot: string, opts: BuildOptions | undefined): string { return path.join(cacheRoot(projectRoot, opts), "project-index-snapshot.json"); } @@ -258,150 +218,29 @@ function isProjectIndexSnapshotPayload(value: unknown): value is ProjectIndexSna (payload.projectRoot === undefined || typeof payload.projectRoot === "string") && (payload.nativeMode === undefined || isSnapshotNativeMode(payload.nativeMode)) && (payload.bloomFilters === undefined || isSerializedBloomFilterRecord(payload.bloomFilters)) && - (payload.buildReport === undefined || isSnapshotBuildReport(payload.buildReport)) && + (payload.analysis === undefined || isAnalysisSummary(payload.analysis)) && (payload.projectFiles === undefined || (Array.isArray(payload.projectFiles) && payload.projectFiles.every(isProjectFileInfo))) ); } -function isSnapshotBuildReport(value: unknown): value is SnapshotBuildReport { - 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 NonNullable { - if (!value || typeof value !== "object" || Array.isArray(value)) return false; - const report = value as Partial>; - return ( - (report.native === undefined || isNativeBackendReport(report.native)) && - (report.parser === undefined || isParserBackendDegradationReport(report.parser)) - ); -} - -function isNativeBackendReport(value: unknown): value is SnapshotNativeBackendReport { - if (!value || typeof value !== "object" || Array.isArray(value)) return false; - const report = value as Partial; - return ( - typeof report.filesUsed === "number" && - typeof report.filesFellBack === "number" && - isNumberRecord(report.fallbackReasons) && - isNativeLanguageReportRecord(report.byLanguage) - ); -} - -function isNativeLanguageReportRecord( - value: unknown, -): value is NonNullable["native"]["byLanguage"] { - if (!value || typeof value !== "object" || Array.isArray(value)) return false; - return Object.values(value).every(isNativeBackendLanguageReport); -} - -function isNativeBackendLanguageReport( - value: unknown, -): value is NonNullable["native"]["byLanguage"][string] { - if (!value || typeof value !== "object" || Array.isArray(value)) return false; - const report = value as Partial["native"]["byLanguage"][string]>; - return ( - typeof report.filesSeen === "number" && - typeof report.filesUsed === "number" && - typeof report.filesFellBack === "number" && - isNumberRecord(report.fallbackReasons) && - (report.normalizedQueryKinds === undefined || - (Array.isArray(report.normalizedQueryKinds) && - report.normalizedQueryKinds.every((kind) => typeof kind === "string"))) && - (report.skippedQueryKinds === undefined || - (Array.isArray(report.skippedQueryKinds) && report.skippedQueryKinds.every((kind) => typeof kind === "string"))) - ); -} - -function isNativeBackendError(value: unknown): boolean { - if (!value || typeof value !== "object" || Array.isArray(value)) return false; - const error = value as { - file?: unknown; - languageId?: unknown; - reason?: unknown; - message?: unknown; - }; - return ( - typeof error.file === "string" && - typeof error.languageId === "string" && - typeof error.reason === "string" && - typeof error.message === "string" - ); -} - -function isParserBackendDegradationReport(value: unknown): value is SnapshotParserBackendDegradationReport { - 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) && - report.files.every(isParserBackendDegradationFile) - ); -} - -function isParserBackendDegradationFile(value: unknown): boolean { - if (!value || typeof value !== "object" || Array.isArray(value)) return false; - const file = value as { - file?: unknown; - languageId?: unknown; - nativeFallbackReason?: unknown; - nativeError?: unknown; - jsError?: unknown; - }; - return ( - typeof file.file === "string" && - typeof file.languageId === "string" && - (file.nativeFallbackReason === undefined || typeof file.nativeFallbackReason === "string") && - (file.nativeError === undefined || typeof file.nativeError === "string") && - (file.jsError === undefined || typeof file.jsError === "string") - ); -} - -function isGraphReport(value: unknown): value is NonNullable { - if (!value || typeof value !== "object" || Array.isArray(value)) return false; - const report = value as Partial>; - return ( - report.fallbackImportExtraction !== undefined && isFallbackImportExtractionReport(report.fallbackImportExtraction) - ); -} - -function isFallbackImportExtractionReport( - value: unknown, -): value is NonNullable["fallbackImportExtraction"] { +function isAnalysisSummary(value: unknown): value is AnalysisSummary { if (!value || typeof value !== "object" || Array.isArray(value)) return false; - const report = value as Partial["fallbackImportExtraction"]>; + const summary = value as Partial; return ( - typeof report.total === "number" && - isNumberRecord(report.byLanguage) && - isFallbackImportExtractionFileRecord(report.files) && - (report.byReason === undefined || isNumberRecord(report.byReason)) + (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 isFallbackImportExtractionFileRecord( - value: unknown, -): value is NonNullable["fallbackImportExtraction"]["files"] { - if (!value || typeof value !== "object" || Array.isArray(value)) return false; - return Object.values(value).every(isFallbackImportExtractionFile); -} - -function isFallbackImportExtractionFile(value: unknown): boolean { - if (!value || typeof value !== "object" || Array.isArray(value)) return false; - const file = value as { language?: unknown; reason?: unknown }; - return typeof file.language === "string" && typeof file.reason === "string"; -} - -function isNumberRecord(value: unknown): value is Record { - if (!value || typeof value !== "object" || Array.isArray(value)) return false; - return Object.values(value).every((entry) => typeof entry === "number"); -} - function serializeBloomFilterCache( cache: BloomFilterCache, files: Iterable, diff --git a/src/indexer/build-index.ts b/src/indexer/build-index.ts index e0e298aa..b2919534 100644 --- a/src/indexer/build-index.ts +++ b/src/indexer/build-index.ts @@ -1027,19 +1027,6 @@ export async function buildProjectIndexIncremental( if (timings) timings.totalMs = Math.round(performance.now() - totalStart); if (report) { initNativeBackendReport(report); - const snapshotBackend = snapshotLoad.buildReport?.backend; - if (snapshotBackend?.native && report.backend) { - report.backend.native.filesUsed = snapshotBackend.native.filesUsed; - report.backend.native.filesFellBack = snapshotBackend.native.filesFellBack; - report.backend.native.fallbackReasons = snapshotBackend.native.fallbackReasons; - report.backend.native.byLanguage = snapshotBackend.native.byLanguage; - } - if (snapshotBackend?.parser && report.backend) { - report.backend.parser = snapshotBackend.parser; - } - if (snapshotLoad.buildReport?.graph) { - report.graph = snapshotLoad.buildReport.graph; - } snapshot.buildReport = report; } return snapshot; diff --git a/src/indexer/types.ts b/src/indexer/types.ts index bfee7b34..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; @@ -108,6 +117,7 @@ export type ProjectIndex = { manifestEntries?: Map; cacheMode?: BuildOptions["cache"]; buildReport?: BuildReport; + analysis?: CachedAnalysisSummary; cacheRootDir?: string; }; diff --git a/tests/cache-invalidation.test.ts b/tests/cache-invalidation.test.ts index abe06851..53829e7f 100644 --- a/tests/cache-invalidation.test.ts +++ b/tests/cache-invalidation.test.ts @@ -601,44 +601,18 @@ describe("Cache invalidation and strict hashing", () => { const filePath = path.join(root, "foo.ts"); await fsp.writeFile(filePath, `export const reportedSnap = 1;\n`, "utf8"); - await buildProjectIndex(root, { threads: 2, cache: "disk" }); + 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 Record; - snapshot.buildReport = { - backend: { - native: { - available: true, - enabled: true, - supportedLanguageIds: ["typescript"], - filesUsed: 7, - filesFellBack: 1, - fallbackReasons: { queryFailure: 1 }, - byLanguage: { - typescript: { - filesSeen: 8, - filesUsed: 7, - filesFellBack: 1, - fallbackReasons: { queryFailure: 1 }, - }, - }, - errors: [], - }, - }, - graph: { - fallbackImportExtraction: { - total: 2, - byLanguage: { typescript: 2 }, - files: { - [normalize(filePath)]: { - language: "typescript", - reason: "parse_error", - }, - }, - }, - }, + const snapshot = JSON.parse(await fsp.readFile(snapshotPath, "utf8")) as { + analysis?: { + backend?: unknown; + label?: unknown; + }; }; - await fsp.writeFile(snapshotPath, JSON.stringify(snapshot), "utf8"); + expect(typeof snapshot.analysis?.backend).toBe("string"); + expect(typeof snapshot.analysis?.label).toBe("string"); const db = new DatabaseSync(diskCacheDbPathFor(root)); try { @@ -658,9 +632,8 @@ describe("Cache invalidation and strict hashing", () => { expect(prepSpy).not.toHaveBeenCalled(); expect(incremental.buildReport).toBe(report); - expect(report.backend?.native.filesUsed).toBe(7); - expect(report.backend?.native.filesFellBack).toBe(1); - expect(report.graph?.fallbackImportExtraction.total).toBe(2); + expect(incremental.analysis?.backend).toBe(snapshot.analysis?.backend); + expect(incremental.analysis?.label).toBe(snapshot.analysis?.label); const moduleIndex = incremental.byFile.get(normalize(filePath)); expect(moduleIndex?.locals.some((local) => local.localName === "reportedSnap")).toBe(true); } finally { From d422fd596782de2e255cfdf86e43a73d62848a7e Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Sun, 28 Jun 2026 23:40:00 -0400 Subject: [PATCH 17/18] fix: close remaining review regressions --- docs/agent-workflows.md | 7 +- src/indexer/build-cache/project-snapshot.ts | 99 ++++++++++++++++++++- src/indexer/build-index.ts | 10 ++- src/session.ts | 6 +- tests/cache-invalidation.test.ts | 8 ++ tests/session.test.ts | 50 ++++++++++- 6 files changed, 173 insertions(+), 7 deletions(-) diff --git a/docs/agent-workflows.md b/docs/agent-workflows.md index f71cfd91..8d29be63 100644 --- a/docs/agent-workflows.md +++ b/docs/agent-workflows.md @@ -365,10 +365,15 @@ 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 the compact review summary, then add a ranked blast-radius map only when needed: +For current local edits, start with the compact review summary: ```bash codegraph review --base HEAD --head WORKTREE --summary +``` + +Add a ranked blast-radius map only when needed: + +```bash codegraph impact --base HEAD --head WORKTREE --pretty ``` diff --git a/src/indexer/build-cache/project-snapshot.ts b/src/indexer/build-cache/project-snapshot.ts index c4809f64..001a31ce 100644 --- a/src/indexer/build-cache/project-snapshot.ts +++ b/src/indexer/build-cache/project-snapshot.ts @@ -9,7 +9,16 @@ 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 { BuildOptions, ExportEntry, ImportBinding, ModuleIndex, ProjectIndex, SymbolDef } 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"; @@ -26,9 +35,14 @@ type SerializedBloomFilter = { bitsBase64: string; }; +type SnapshotAnalysisReport = { + backend?: BackendReport; + graph?: GraphReport; +}; + export type LoadedProjectIndexSnapshot = { index: ProjectIndex; - analysis?: AnalysisSummary; + analysisReport?: SnapshotAnalysisReport; }; type ProjectIndexSnapshotPayload = { @@ -44,6 +58,7 @@ type ProjectIndexSnapshotPayload = { projectFiles?: ProjectFileInfo[]; bloomFilters?: Record; analysis?: AnalysisSummary; + analysisReport?: SnapshotAnalysisReport; }; export function projectSnapshotFilesSignature(entries: ReadonlyMap): string { @@ -110,6 +125,7 @@ export async function tryLoadProjectIndexSnapshot( ...(payload.analysis ? { analysis: payload.analysis } : {}), }, ...(payload.analysis ? { analysis: payload.analysis } : {}), + ...(payload.analysisReport ? { analysisReport: payload.analysisReport } : {}), }; } catch { return null; @@ -126,6 +142,7 @@ export async function writeProjectIndexSnapshot( 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, @@ -142,6 +159,7 @@ export async function writeProjectIndexSnapshot( ...(index.projectFiles ? { projectFiles: index.projectFiles } : {}), ...(serializedBloomFilters ? { bloomFilters: serializedBloomFilters } : {}), ...(snapshotAnalysis ? { analysis: snapshotAnalysis } : {}), + ...(snapshotAnalysisReport ? { analysisReport: snapshotAnalysisReport } : {}), }; try { const snapshotPath = projectSnapshotPath(projectRoot, opts); @@ -219,11 +237,88 @@ function isProjectIndexSnapshotPayload(value: unknown): value is ProjectIndexSna (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; diff --git a/src/indexer/build-index.ts b/src/indexer/build-index.ts index b2919534..02bc00a6 100644 --- a/src/indexer/build-index.ts +++ b/src/indexer/build-index.ts @@ -1026,7 +1026,15 @@ export async function buildProjectIndexIncremental( }); if (timings) timings.totalMs = Math.round(performance.now() - totalStart); if (report) { - initNativeBackendReport(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; diff --git a/src/session.ts b/src/session.ts index f4996d32..fe43f52d 100644 --- a/src/session.ts +++ b/src/session.ts @@ -211,6 +211,7 @@ export class CodeReviewSession implements ICodeReviewSession { private lastStaleCheckAt = 0; private lastTrackedFileScanAt = 0; private lastImpactProjectDriftCheckAt = 0; + private lastPassiveStaleCheckAt = 0; private lastRefreshAt: number | undefined; private lastRefreshReason: "initialization" | "manual" | "stale_check" | undefined; @@ -381,6 +382,7 @@ export class CodeReviewSession implements ICodeReviewSession { 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; @@ -419,8 +421,8 @@ export class CodeReviewSession implements ICodeReviewSession { private checkForStaleness(options: { force?: boolean } = {}): void { if (this.status !== "ready" || !this.index) return; const now = Date.now(); - if (!options.force && now - this.lastStaleCheckAt < CodeReviewSession.STALE_CHECK_INTERVAL_MS) return; - this.lastStaleCheckAt = 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) { diff --git a/tests/cache-invalidation.test.ts b/tests/cache-invalidation.test.ts index 53829e7f..5993d5a0 100644 --- a/tests/cache-invalidation.test.ts +++ b/tests/cache-invalidation.test.ts @@ -610,9 +610,15 @@ describe("Cache invalidation and strict hashing", () => { 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 { @@ -634,6 +640,8 @@ describe("Cache invalidation and strict hashing", () => { 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 { diff --git a/tests/session.test.ts b/tests/session.test.ts index 9b554fe1..a14d3cfe 100644 --- a/tests/session.test.ts +++ b/tests/session.test.ts @@ -592,7 +592,55 @@ index 1234567..abcdef0 100644 column: 22, }); - expect(result.status).toBeDefined(); + 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"); + const session = await createCodeReviewSession({ + root, + buildOptions: { cache: "memory", useBloomFilters: true }, + }); + + Object.defineProperty(session, "lastStaleCheckAt", { + configurable: true, + value: 0, + writable: true, + }); + 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 { From 842f51c4d31de3bac61b6623508a65db612f8ce2 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Sun, 28 Jun 2026 23:52:13 -0400 Subject: [PATCH 18/18] test: cover session freshness without private timer mutation --- tests/session.test.ts | 118 +++++++++++++----------------------------- 1 file changed, 36 insertions(+), 82 deletions(-) diff --git a/tests/session.test.ts b/tests/session.test.ts index a14d3cfe..23777176 100644 --- a/tests/session.test.ts +++ b/tests/session.test.ts @@ -1,4 +1,4 @@ -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, BuildReport } from "../src/indexer/types.js"; import { CodeReviewSession, SessionManager, createCodeReviewSession } from "../src/session.js"; @@ -34,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; @@ -353,15 +366,12 @@ index 1234567..abcdef0 100644 "import { value0 } from './dep0';\nexport const value = value0;\n", "utf8", ); + setSessionClock(); const session = await createCodeReviewSession({ root, buildOptions: { cache: "memory", useBloomFilters: true }, }); - Object.defineProperty(session, "lastStaleCheckAt", { - configurable: true, - value: 0, - writable: true, - }); + advancePastStaleInterval(); const statSpy = vi.spyOn(fs, "statSync"); try { @@ -388,6 +398,7 @@ index 1234567..abcdef0 100644 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 }, @@ -399,16 +410,8 @@ index 1234567..abcdef0 100644 }); expect(navigation.status).toBe("ok"); await fsp.writeFile(utilsPath, "export function helper() { return 42; }\n", "utf8"); - Object.defineProperty(session, "lastStaleCheckAt", { - configurable: true, - value: Date.now(), - writable: true, - }); - Object.defineProperty(session, "lastTrackedFileScanAt", { - configurable: true, - value: 0, - writable: true, - }); + advancePastStaleInterval(); + expect(session.getStats().status).toBe("ready"); const buildSpy = vi.spyOn(indexerBuild, "buildProjectIndexIncremental"); try { @@ -444,6 +447,7 @@ index 1234567..abcdef0 100644 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 }, @@ -455,16 +459,8 @@ index 1234567..abcdef0 100644 }); expect(navigation.status).toBe("ok"); await fsp.writeFile(utilsPath, "export function helper() { return 42; }\n", "utf8"); - Object.defineProperty(session, "lastStaleCheckAt", { - configurable: true, - value: Date.now(), - writable: true, - }); - Object.defineProperty(session, "lastTrackedFileScanAt", { - configurable: true, - value: 0, - writable: true, - }); + advancePastStaleInterval(); + expect(session.getStats().status).toBe("ready"); const buildSpy = vi.spyOn(indexerBuild, "buildProjectIndexIncremental"); try { @@ -507,6 +503,7 @@ index 1234567..abcdef0 100644 ); 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 }, @@ -520,11 +517,7 @@ index 1234567..abcdef0 100644 -export const value = value0; +export const value = value0 + 1; `; - Object.defineProperty(session, "lastTrackedFileScanAt", { - configurable: true, - value: 0, - writable: true, - }); + advancePastStaleInterval(); const statSpy = vi.spyOn(fs, "statSync"); const buildSpy = vi.spyOn(indexerBuild, "buildProjectIndexIncremental"); @@ -554,20 +547,12 @@ index 1234567..abcdef0 100644 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 }, }); - Object.defineProperty(session, "lastStaleCheckAt", { - configurable: true, - value: 0, - writable: true, - }); - Object.defineProperty(session, "lastTrackedFileScanAt", { - configurable: true, - value: 0, - writable: true, - }); + advancePastStaleInterval(); await session.analyzeImpact({ provider: "raw", @@ -614,16 +599,13 @@ index 1234567..abcdef0 100644 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 }, }); - Object.defineProperty(session, "lastStaleCheckAt", { - configurable: true, - value: 0, - writable: 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)); @@ -657,27 +639,14 @@ index 1234567..abcdef0 100644 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)); - Object.defineProperty(session, "lastStaleCheckAt", { - configurable: true, - value: Date.now(), - writable: true, - }); - Object.defineProperty(session, "lastTrackedFileScanAt", { - configurable: true, - value: Date.now(), - writable: true, - }); - Object.defineProperty(session, "lastImpactProjectDriftCheckAt", { - configurable: true, - value: 0, - writable: true, - }); + advancePastStaleInterval(); const buildSpy = vi.spyOn(indexerBuild, "buildProjectIndexIncremental"); try { @@ -709,27 +678,14 @@ index 1234567..abcdef0 100644 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)); - Object.defineProperty(session, "lastStaleCheckAt", { - configurable: true, - value: Date.now(), - writable: true, - }); - Object.defineProperty(session, "lastTrackedFileScanAt", { - configurable: true, - value: Date.now(), - writable: true, - }); - Object.defineProperty(session, "lastImpactProjectDriftCheckAt", { - configurable: true, - value: 0, - writable: true, - }); + advancePastStaleInterval(); const buildSpy = vi.spyOn(indexerBuild, "buildProjectIndexIncremental"); try { @@ -771,6 +727,7 @@ index 1234567..abcdef0 100644 JSON.stringify({ discovery: { includeGlobs: ["main.ts"] } }), "utf8", ); + setSessionClock(); const session = await createCodeReviewSession({ root, buildOptions: { cache: "memory", useBloomFilters: true }, @@ -780,11 +737,7 @@ index 1234567..abcdef0 100644 JSON.stringify({ discovery: { includeGlobs: ["other.ts"] } }), "utf8", ); - Object.defineProperty(session, "lastStaleCheckAt", { - configurable: true, - value: 0, - writable: true, - }); + advancePastStaleInterval(); const buildSpy = vi.spyOn(indexerBuild, "buildProjectIndexIncremental"); try { @@ -1352,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