diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..d485654
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,2 @@
+# Benchmark harnesses support validation and are not part of Codetrip's Go runtime.
+benchmarks/**/*.py linguist-detectable=false
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index a69158d..016d533 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -10,7 +10,22 @@ permissions:
contents: write
jobs:
+ quality:
+ name: Release gate
+ runs-on: ubuntu-24.04
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ cache: true
+
+ - name: Verify release correctness
+ run: make release-gate
+
build:
+ needs: quality
name: ${{ matrix.goos }}/${{ matrix.goarch }}
strategy:
fail-fast: false
diff --git a/.gitignore b/.gitignore
index 2ed0719..78e8c30 100644
--- a/.gitignore
+++ b/.gitignore
@@ -35,4 +35,12 @@ go.work.sum
bin
.validation
/dist
-.misc
\ No newline at end of file
+.misc
+
+# Public benchmark machine-local configuration and generated workspaces.
+benchmarks/competitive/config.local.json
+benchmarks/competitive/results.local/
+__pycache__/
+
+# Local README working drafts; the published English and Chinese files are tracked.
+/README_DRAFT*.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1d561eb..b45e1a1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,100 @@ All notable changes to codetrip will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [0.3.0] - 2026-08-10
+
+### Added
+
+- Added `Engine.Brief`, `codetrip brief`, and the MCP `brief` tool for
+ deterministic, bounded Agent Context Packs.
+- Added ranked callers, callees, contracts, implementations, entrypoints,
+ related tests, direct-impact summaries, confidence, evidence, and explicit
+ truncation to symbol briefs.
+- Added precise ambiguity errors and compact JSON plus terminal text output.
+- Added compiler-backed Go interface contracts from local generated sources
+ without indexing their generated implementation bodies.
+- Added exact TypeScript named-import aliases, wildcard barrel exports,
+ workspace packages, nested path mappings, and NodeNext source resolution.
+- Added change-aware impact analysis for behavior, signature, contract, and
+ removal changes, with confirmed dependents, contract impacts, entry points,
+ related tests, review candidates, evidence paths, and coverage assumptions.
+- Added automatic repository refresh with file fingerprints and explicit
+ `noop`, `incremental`, `full-fallback`, and `full` results.
+- Added language-neutral incremental ingestion using content, structure, and
+ call-shape fingerprints. File-local body changes, call changes, structural
+ dependency expansion, and file add/delete/rename now share one invalidation
+ and publication protocol across supported languages; incompatible persisted
+ state safely triggers an atomic full rebuild.
+- Added the MCP `index` tool so coding Agents can refresh graph and search data
+ after editing source without leaving the MCP workflow.
+- Added `codetrip mcp --watch` with recursive cross-platform filesystem
+ watching, debounced refreshes, dynamic repository discovery, and stderr-only
+ diagnostics. `mcp setup --watch` installs this mode into supported clients.
+
+### Changed
+
+- Promoted Go and TypeScript/TSX to Stable after multi-repository Precision,
+ Recall, and 60-symbol Brief reviews.
+- Go compiler semantics now load nested modules independently when a combined
+ workspace contains conflicting replacements.
+- Top-level Go function deletion, rename, and signature changes now reload the
+ affected package closure instead of every repository package, while retaining
+ compiler-resolved calls and clean-full equivalence.
+- Go embedded interfaces retain the true declaring owner for promoted methods
+ and use explicit `EXTENDS` relationships for embedding.
+- Public graph, CLI, MCP, and Go API source coordinates are consistently
+ one-based.
+- Stable-language calls now require lexical, import, receiver-type, or compiler
+ evidence instead of repository-wide unique-name guessing.
+- Impact propagation now preserves uncertainty across dispatch bridges,
+ prevents alternative implementations from becoming confirmed dependents,
+ and excludes review-only candidates from aggregated Git diff impact.
+- Changed CLI indexing to automatic refresh by default. `--full` forces a
+ complete rebuild and `--replace` remains a compatibility alias.
+- Incremental graph, symbol, source, vector invalidation, and metadata changes
+ now publish as one recoverable revision. Full replacements continue to use
+ immutable snapshots, and queries never observe a partially committed revision.
+
+### Fixed
+
+- Fixed missing cross-module Go calls, interface implementations, method
+ dispatch, and generated RPC server contracts.
+- Fixed local-variable and type-conversion calls being mapped to unrelated
+ same-name imported Go symbols.
+- Fixed superseded lexical Go relationships surviving compiler reconciliation.
+- Fixed TypeScript alias-import recall sampling and source target resolution.
+- Fixed source excerpts and Git diff-to-symbol mapping after the one-based
+ coordinate migration.
+- Fixed Go body-level call changes removing compiler-generated interface
+ method nodes and `HAS_METHOD`, `METHOD_IMPLEMENTS`, or `DISPATCHES_TO` facts.
+
+### Quality
+
+- Go reviewed Precision: 227/227; static Recall: 64/64.
+- Impact Golden Facts review: Precision 116/116 (100%); Recall 116/129
+ (89.9%). The post-remediation Agent A/B rerun was invalidated because failed
+ impact invocations were not detected, so no efficiency claim is made from it.
+- TypeScript/TSX reviewed Precision: 328/329; static Recall: 78/78.
+- All 12 maintained language fixtures and all five C++ Stable fixtures pass.
+- In a 20-task Codex A/B evaluation, `brief` reduced aggregate commands by
+ 7.0%, input tokens by 5.4%, and output tokens by 7.7% without observed
+ answer-quality regression.
+- In a blinded 20-task Codex A/B evaluation, `source` reduced aggregate
+ commands by 32.8%, file-reading commands by 50.0%, input tokens by 29.6%,
+ and output tokens by 18.5%; both arms passed 20/20 answer-quality reviews.
+- Added 16 automated structural incremental gates across Go and TypeScript,
+ covering function add/delete/rename/move, signature and interface changes,
+ cross-file calls, and imports. Incremental graph, symbol, and source results
+ must match a clean full index.
+- Compound structural edits on temporary runc and Understand-Anything checkouts
+ refreshed in 1.53 seconds and 0.33 seconds respectively; both incremental
+ node and edge exports were byte-for-byte identical to clean full indexes.
+- Added a reproducible no-LLM live-context benchmark. Across five Gin function
+ renames, Codetrip needed one query, returned zero mixed revisions, and matched
+ a clean full index in 5/5 runs. CodeGraph and codebase-memory-mcp needed four
+ and three product operations at P50, a 67%–75% round-trip reduction for
+ Codetrip in this scenario; this is not presented as equivalent token savings.
+
## [0.2.1] - 2026-07-24
### Added
diff --git a/Makefile b/Makefile
index 58d9407..3a23323 100644
--- a/Makefile
+++ b/Makefile
@@ -21,7 +21,7 @@ ifeq ($(GOOS),windows)
RELEASE_LDFLAGS += -extldflags=-static
endif
-.PHONY: all build release-build build-all clean test version install help
+.PHONY: all build release-build build-all clean test vet incremental-gate release-gate version install help
all: build
@@ -63,6 +63,19 @@ clean:
test:
go test ./...
+vet:
+ go vet ./...
+
+# Fast, named correctness gate for Go/TypeScript structural refreshes.
+incremental-gate:
+ go test -run TestV030StructuralIncrementalReleaseGate -count=1 .
+
+# Required before release artifacts are built. The full suite includes the
+# structural incremental gate, so it is intentionally executed only once.
+release-gate:
+ go test ./...
+ go vet ./...
+
version:
@echo "$(BINARY_NAME) v$(VERSION)"
@@ -76,6 +89,9 @@ help:
@echo " build-all - Dispatch the GitHub Actions multi-platform build"
@echo " clean - Remove build artifacts"
@echo " test - Run tests"
+ @echo " vet - Run Go static analysis"
+ @echo " incremental-gate - Compare structural incremental refreshes with clean full indexes"
+ @echo " release-gate - Run all correctness and static-analysis release checks"
@echo " version - Show version"
@echo " install - Install for the current platform"
@echo " help - Show this help"
diff --git a/README.md b/README.md
index a0c69d6..ded7ca7 100644
--- a/README.md
+++ b/README.md
@@ -1,250 +1,236 @@
-# codetrip [](https://github.com/mengshi02/codetrip/actions/workflows/go.yml) [](https://github.com/mengshi02/codetrip/releases) [](https://go.dev/) [](LICENSE)
+# codetrip
+
+[](https://github.com/mengshi02/codetrip/actions/workflows/go.yml)
+[](https://github.com/mengshi02/codetrip/releases)
+[](https://go.dev/)
+[](LICENSE)
+[](#3-connect-your-coding-agent)
+[](#3-connect-your-coding-agent)
+[](#3-connect-your-coding-agent)
+[](#3-connect-your-coding-agent)
+[](#1-download-a-single-binary)
+[](#1-download-a-single-binary)
+[](#1-download-a-single-binary)
[English](README.md) | [简体中文](README_ZH.md)
-**Give coding agents a structural map of your codebase.**
+## One edit. One query. One consistent graph.
-Codetrip turns a repository into a local typed code graph so Codex, Claude Code,
-Cursor, VS Code/Copilot, and GitHub Copilot CLI can answer questions that plain
-text search cannot:
+**Give your coding agent the map—not another search box.** Codetrip waits for
+one complete revision, then returns current source and current graph relations
+together. The Agent does not need to detect stale edges, reconcile mixed
+results, or retry until the index settles.
```text
-What calls this method? What could this change affect?
-How does this request flow? Can I safely rename this symbol?
+Without Codetrip: search → read → find callers → read → check tests → reconcile
+With Codetrip: brief ParseConfig → one bounded context pack
```
-It combines graph traversal, source search, and optional semantic retrieval
-behind one CLI, MCP server, and Go library. Indexes stay on your machine.
-When an MCP client invokes a tool, the selected result is returned to that
-client and may enter its model context under the client's data policy.
-Configuring an external embedding endpoint additionally sends indexed chunks to
-that endpoint; lexical and graph indexing require neither service.
+**Codetrip is a local Hybrid Graph-Augmented Code Intelligence Engine.** It
+turns a repository into a typed, queryable code graph so coding agents can
+understand symbols, trace dependencies, and reason about changes without
+reconstructing the architecture through repeated grep and file reads.
-## See It Work in Codex
+```text
+What is this symbol? Who calls or implements it?
+What will this edit affect? Is the Agent querying the latest code?
+```
-In a real read-only Codex run, Codetrip mapped the current working-tree diff to
-five changed CLI symbols, walked their reverse dependencies, separated
-documentation-only changes, and identified the shared runtime risk path:
+One native executable provides a CLI, an MCP server for Codex, Claude Code,
+Cursor and Copilot, and a Go library for embedded use. Graph and lexical
+indexing are local and require no LLM or external service.
-
+**[Download a single binary](https://github.com/mengshi02/codetrip/releases/latest)**
+· [Connect your coding agent](#3-connect-your-coding-agent)
+· [Read the user guide](docs/USER_GUIDE.md)
-The prompt was:
+## v0.3.0 flagship: instant context that stays current
-> Before I commit, use Codetrip to analyze the current working-tree changes.
-> Separate documentation-only changes and show the highest-risk runtime path.
+### Understand a symbol in one query
-Codex called Codetrip's `diff` and `impact` MCP tools. The index remained local,
-the selected graph results were returned to Codex, and no files were edited.
+`brief` turns source, callers, callees, contracts, implementations, related
+tests, impact counts, confidence, and resolution evidence into one bounded,
+deterministic **Agent Context Pack**.
-The same graph is directly available to humans through the CLI:
+
```console
-$ codetrip impact IndexRepo --repo codetrip --depth 2 --format tree
-IndexRepo engine.go:318
-└── CALLS ← newIndexCmd cmd/codetrip/cli.go:469
- └── CALLS ← newRootCmd cmd/codetrip/cli.go:39
+$ codetrip brief maintenanceServer --repo etcd --format text
+maintenanceServer server/etcdserver/api/v3rpc/maintenance.go:71
+Type: Struct · go
+
+Contracts
+ → IMPLEMENTS MaintenanceServer api/etcdserverpb/rpc.pb.go:7474
+ → IMPLEMENTS Downgrader server/etcdserver/api/v3rpc/maintenance.go:55
-2 affected symbols across 2 files
+Incoming
+ ← CALLS NewMaintenanceServer server/etcdserver/api/v3rpc/maintenance.go:87
```
-Unlike a text match, the result records relationship direction, distance, and
-the evidence used to resolve each call. Omit `--format tree` to receive the
-complete JSON result, including confidence and resolution evidence.
+In a controlled 20-task Codex A/B evaluation, starting with `brief` used
+**7.0% fewer commands, 5.4% fewer input tokens, and 7.7% fewer output tokens**
+without observed answer-quality regression. It gives the Agent a precise
+starting point; behavioral conclusions can still be verified against source.
-## Who It Is For
+### Save code, query the new graph
-Codetrip is primarily for developers and small teams who want their coding
-agent to understand repository structure without deploying a code-intelligence
-service. It is also useful directly from the terminal for pre-change impact
-analysis, architecture exploration, and safe rename planning.
+Run MCP with `--watch` and Codetrip follows Agent edits automatically. It
+re-analyzes affected files and dependencies, then atomically publishes graph,
+symbol, source, vector invalidation, and metadata at one revision. Queries see
+a complete old revision or a complete new revision—never a half-updated graph.
-The Go library is the secondary integration surface for tools that need an
-embedded, repository-scoped code graph. Codetrip is not currently intended to
-replace an enterprise code-search platform, an IDE refactoring engine, or a
-cross-organization code host.
+Real-repository structural edits—including signature and interface changes,
+function moves, and new cross-file calls—were refreshed in **1.53 s for runc**
+and **0.33 s for Understand-Anything**. Their final graph exports were
+byte-for-byte identical to clean full indexes. Complex changes can safely
+expand their analysis scope or fall back to an atomic full rebuild.
-## Quick Start
+> **Edit code. Save it. Query the latest complete graph.**
-### 1. Install
+In a five-run Gin function-rename benchmark, Codetrip's first `brief` returned
+the renamed source and the updated compiler-resolved call edge from the same
+revision. It observed **zero mixed revisions**, zero stale results after
+publication, and matched a clean full index in every run.
-Download a prebuilt binary for Linux, macOS, or Windows from
-[GitHub Releases](https://github.com/mengshi02/codetrip/releases), or install
-with Go:
+| Time from save to accurate source + call graph | Codetrip | CodeGraph v1.5.0 | codebase-memory-mcp v0.9.0 |
+|---|---:|---:|---:|
+| P50 / P95 | 1.169 s / 1.196 s | **0.298 s / 0.389 s** | 0.310 s / 0.396 s |
+| Product operations needed, P50 | **1** | 4 | 3 |
+| Mixed-revision observations across 5 runs | **0** | 17; 13 carried a stale warning | 5 |
+| Clean-full equivalence | **5/5** | 5/5 | 5/5 after explicit refresh |
-```bash
-go install github.com/mengshi02/codetrip/cmd/codetrip@latest
-```
+CodeGraph and CBM reached an accurate result sooner in wall-clock time, but
+their earlier responses combined new on-disk source with old graph relations.
+Codetrip waited for atomic publication and returned one usable context pack on
+the first request. In this scenario that means **67%–75% fewer product tool
+round trips** than CBM and CodeGraph, with no Agent-side stale detection,
+supplemental read, or retry loop.
-Building with `go install` requires Go 1.26+ and a C toolchain. Release archives
-contain a single executable and require no language runtime.
+This is a tool-round-trip result, not a claim of 67%–75% token savings. See the
+[reproducible no-LLM protocol](benchmarks/live_context/README.md) and
+[machine-readable result](benchmarks/live_context/results/gin-function-rename.json).
-### 2. Index a Repository
-
-```bash
-cd /path/to/project
-codetrip index . --repo project
-```
+#### More than file watching
-The command prints the number of files, graph nodes, graph edges, and elapsed
-indexing time. Re-index later with `--replace`.
+| Typical incremental graph refresh | Codetrip revision-consistent refresh |
+|---|---|
+| Detects changed files and reparses them | Computes the affected semantic dependency closure |
+| Graph and search components may settle independently | Graph, symbol, source, vector invalidation, and metadata publish together |
+| Freshness during an update is unspecified | Queries read a complete old revision or wait for the complete new revision |
+| Interrupted work may require manual reindexing | Journal recovery and safe atomic full fallback preserve a queryable revision |
-### 3. Try a Query
+Among the major open-source code-graph tools whose public documentation we
+reviewed, we did not find an equivalent documented multi-index revision and
+query-freshness guarantee. That is a scoped, verifiable comparison—not a claim
+that no private or undiscovered system can implement the same design. See the
+[consistency comparison and sources](docs/AGENT_VALUE.md#revision-consistency-is-a-separate-capability).
-```bash
-# Exact symbol names work directly.
-codetrip search "ParseConfig" --repo project
+## Why Codetrip for coding agents?
-codetrip context ParseConfig --repo project
-codetrip impact ParseConfig --repo project --depth 3 --format tree
-codetrip diff HEAD~1 --target HEAD --repo project
-```
+**A fast graph is useful. A graph the Agent can trust immediately after an edit
+is useful enough to change how it works.** Codetrip combines typed semantic
+precision, one-query context, and revision-consistent updates in one local
+engine.
-If a name is ambiguous, Codetrip lists matching locations and their node IDs
-instead of guessing. Pass one of those IDs to select it precisely. CLI query
-results are JSON by default and are safe to pipe into `jq` or another program.
+An initial same-machine comparison used six real repositories, including etcd,
+Kubernetes and runc. This is directional engineering evidence—not an
+independent, universal leaderboard:
-### 4. Connect Your Coding Agent
+| What was measured | Codetrip | CodeGraph v1.5.0 | codebase-memory-mcp v0.9.0 | Codetrip Agent-workflow advantage |
+|---|---|---|---|---|
+| 7 manually reviewed Go/TypeScript semantic fact sets | **Exact on all 7 reviewed sets** | Strong TS coverage; misses or false edges in several reviewed sets | Fast and broad; less precise relation typing in the sample | More trustworthy typed relations in the reviewed facts |
+| Gin rename: operations to accurate live context, P50 | **1** | 4 | 3 | **67%–75% fewer product tool round trips** |
+| Gin rename: mixed revisions across 5 runs | **0** | 17; 13 carried stale warnings | 5 | No Agent-side stale/retry branch |
+| Kubernetes product-level context query, P50 | **52.27 ms** with one deterministic `brief` | 883.85 ms with `codegraph_explore` | ~26.65 ms for two narrower calls | One bounded context contract |
+| Kubernetes add-function-and-call refresh | 13.92 s; one atomic cross-index revision, clean-full-gated | **3.47 s** | 22.36 s | Atomic multi-index publication |
+| Cold full indexing | Not the current leader | Faster | **Fastest in all 6 measured repositories** | — |
-```bash
-codetrip mcp setup --dry-run
-codetrip mcp setup
-```
+The output contracts are different, so latency alone is not a quality score.
+The reviewed facts found Codetrip exact where the other tools sometimes missed
+cross-file calls, interface implementations, or returned false edges. After a
+structural Kubernetes edit, Codetrip published a graph with **114,092 nodes and
+489,174 edges, zero integrity findings, and the new compiler-resolved call
+immediately queryable**.
-The setup command detects supported clients and preserves unrelated MCP
-servers. Restart your client if it is already running, then try:
+> **Choose Codetrip when your Agent must understand code, edit it, refresh its
+> model, and continue reasoning against one complete revision.**
-> Use Codetrip to find `ParseConfig` and show what depends on it.
+Read the [comparison methodology, raw results, limitations, and honest
+verdict](docs/COMPETITIVE_BENCHMARK.md). The benchmark explicitly records where
+competitors lead; repeat the workloads before treating small differences as
+significant.
-Target a specific client with `codetrip mcp setup codex` (or `claude`,
-`cursor`, `vscode`, or `copilot`). Use `--force` only when replacing an existing
-Codetrip entry.
+## Replace Agent tool loops with one query
-## Why a Code Graph?
+| Goal | Replaces | Codetrip value |
+|---|---|---|
+| Understand a symbol | `grep → read definition → find callers → find implementations → search tests` | `brief` returns a bounded, evidence-backed context pack in one request. |
+| Find source context | `guess path → grep → read around every hit` | `source` combines regex search and context lines. A 20-task A/B test measured **32.8% fewer commands and 29.6% fewer input tokens**, with both arms passing 20/20 quality reviews. |
+| Assess a change | `find references → read callers → repeat → deduplicate` | `impact` returns confirmed dependents, contracts, entry points, tests, paths, and uncertainty. Reviewed Golden Facts measured **100% precision and 89.9% recall**. |
+| Review a diff | `git diff → map lines to symbols → inspect every dependency` | `diff` maps hunks to changed symbols and aggregates downstream semantic risk. |
+| Trace execution | Repeated cross-file search and source reads | `path` returns the shortest typed route; `traverse` performs bounded, relation-filtered BFS with edges and evidence. |
-| Plain text or vector search | Codetrip |
-|---|---|
-| Finds similar or matching text | Resolves symbols plus typed relationships |
-| Shows isolated occurrences | Connects calls, imports, inheritance, and overrides |
-| Does not know dependency direction | Traverses forward or reverse dependencies |
-| Leaves change impact to the reader | Maps Git changes to symbols and affected code |
-| Treats rename hits alike | Separates semantic references from review candidates |
+The published no-quality-regression result currently applies to the controlled
+`brief` and `source` evaluations. `impact` has a reviewed raw correctness
+baseline, but its post-remediation Agent A/B run was invalidated; the remaining
+commands have structural value claims rather than unproven end-to-end savings.
-Codetrip still includes fast source and symbol search. The graph adds structural
-context where search alone is not enough.
+See the [complete Agent value and command guide](docs/AGENT_VALUE.md) for
+`search`, `context`, `rename`, `check`, `hybrid`, measured claims, and product
+comparisons.
-## How It Compares
+## Download and run
-These projects overlap, but optimize for different operating models:
+### 1. Download a single binary
-| Project | Best fit | Deployment model | Current strengths |
-|---|---|---|---|
-| **Codetrip** | Local structural context for coding agents and CLI users | One native executable; embedded Go library; local MCP | Atomic repository snapshots, Git-diff-to-symbol impact, typed graph queries, five release targets, no required model service |
-| [GitNexus](https://github.com/abhigyanpatwari/GitNexus) | Rich agent workflows and graph exploration | Node/npm CLI, MCP, skills/hooks, and Web UI | Incremental updates, execution flows, clusters, Cypher, repository groups, PDG/taint tools, broad agent automation |
-| [Serena](https://github.com/oraios/serena) | IDE-like semantic retrieval and source editing | MCP backed by language servers or its JetBrains plugin | 40+ language-server integrations, symbolic editing, refactoring, diagnostics, and optional IDE-grade analysis |
-| [Sourcegraph](https://sourcegraph.com/docs) | Organization-wide search and navigation | Managed single-tenant/enterprise platform plus IDE/Web integrations | Multi-repository and multi-code-host search, branch/history search, SCIP-based precise navigation, enterprise scale |
+Download a dependency-free executable for Linux, macOS, or Windows:
-Choose Codetrip when local deployment simplicity, a native binary, deterministic
-repository snapshots, and embeddability matter more than a Web UI or agent
-workflow suite. Choose GitNexus when you want a broader graph-agent environment
-today; Serena when the agent should perform IDE-backed edits and refactors; and
-Sourcegraph when the problem is organization-wide code discovery and
-governance.
+**[Download Codetrip for your platform →](https://github.com/mengshi02/codetrip/releases/latest)**
-This table compares documented product shape, not relative speed or accuracy.
-Codetrip has not yet published an apples-to-apples competitor benchmark.
+Or install from source with Go 1.26+ and a C toolchain:
-## What You Can Ask
+```bash
+go install github.com/mengshi02/codetrip/cmd/codetrip@latest
+```
-| Goal | Command |
-|---|---|
-| Find a symbol | `codetrip search "ParseConfig" --repo project` |
-| Search source or documentation | `codetrip source 'lang:go ParseConfig' --repo project` |
-| Explain a symbol and its neighbors | `codetrip context ParseConfig --repo project` |
-| Find reverse change impact | `codetrip impact ParseConfig --repo project --format tree` |
-| Analyze a Git diff | `codetrip diff HEAD~1 --target HEAD --repo project` |
-| Trace a directed route | `codetrip path LoadConfig ParseConfig --repo project` |
-| Check cycles and graph integrity | `codetrip check --repo project` |
-| Plan a rename without editing files | `codetrip rename ParseConfig NewName --repo project` |
+### 2. Index and query
-The MCP server exposes the same core operations:
+```bash
+cd /path/to/project
+codetrip index . --repo project
-```text
-list search source context impact check diff rename traverse path
+codetrip brief ParseConfig --repo project --format text
+codetrip impact ParseConfig --repo project --depth 3 --format tree
+codetrip diff HEAD~1 --target HEAD --repo project
```
-## Supported Languages
+JSON is the default output for automation. Ambiguous names return candidates
+and stable node IDs instead of silently selecting the wrong symbol.
-Codetrip parses Go, TypeScript/TSX, JavaScript/JSX, Python, Java, C, C++, C#,
-Rust, PHP, Swift, and Kotlin. Language-aware resolution is covered by the
-repository's semantic test suite, but relationship precision varies with each
-language and with dynamic or reflective code. Textual candidates remain
-available when a reference cannot be resolved semantically. All 12 languages
-pass curated semantic fixture gates; real-repository precision and
-source-driven recall reviews are complete for C++, in progress for Kotlin, and
-planned for the remaining languages. See the
-[semantic quality report](docs/QUALITY.md).
-
-## Measured on Real Repositories
-
-Cold full-snapshot indexing on an Apple M2 Pro with 16 GB RAM, with embeddings
-disabled:
-
-| Repository | Language | Files | Nodes | Edges | Duration | Index size |
-|---|---|---:|---:|---:|---:|---:|
-| Kubernetes | Go | 17,389 | 107,385 | 277,588 | 121.11 s | 713.5 MiB |
-| RocksDB | C++ | 2,006 | 52,507 | 198,900 | 204.62 s | 434.5 MiB |
-| RocketMQ | Java | 2,552 | 29,535 | 141,874 | 29.16 s | 342.7 MiB |
-| FastAPI | Python | 2,714 | 8,913 | 15,474 | 7.53 s | 68.4 MiB |
-| Exposed | Kotlin | 5,173 | 25,763 | 70,030 | 26.65 s | 443.9 MiB |
-
-All 12 advertised languages completed the same cold-index procedure. See the
-[full results, commits, and methodology](docs/BENCHMARKS.md). These are
-Codetrip measurements, not cross-tool comparisons; time and size vary with
-hardware, checkout contents, operating system, and version.
-
-## Optional Semantic Search
-
-Lexical search and graph analysis do not require an embedding model. To add
-semantic retrieval, point Codetrip at an OpenAI-compatible embeddings endpoint:
+### 3. Connect your coding agent
```bash
-codetrip embed --repo project \
- --endpoint http://localhost:11434/v1/embeddings \
- --model nomic-embed-text
-
-codetrip hybrid "configuration loading" --repo project \
- --endpoint http://localhost:11434/v1/embeddings \
- --model nomic-embed-text
+codetrip mcp setup --dry-run
+codetrip mcp setup --watch
```
-Vectors are persisted per repository. Optional int8 quantization is available
-for smaller indexes.
+The setup command detects Codex, Claude Code, Cursor, VS Code/Copilot, and
+GitHub Copilot CLI while preserving unrelated MCP servers. Restart the client,
+then ask:
-## How It Works
+> Use Codetrip to explain `ParseConfig` and show what a signature change could affect.
-```text
-+-------------------+ +---------------------------+
-| Repository | | Typed Code Graph |
-| .go .ts .py ... | ----> | symbols, calls, imports, |
-| source + Git | | inheritance, processes |
-+-------------------+ +-------------+-------------+
- |
- +---------------------+---------------------+
- | | |
- Symbol/source Graph traversal Optional vectors
- search and change impact + hybrid ranking
- | | |
- +---------------------+---------------------+
- |
- Go library / CLI / MCP
-```
+Target one client with `codetrip mcp setup codex --watch` (or `claude`,
+`cursor`, `vscode`, or `copilot`).
-Each repository has independent storage and is published as an atomic
-snapshot. The durable graph is authoritative; search indexes and vectors are
-repository-scoped derived data. Building a replacement never exposes a
-partially updated active snapshot.
+## One engine, three integration surfaces
-## Go Library
+- **MCP** gives coding agents local graph and search tools, plus automatic
+ refresh after edits.
+- **CLI** gives developers and CI deterministic JSON, readable text/tree views,
+ CSV export, and repository management.
+- **Go library** embeds repository-scoped indexing and queries directly into
+ another tool—without routing through `cmd` or an MCP server.
```go
engine, err := codetrip.Open("./.codetrip")
@@ -255,90 +241,79 @@ defer engine.Close()
_, err = engine.IndexRepo(ctx, "/path/to/project",
codetrip.WithRepoName("project"),
- codetrip.WithReplaceExisting(true),
+ codetrip.WithIndexMode(codetrip.IndexModeAuto),
)
-
-result, err := engine.Search(ctx, &codetrip.SearchRequest{
- Repo: "project", Query: "ParseConfig", Limit: 20,
-})
-
-impact, err := engine.Impact(ctx, &codetrip.ImpactRequest{
- Repo: "project", NodeID: result.Results[0].NodeID, MaxDepth: 3,
+brief, err := engine.Brief(ctx, &codetrip.BriefRequest{
+ Repo: "project", Symbol: "ParseConfig",
})
```
-The public API also provides source and hybrid search, context, structural
-checks, Git change analysis, rename planning, traversal, shortest paths,
-repository management, CSV export, metrics, and configuration options.
+The public API also exposes source and symbol search, context, impact, Git diff
+analysis, rename planning, structural checks, traversal, shortest paths,
+vectors, metrics, repository lifecycle, and CSV export.
+
+## How it works
+
+```mermaid
+flowchart LR
+ R["Repository
source + Git"] --> G["Typed Code Graph
symbols · calls · imports · contracts"]
+ G --> S["Source & symbol search"]
+ G --> Q["Graph reasoning & change impact"]
+ G --> V["Optional vectors & hybrid ranking"]
+ S --> A["Go library · CLI · MCP"]
+ Q --> A
+ V --> A
+```
-## Export and Inspection
+Each repository has isolated storage. The durable graph is authoritative;
+search and vector indexes are repository-scoped derived data. Incremental and
+full builds use recoverable, atomic publication so readers never observe a
+partially committed revision.
-Export the complete active graph as deterministic CSV plus a manifest with row
-counts and SHA-256 checksums:
+## Language quality, measured—not implied
-```bash
-codetrip export --repo project --output ./exports/project
-```
-
-For parser and language tuning, `index --export` can also capture validation
-CSV before persistence. See the user guide for details.
-
-## Known Limitations
-
-Release binaries are built for Linux and macOS on amd64/arm64, and Windows on
-amd64. Linux artifacts are statically linked; macOS artifacts use system
-libraries only; Windows artifacts do not depend on a compiler runtime.
-
-Linux and macOS use the native high-throughput source-search backend. Windows
-uses a portable backend with the same query features and may be slower on large
-repositories. Rename is analysis-only and never edits source. Git diff analysis
-does not include untracked files until they are added and indexed.
-
-- Re-indexing currently builds a complete replacement snapshot. This is
- consistent and reader-safe, but expensive for large frequently changing
- repositories.
-- Cross-repository relationships are not resolved yet.
-- Dynamic dispatch, reflection, generated code, macros, and runtime module
- loading can reduce relationship precision. Confidence and textual fallback
- candidates remain visible rather than being presented as certain.
-- Language support is not equally precise. Fixture results are published for
- all advertised languages, while real-repository precision and recall reviews
- are complete for only part of the language matrix.
-- The CLI is moving toward richer human-readable views; most commands still
- return JSON, while `impact` additionally supports `--format tree`.
-- Codetrip has no Web UI and does not perform source edits.
-
-## Roadmap
-
-Current priorities:
-
-- Incremental indexing without weakening atomic publication
-- Cross-repository analysis and contract-aware impact
-- Stronger resolution for dynamic languages
-- More human-readable CLI output
-- Automatic repository discovery and simpler repo selection
-- More MCP workflows for pre-commit analysis, debugging, and review
-
-The longer-term direction is a distributed, cloud/local hybrid code-intelligence
-engine that preserves the current embedded and local-first path. See an
-important missing capability or workflow? Open an
-[issue](https://github.com/mengshi02/codetrip/issues)—or star the repository to
-follow the roadmap.
+Codetrip parses Go, TypeScript/TSX, JavaScript/JSX, Python, Java, C, C++, C#,
+Rust, PHP, Swift, and Kotlin. All 12 pass curated semantic fixtures. C++, Go,
+and TypeScript/TSX have also completed multi-repository precision and
+source-driven recall reviews and are **Stable**; other languages retain their
+published maturity levels.
+
+Cold full indexing has been measured on real repositories including
+Kubernetes, RocksDB, RocketMQ, FastAPI, and Exposed. See
+[language quality](docs/QUALITY.md), [benchmarks](docs/BENCHMARKS.md), and the
+[v0.3.0 validation baseline](docs/V0.3.0_BASELINE.md) for samples, commits,
+hardware, and limitations.
+
+## Scope and trust
+
+- Indexes stay local. External data transfer occurs only if you configure an
+ embedding endpoint or your MCP client sends selected results to its model.
+- Dynamic dispatch, reflection, generated code, macros, and runtime loading can
+ reduce static-analysis recall; confidence and fallback candidates stay visible.
+- Relationships do not cross repository boundaries yet. Rename creates a plan
+ and never edits source. Codetrip has no Web UI.
+- Linux and macOS use the native source-search backend. Windows provides the
+ same query surface through a portable backend and may be slower on large repos.
+
+Read the [user guide](docs/USER_GUIDE.md) for every command, MCP schema,
+embedding setup, CSV validation, storage behavior, and detailed limitations.
## Documentation
- [User guide](docs/USER_GUIDE.md)
+- [Agent value and command guide](docs/AGENT_VALUE.md)
+- [Competitive engineering benchmark](docs/COMPETITIVE_BENCHMARK.md)
- [Semantic quality report](docs/QUALITY.md)
-- [Indexing benchmarks](docs/BENCHMARKS.md)
-- [Changelog](CHANGELOG.md)
-- [Contributing](CONTRIBUTING.md)
-- [Security policy](SECURITY.md)
+- [Real-repository benchmarks](docs/BENCHMARKS.md)
+- [`brief` Agent A/B evaluation](docs/V0.3.0_AGENT_AB.md)
+- [`source` Agent A/B evaluation](docs/V0.3.0_SOURCE_AGENT_AB.md)
+- [`impact` correctness baseline](docs/V0.3.0_IMPACT_AGENT_AB.md)
+- [Changelog](CHANGELOG.md) · [Contributing](CONTRIBUTING.md) · [Security](SECURITY.md)
## Development
```bash
-go test ./...
-go vet ./...
+make release-gate
```
## License
diff --git a/README_ZH.md b/README_ZH.md
index d2cdebf..ef9461f 100644
--- a/README_ZH.md
+++ b/README_ZH.md
@@ -1,227 +1,212 @@
-# codetrip [](https://github.com/mengshi02/codetrip/actions/workflows/go.yml) [](https://github.com/mengshi02/codetrip/releases) [](https://go.dev/) [](LICENSE)
+# codetrip
+
+[](https://github.com/mengshi02/codetrip/actions/workflows/go.yml)
+[](https://github.com/mengshi02/codetrip/releases)
+[](https://go.dev/)
+[](LICENSE)
+[](#3-连接编程-agent)
+[](#3-连接编程-agent)
+[](#3-连接编程-agent)
+[](#3-连接编程-agent)
+[](#1-下载单二进制)
+[](#1-下载单二进制)
+[](#1-下载单二进制)
[English](README.md) | [简体中文](README_ZH.md)
-**为编程智能体提供代码库的结构化地图。**
+## 一次修改。一次查询。一张一致的图谱。
-Codetrip 将代码仓库转换为本地的类型化代码图,让 Codex、Claude Code、
-Cursor、VS Code/Copilot 和 GitHub Copilot CLI 能够回答纯文本搜索无法解决的问题:
+**给编程 Agent 一张地图,而不是再加一个搜索框。** Codetrip 会等待一个完整
+revision 发布,再将最新源码与最新图关系一并返回。Agent 不需要识别过期边、协调
+新旧混合结果,也不需要反复重试等待索引收敛。
```text
-谁调用了这个方法? 这项改动可能影响什么?
-这个请求的执行流程是什么? 我能安全地重命名这个符号吗?
+没有 Codetrip:搜索 → 读源码 → 查调用者 → 再读源码 → 查测试 → 协调结果
+使用 Codetrip:brief ParseConfig → 一份有边界的完整上下文包
```
-它通过统一的 CLI、MCP 服务器和 Go 库,将图遍历、源码搜索与可选的语义检索结合起来。
-索引始终保留在你的机器上。当 MCP 客户端调用工具时,选中的结果会返回给该客户端,
-并可能根据客户端的数据策略进入其模型上下文。如果配置了外部嵌入端点,索引片段还会
-被发送到该端点;词法索引和图索引不需要任何外部服务。
+**Codetrip 是一个本地优先的混合图增强代码智能引擎(Hybrid Graph-Augmented
+Code Intelligence Engine)。** 它把代码仓库转化为类型化、可查询的代码图,让
+Agent 无需反复 grep、读取文件并人工拼接架构,就能理解符号、追踪依赖和判断改动影响。
-## 在 Codex 中查看实际效果
+```text
+这个符号是什么? 谁调用或实现了它?
+这次修改会影响什么? Agent 查询的是不是最新代码?
+```
-在一次真实的只读 Codex 运行中,Codetrip 将当前工作区差异映射到五个发生变化的
-CLI 符号,沿反向依赖关系进行遍历,区分仅文档改动,并找出了共同的运行时风险路径:
+一个原生可执行文件同时提供 CLI、面向 Codex、Claude Code、Cursor 和 Copilot
+的 MCP 服务,以及可嵌入其他工具的 Go Lib。图和词法索引完全在本地完成,不依赖
+LLM 或外部服务。
-
+**[下载单二进制](https://github.com/mengshi02/codetrip/releases/latest)**
+· [连接编程 Agent](#3-连接编程-agent)
+· [阅读用户手册](docs/USER_GUIDE_ZH.md)
-使用的提示词是:
+## v0.3.0 杀手锏:即时上下文,代码变化后仍然准确
-> 在提交之前,使用 Codetrip 分析当前工作区的改动。区分仅文档改动,
-> 并显示风险最高的运行时路径。
+### 一次查询理解一个符号
-Codex 调用了 Codetrip 的 `diff` 和 `impact` MCP 工具。索引保留在本地,
-选中的图结果返回给 Codex,且没有编辑任何文件。
+`brief` 将源码、调用者、被调用者、接口契约、实现类、相关测试、影响统计、
+置信度和解析证据组织成一份确定性、受预算约束的 **Agent Context Pack**。
-同一张图也可以直接通过 CLI 供开发者使用:
+
```console
-$ codetrip impact IndexRepo --repo codetrip --depth 2 --format tree
-IndexRepo engine.go:318
-└── CALLS ← newIndexCmd cmd/codetrip/cli.go:469
- └── CALLS ← newRootCmd cmd/codetrip/cli.go:39
+$ codetrip brief maintenanceServer --repo etcd --format text
+maintenanceServer server/etcdserver/api/v3rpc/maintenance.go:71
+Type: Struct · go
-2 affected symbols across 2 files
-```
+Contracts
+ → IMPLEMENTS MaintenanceServer api/etcdserverpb/rpc.pb.go:7474
+ → IMPLEMENTS Downgrader server/etcdserver/api/v3rpc/maintenance.go:55
-与文本匹配不同,该结果会记录关系方向、距离,以及解析每次调用所依据的证据。
-省略 `--format tree` 可获得完整的 JSON 结果,其中包括置信度和解析证据。
+Incoming
+ ← CALLS NewMaintenanceServer server/etcdserver/api/v3rpc/maintenance.go:87
+```
-## 适用人群
+在 20 个 Codex 任务的受控 A/B 测试中,从 `brief` 开始分析使**总命令数减少
+7.0%、输入 token 减少 5.4%、输出 token 减少 7.7%**,且未观察到答案质量下降。
+它为 Agent 提供精准起点;涉及行为判断时,Agent 仍可继续核验源码。
-Codetrip 主要面向希望编程智能体理解仓库结构、但又不想部署代码智能服务的开发者和
-小型团队。它也适合直接在终端中进行改动前影响分析、架构探索和安全重命名规划。
+### 保存代码,立即查询新图谱
-Go 库是次要的集成接口,适用于需要嵌入式、仓库级代码图的工具。目前 Codetrip
-并不打算取代企业级代码搜索平台、IDE 重构引擎或跨组织代码托管平台。
+通过 `--watch` 启动 MCP 后,Codetrip 会自动跟踪 Agent 的代码修改,重新分析受影响
+文件和依赖,并将图谱、符号、源码、向量失效状态和元数据作为同一个 revision 原子发布。
+查询只能看到完整旧版本或完整新版本,不会读到“源码已更新、调用图仍是旧的”半成品。
-## 快速开始
+包含签名和接口变化、函数移动及新增跨文件调用的真实仓库结构性修改,在 runc 上
+**1.53 秒**完成,在 Understand-Anything 上 **0.33 秒**完成;最终图谱 CSV 与干净
+全量索引逐字节一致。复杂变化会安全扩大分析范围,必要时原子回退到完整重建。
-### 1. 安装
+> **修改代码。保存。查询最新且完整的图谱。**
-从 [GitHub Releases](https://github.com/mengshi02/codetrip/releases)
-下载适用于 Linux、macOS 或 Windows 的预编译二进制文件,或使用 Go 安装:
+在五轮 Gin 函数重命名测试中,Codetrip 的第一次 `brief` 就返回了同一 revision
+中的重命名源码和更新后的编译器解析调用边。五轮均未观察到混合 revision,发布后
+没有旧结果,并且每轮都与干净全量索引一致。
-```bash
-go install github.com/mengshi02/codetrip/cmd/codetrip@latest
-```
-
-使用 `go install` 构建需要 Go 1.26+ 和 C 工具链。发布压缩包只包含一个可执行文件,
-无需语言运行时。
-
-### 2. 为仓库建立索引
-
-```bash
-cd /path/to/project
-codetrip index . --repo project
-```
+| 从保存到获得准确源码与调用图 | Codetrip | CodeGraph v1.5.0 | codebase-memory-mcp v0.9.0 |
+|---|---:|---:|---:|
+| P50 / P95 | 1.169 秒 / 1.196 秒 | **0.298 秒 / 0.389 秒** | 0.310 秒 / 0.396 秒 |
+| 获得正确结果所需产品操作数 P50 | **1** | 4 | 3 |
+| 五轮中的混合 revision | **0** | 17 次,其中 13 次带过期警告 | 5 次 |
+| 与干净全量索引等价 | **5/5** | 5/5 | 显式刷新后 5/5 |
-该命令会输出文件数、图节点数、图边数和索引耗时。之后可使用 `--replace` 重新索引。
+CodeGraph 和 CBM 在绝对时间上更快得到准确结果,但此前的响应会把新磁盘源码与旧图
+关系组合在一起。Codetrip 等待原子发布,第一次请求就返回可直接使用的上下文包。
+在这个场景中,Codetrip 相比 CBM 和 CodeGraph **减少 67%–75% 的产品工具往返**,
+Agent 无需执行过期检测、补充源码读取或重试循环。
-### 3. 尝试查询
+这里测量的是工具往返,不代表 token 节省 67%–75%。参见
+[可复现的零 LLM 测试协议](benchmarks/live_context/README.md)和
+[机器可读结果](benchmarks/live_context/results/gin-function-rename.json)。
-```bash
-# 可以直接使用准确的符号名称。
-codetrip search "ParseConfig" --repo project
+#### 不只是监听文件变化
-codetrip context ParseConfig --repo project
-codetrip impact ParseConfig --repo project --depth 3 --format tree
-codetrip diff HEAD~1 --target HEAD --repo project
-```
+| 常见的代码图增量刷新 | Codetrip 的 revision 一致刷新 |
+|---|---|
+| 检测并重新解析修改文件 | 计算受影响的语义依赖闭包 |
+| 图谱和各搜索组件可能分别收敛 | 图谱、符号、源码、向量失效状态和元数据共同发布 |
+| 更新期间的查询新鲜度没有明确保证 | 查询读取完整旧 revision,或等待完整新 revision |
+| 中断后可能需要手工重建 | 日志恢复与安全原子全量回退始终保留可查询版本 |
-如果名称存在歧义,Codetrip 会列出匹配位置及其节点 ID,而不会自行猜测。
-传入其中一个 ID 即可精确选择。CLI 查询结果默认使用 JSON 格式,
-可以安全地通过管道交给 `jq` 或其他程序。
+在我们审查过公开文档的主流开源代码图谱工具中,尚未发现同等的多索引 revision
+与查询新鲜度保证。这是有范围、可核验的比较,并不是声称所有私有或尚未发现的系统
+都不可能采用相同设计。参见[一致性对比与资料来源](docs/AGENT_VALUE_ZH.md#revision-一致性是一项独立能力)。
-### 4. 连接你的编程智能体
+## 为什么 Code Agent 应该选择 Codetrip?
-```bash
-codetrip mcp setup --dry-run
-codetrip mcp setup
-```
+**快的图谱有价值;Agent 修改代码后立刻可以信任的图谱,才能真正改变工作方式。**
+Codetrip 在一个本地引擎中同时提供类型化语义精度、一次查询的上下文,以及 revision
+一致的增量更新。
-安装命令会检测支持的客户端,并保留无关的 MCP 服务器。如果客户端已经在运行,
-请重启后尝试:
+首轮同机对比覆盖 6 个真实仓库,包括 etcd、Kubernetes 和 runc。以下是方向性的工程
+证据,不是独立机构完成的全领域排行榜:
-> 使用 Codetrip 查找 `ParseConfig`,并显示哪些代码依赖它。
+| 对比项目 | Codetrip | CodeGraph v1.5.0 | codebase-memory-mcp v0.9.0 | Codetrip 的 Agent 工作流优势 |
+|---|---|---|---|---|
+| 7 组人工复核的 Go/TypeScript 语义事实 | **7 组全部与预期一致** | TypeScript 覆盖较好;部分样本存在漏边或误边 | 速度快、覆盖广;样本中的关系类型与精度较弱 | 人工复核事实中的类型化关系更可信 |
+| Gin 重命名:获得准确实时上下文的操作数 P50 | **1** | 4 | 3 | **减少 67%–75% 的产品工具往返** |
+| Gin 重命名:五轮混合 revision 数 | **0** | 17 次,其中 13 次带过期警告 | 5 次 | Agent 无需处理过期或重试分支 |
+| Kubernetes 产品级上下文查询 P50 | **52.27 ms**,一次确定性 `brief` | `codegraph_explore` 为 883.85 ms | 两次更窄查询合计约 26.65 ms | 一个有边界的上下文契约 |
+| Kubernetes 新增函数及调用的刷新 | 13.92 秒;跨索引原子 revision,并通过干净全量等价门禁 | **3.47 秒** | 22.36 秒 | 跨索引原子发布 |
+| 冷启动全量索引 | 当前不是领先者 | 更快 | **6 个实测仓库全部最快** | — |
-使用 `codetrip mcp setup codex` 可指定某个客户端(也支持 `claude`、`cursor`、
-`vscode` 或 `copilot`)。仅在替换现有 Codetrip 配置时使用 `--force`。
+三者返回的证据范围不同,因此不能只用耗时判断质量。人工复核发现,竞品在部分样本中
+会漏掉跨文件调用、接口实现或产生误边,而 Codetrip 的 7 组事实均与预期一致。一次
+Kubernetes 结构性修改后,Codetrip 原子发布了包含 **114,092 个节点、489,174 条边**
+的图谱,完整性检查为零问题,新增的编译器解析调用关系可以立即查询。
-## 为什么使用代码图?
+> **当 Agent 需要理解代码、修改代码、刷新认知,并基于同一个完整 revision 继续推理时,
+> 选择 Codetrip。**
-| 纯文本或向量搜索 | Codetrip |
-|---|---|
-| 查找相似或匹配的文本 | 解析符号和类型化关系 |
-| 显示孤立的出现位置 | 连接调用、导入、继承和重写关系 |
-| 不理解依赖方向 | 沿正向或反向依赖关系遍历 |
-| 由读者自行判断改动影响 | 将 Git 改动映射到符号及受影响代码 |
-| 将所有重命名命中一视同仁 | 区分语义引用和需要审查的候选项 |
+请阅读[对比方法、原始结果、限制与诚实结论](docs/COMPETITIVE_BENCHMARK_ZH.md)。材料明确
+记录竞品领先的项目;在重复测试前,不应把小幅差异解读为稳定结论。
-Codetrip 仍然提供快速的源码和符号搜索。代码图则在仅靠搜索不足时补充结构化上下文。
+## 用一次查询替代 Agent 的多轮工具调用
-## 与其他工具的比较
+| 目标 | 替代的 Agent 操作 | Codetrip 的价值 |
+|---|---|---|
+| 理解符号 | `grep → 读取定义 → 查调用者 → 查实现类 → 搜索测试` | `brief` 一次返回受限且有证据的上下文包。 |
+| 定位源码上下文 | `猜文件路径 → grep → 逐个读取命中附近源码` | `source` 将正则搜索和上下文读取合并为一次调用。20 任务 A/B 测试测得**命令数减少 32.8%、输入 token 减少 29.6%**,两组答案均通过 20/20 质量评审。 |
+| 判断改动影响 | `查引用 → 读调用者 → 继续查引用 → 手工去重` | `impact` 返回确定依赖、契约、入口、测试、证据路径和不确定性;人工复核 Golden Facts 达到 **100% Precision、89.9% Recall**。 |
+| 审查代码差异 | `git diff → 将行映射到符号 → 逐个检查依赖` | `diff` 把 hunk 映射到修改符号,并聚合下游语义风险。 |
+| 追踪执行路径 | 跨文件反复搜索和读取源码 | `path` 一次返回最短类型化路径;`traverse` 返回按关系过滤的有界 BFS、实际边和证据。 |
-以下项目的能力有所重叠,但它们针对不同的运行模式进行了优化:
+目前公开的“不降低答案质量”结论只适用于受控的 `brief` 和 `source` 评测。
+`impact` 已有人工复核的原始结果正确性基线,但修复后的 Agent A/B 测试无效;其他
+命令宣传的是可以直接验证的工具链压缩价值,而不是尚未证明的完整任务降本数据。
-| 项目 | 最适合 | 部署模式 | 当前优势 |
-|---|---|---|---|
-| **Codetrip** | 为编程智能体和 CLI 用户提供本地结构化上下文 | 单个原生可执行文件;嵌入式 Go 库;本地 MCP | 原子化仓库快照、从 Git 差异到符号的影响分析、类型化图查询、五种发布目标、无需模型服务 |
-| [GitNexus](https://github.com/abhigyanpatwari/GitNexus) | 丰富的智能体工作流和图探索 | Node/npm CLI、MCP、技能/钩子和 Web UI | 增量更新、执行流程、聚类、Cypher、仓库组、PDG/污点工具和广泛的智能体自动化 |
-| [Serena](https://github.com/oraios/serena) | 类 IDE 的语义检索和源码编辑 | 由语言服务器或 JetBrains 插件支持的 MCP | 40 多种语言服务器集成、符号化编辑、重构、诊断和可选的 IDE 级分析 |
-| [Sourcegraph](https://sourcegraph.com/docs) | 组织级搜索和导航 | 托管的单租户/企业平台,以及 IDE/Web 集成 | 多仓库、多代码托管平台搜索,分支/历史搜索,基于 SCIP 的精确导航和企业级规模 |
+完整的 `search`、`context`、`rename`、`check`、`hybrid` 价值说明、测量口径和
+竞品定位请参阅[完整 Agent 价值与命令指南](docs/AGENT_VALUE_ZH.md)。
-如果你更看重本地部署简单、原生二进制文件、确定性的仓库快照和可嵌入性,而不是
-Web UI 或智能体工作流套件,请选择 Codetrip。如果你现在需要更广泛的图智能体环境,
-请选择 GitNexus;如果希望智能体执行 IDE 支持的编辑和重构,请选择 Serena;
-如果问题涉及组织级代码发现和治理,请选择 Sourcegraph。
+## 下载即用
-此表比较的是各产品公开文档中的定位,而不是相对速度或准确率。Codetrip 尚未发布
-同等条件下的竞品基准测试。
+### 1. 下载单二进制
-## 可以用它做什么
+下载适用于 Linux、macOS 或 Windows 的无依赖单文件程序:
-| 目标 | 命令 |
-|---|---|
-| 查找符号 | `codetrip search "ParseConfig" --repo project` |
-| 搜索源码或文档 | `codetrip source 'lang:go ParseConfig' --repo project` |
-| 解释符号及其邻接关系 | `codetrip context ParseConfig --repo project` |
-| 查找反向改动影响 | `codetrip impact ParseConfig --repo project --format tree` |
-| 分析 Git 差异 | `codetrip diff HEAD~1 --target HEAD --repo project` |
-| 跟踪有向路径 | `codetrip path LoadConfig ParseConfig --repo project` |
-| 检查环路和图完整性 | `codetrip check --repo project` |
-| 在不编辑文件的情况下规划重命名 | `codetrip rename ParseConfig NewName --repo project` |
+**[下载适合你平台的 Codetrip →](https://github.com/mengshi02/codetrip/releases/latest)**
-MCP 服务器提供相同的核心操作:
+也可以使用 Go 1.26+ 和 C 工具链从源码安装:
-```text
-list search source context impact check diff rename traverse path
+```bash
+go install github.com/mengshi02/codetrip/cmd/codetrip@latest
```
-## 支持的语言
-
-Codetrip 可解析 Go、TypeScript/TSX、JavaScript/JSX、Python、Java、C、C++、
-C#、Rust、PHP、Swift 和 Kotlin。仓库的语义测试套件覆盖了语言感知解析,
-但关系精度会随语言以及动态或反射代码而变化。当引用无法从语义上解析时,
-文本候选项仍然可用。所有 12 种语言均通过了精选的语义固件门禁;
-C++ 的真实仓库精度和源码驱动召回率评审已经完成,Kotlin 正在进行中,
-其余语言尚在规划中。参见[语义质量报告](docs/QUALITY.md)。
-
-## 真实仓库实测
+### 2. 建立索引并查询
-以下数据来自配备 16 GB 内存的 Apple M2 Pro,关闭嵌入功能后进行的冷启动完整快照索引:
-
-| 仓库 | 语言 | 文件数 | 节点数 | 边数 | 耗时 | 索引大小 |
-|---|---|---:|---:|---:|---:|---:|
-| Kubernetes | Go | 17,389 | 107,385 | 277,588 | 121.11 秒 | 713.5 MiB |
-| RocksDB | C++ | 2,006 | 52,507 | 198,900 | 204.62 秒 | 434.5 MiB |
-| RocketMQ | Java | 2,552 | 29,535 | 141,874 | 29.16 秒 | 342.7 MiB |
-| FastAPI | Python | 2,714 | 8,913 | 15,474 | 7.53 秒 | 68.4 MiB |
-| Exposed | Kotlin | 5,173 | 25,763 | 70,030 | 26.65 秒 | 443.9 MiB |
+```bash
+cd /path/to/project
+codetrip index . --repo project
-所有 12 种已声明支持的语言都完成了相同的冷索引流程。参见
-[完整结果、提交版本和测试方法](docs/BENCHMARKS.md)。这些是 Codetrip 自身的测量数据,
-并非跨工具对比;耗时和大小会随硬件、检出内容、操作系统和版本而变化。
+codetrip brief ParseConfig --repo project --format text
+codetrip impact ParseConfig --repo project --depth 3 --format tree
+codetrip diff HEAD~1 --target HEAD --repo project
+```
-## 可选的语义搜索
+CLI 默认输出 JSON,便于 Agent 和自动化使用。名称存在歧义时,Codetrip 会返回候选
+位置及稳定节点 ID,而不是静默选错。
-词法搜索和图分析不需要嵌入模型。如需添加语义检索,可将 Codetrip 指向兼容
-OpenAI API 的嵌入端点:
+### 3. 连接编程 Agent
```bash
-codetrip embed --repo project \
- --endpoint http://localhost:11434/v1/embeddings \
- --model nomic-embed-text
-
-codetrip hybrid "configuration loading" --repo project \
- --endpoint http://localhost:11434/v1/embeddings \
- --model nomic-embed-text
+codetrip mcp setup --dry-run
+codetrip mcp setup --watch
```
-向量按仓库持久化。还可以选择 int8 量化以减小索引体积。
+安装命令会检测 Codex、Claude Code、Cursor、VS Code/Copilot 和 GitHub Copilot
+CLI,并保留无关 MCP 服务。重启客户端后尝试:
-## 工作原理
+> 使用 Codetrip 解释 `ParseConfig`,并说明修改其签名可能影响哪些代码。
-```text
-+-------------------+ +---------------------------+
-| 代码仓库 | | 类型化代码图 |
-| .go .ts .py ... | ----> | 符号、调用、导入、 |
-| 源码 + Git | | 继承、处理流程 |
-+-------------------+ +-------------+-------------+
- |
- +---------------------+---------------------+
- | | |
- 符号/源码搜索 图遍历和改动影响 可选向量
- + 混合排序
- | | |
- +---------------------+---------------------+
- |
- Go 库 / CLI / MCP
-```
+使用 `codetrip mcp setup codex --watch` 可指定客户端,也支持 `claude`、
+`cursor`、`vscode` 和 `copilot`。
-每个仓库都有独立存储,并以原子快照的方式发布。持久化图是权威数据;
-搜索索引和向量是按仓库生成的派生数据。构建替代快照时,绝不会暴露部分更新的活动快照。
+## 一个引擎,三种集成方式
-## Go 库
+- **MCP** 为编程 Agent 提供本地图谱与搜索工具,并在代码修改后自动刷新。
+- **CLI** 为开发者和 CI 提供确定性 JSON、可读文本/树形视图、CSV 导出和仓库管理。
+- **Go Lib** 将仓库级索引与查询直接嵌入其他工具,无需经过 `cmd` 或 MCP Server。
```go
engine, err := codetrip.Open("./.codetrip")
@@ -232,81 +217,72 @@ defer engine.Close()
_, err = engine.IndexRepo(ctx, "/path/to/project",
codetrip.WithRepoName("project"),
- codetrip.WithReplaceExisting(true),
+ codetrip.WithIndexMode(codetrip.IndexModeAuto),
)
-
-result, err := engine.Search(ctx, &codetrip.SearchRequest{
- Repo: "project", Query: "ParseConfig", Limit: 20,
-})
-
-impact, err := engine.Impact(ctx, &codetrip.ImpactRequest{
- Repo: "project", NodeID: result.Results[0].NodeID, MaxDepth: 3,
+brief, err := engine.Brief(ctx, &codetrip.BriefRequest{
+ Repo: "project", Symbol: "ParseConfig",
})
```
-公共 API 还提供源码和混合搜索、上下文、结构检查、Git 改动分析、重命名规划、
-遍历、最短路径、仓库管理、CSV 导出、指标和配置选项。
+公共 API 还提供源码与符号搜索、上下文、影响分析、Git 差异分析、重命名规划、
+结构检查、遍历、最短路径、向量、指标、仓库生命周期和 CSV 导出。
-## 导出和检查
-
-将完整的活动图导出为确定性的 CSV,并生成包含行数和 SHA-256 校验和的清单:
+## 工作原理
-```bash
-codetrip export --repo project --output ./exports/project
+```mermaid
+flowchart LR
+ R["代码仓库
源码 + Git"] --> G["类型化代码图
符号 · 调用 · 导入 · 契约"]
+ G --> S["源码与符号搜索"]
+ G --> Q["图推理与改动影响"]
+ G --> V["可选向量与混合排序"]
+ S --> A["Go Lib · CLI · MCP"]
+ Q --> A
+ V --> A
```
-为了调整解析器和语言支持,`index --export` 还可以在持久化之前捕获验证 CSV。
-详情请参阅用户手册。
+每个仓库拥有独立存储。持久化图谱是权威数据,搜索和向量索引是仓库级派生数据。
+增量和全量构建都通过可恢复的原子协议发布,查询不会看到只提交了一部分的 revision。
-## 已知限制
+## 语言质量:用数据说明,不暗示
-发布的二进制文件支持 Linux 和 macOS 的 amd64/arm64,以及 Windows 的 amd64。
-Linux 构件采用静态链接;macOS 构件仅使用系统库;Windows 构件不依赖编译器运行时。
-
-Linux 和 macOS 使用原生高吞吐源码搜索后端。Windows 使用具备相同查询功能的
-可移植后端,在大型仓库上可能较慢。重命名功能仅进行分析,绝不会编辑源码。
-Git 差异分析不会包括尚未添加并建立索引的未跟踪文件。
-
-- 目前重新索引会构建完整的替代快照。这样可以保证一致性和读取安全,
- 但对于频繁变化的大型仓库来说成本较高。
-- 尚未解析跨仓库关系。
-- 动态分派、反射、生成代码、宏和运行时模块加载可能降低关系精度。
- 系统会继续显示置信度和文本后备候选项,而不会将其视为确定结果。
-- 各语言的支持精度并不相同。所有已声明支持的语言都发布了固件测试结果,
- 但真实仓库的精度和召回率评审目前只覆盖部分语言。
-- CLI 正在向更丰富的易读视图演进;大多数命令仍返回 JSON,
- `impact` 另外支持 `--format tree`。
-- Codetrip 没有 Web UI,也不会执行源码编辑。
+Codetrip 可解析 Go、TypeScript/TSX、JavaScript/JSX、Python、Java、C、C++、
+C#、Rust、PHP、Swift 和 Kotlin。12 种语言全部通过精选语义 fixture;C++、Go 和
+TypeScript/TSX 还完成了多真实仓库 Precision 与源码驱动 Recall 评审,达到
+**Stable**,其他语言保留公开的成熟度等级。
-## 路线图
+Kubernetes、RocksDB、RocketMQ、FastAPI 和 Exposed 等真实仓库均完成冷启动全量
+索引测量。样本、commit、硬件和限制请参阅[语言质量报告](docs/QUALITY.md)、
+[索引基准](docs/BENCHMARKS.md)及 [v0.3.0 验证基线](docs/V0.3.0_BASELINE.md)。
-当前优先事项:
+## 使用边界与可信度
-- 在不削弱原子发布的前提下实现增量索引
-- 跨仓库分析和契约感知的影响分析
-- 加强动态语言的解析能力
-- 提供更多易于阅读的 CLI 输出
-- 自动发现仓库并简化仓库选择
-- 为提交前分析、调试和审查提供更多 MCP 工作流
+- 索引保留在本地。只有主动配置嵌入端点,或 MCP 客户端将选中结果发送给其模型时,
+ 才会发生外部数据传输。
+- 动态分派、反射、生成代码、宏和运行时加载可能降低静态分析召回率;置信度和后备
+ 候选会保持可见。
+- 暂不解析跨仓库关系;`rename` 只生成计划、不编辑源码;当前没有 Web UI。
+- Linux 和 macOS 使用原生源码搜索后端;Windows 提供相同查询接口的可移植后端,
+ 在大型仓库上可能更慢。
-长期目标是打造一个分布式、云端/本地混合的代码智能引擎,同时保留现有的嵌入式和
-本地优先使用路径。发现重要的能力或工作流缺口?欢迎提交
-[issue](https://github.com/mengshi02/codetrip/issues),或为仓库点 Star 以关注路线图。
+所有命令、MCP Schema、嵌入配置、CSV 验证、存储行为和详细限制请参阅
+[用户手册](docs/USER_GUIDE_ZH.md)。
## 文档
- [用户手册](docs/USER_GUIDE_ZH.md)
+- [Agent 价值与命令指南](docs/AGENT_VALUE_ZH.md)
+- [竞品工程对比](docs/COMPETITIVE_BENCHMARK_ZH.md)
- [语义质量报告](docs/QUALITY.md)
-- [索引基准测试](docs/BENCHMARKS.md)
-- [变更日志](CHANGELOG.md)
-- [贡献指南](CONTRIBUTING.md)
-- [安全策略](SECURITY.md)
+- [真实仓库索引基准](docs/BENCHMARKS.md)
+- [`brief` Agent A/B 评测](docs/V0.3.0_AGENT_AB.md)
+- [`source` Agent A/B 评测](docs/V0.3.0_SOURCE_AGENT_AB.md)
+- [`impact` 正确性基线](docs/V0.3.0_IMPACT_AGENT_AB.md)
+- [变更日志](CHANGELOG.md) · [贡献指南](CONTRIBUTING.md) · [安全策略](SECURITY.md)
## 开发
```bash
-go test ./...
-go vet ./...
+make release-gate
```
## 许可证
diff --git a/VERSION b/VERSION
index 0c62199..0d91a54 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.2.1
+0.3.0
diff --git a/benchmarks/competitive/PROTOCOL.md b/benchmarks/competitive/PROTOCOL.md
new file mode 100644
index 0000000..9671044
--- /dev/null
+++ b/benchmarks/competitive/PROTOCOL.md
@@ -0,0 +1,134 @@
+# Competitive Agent benchmark protocol
+
+## Scope
+
+This protocol adopts CodeGraph's seven published repositories, questions, and
+four-run-median Agent design, then evaluates the source-only baseline,
+Codetrip, CodeGraph, and codebase-memory-mcp under one Codex model. It does not
+claim to reproduce CodeGraph's published Claude Opus 4.8 numbers.
+
+The four arms are:
+
+1. `baseline`: built-in source tools, no code-graph MCP;
+2. `codetrip`: the same built-in tools plus Codetrip MCP;
+3. `codegraph`: the same built-in tools plus CodeGraph MCP;
+4. `cbm`: the same built-in tools plus codebase-memory-mcp.
+
+## Invariants
+
+- One Agent CLI version and one model are used for every arm in a run.
+- Every arm receives the exact published question.
+- Every repository is pinned to one commit and copied independently per arm.
+- Every index is built from that same commit in an isolated store.
+- Agent sessions run sequentially by default to reduce machine and provider
+ contention.
+- Codex runs with `--ignore-user-config`, `--ignore-rules`, and `--ephemeral`.
+ Authentication remains available, while user MCP servers, plugins, project
+ rules, and remembered sessions do not enter the comparison. The unattended
+ runner uses Codex's approval-bypass switch because otherwise external MCP
+ calls are cancelled in headless mode. It runs only pinned public repositories
+ and the prompt forbids modifications; this is an automation requirement, not
+ a product advantage.
+- Non-benchmark Codex features (plugins, apps, browser/computer use, and
+ multi-agent execution) are disabled in child sessions. After every session,
+ the runner terminates the isolated process group and records system process
+ and thread counts before and after, preventing MCP/helper accumulation.
+- Exactly one MCP server is injected through command-line configuration for
+ each product arm; the baseline receives none.
+- Product arms receive the same neutral instruction to consult their injected
+ MCP before falling back to source inspection. Product-specific answers,
+ symbols, or tool-call sequences are forbidden.
+- The Agent process receives a sanitized `PATH`; graph CLIs are not available
+ to Bash. MCP servers use resolved absolute commands.
+- Built-in `Read`, `Grep`, `Glob`, and `Bash` remain available to every arm.
+- Failures, timeouts, budget exhaustion, and tool errors remain in the sample.
+- Authentication failures abort immediately with zero usable samples; provider
+ retry backoff is not allowed to consume the benchmark time budget silently.
+- Raw stream-JSON output and stderr are retained before aggregation.
+- No answer is counted as efficient until it passes blinded correctness review.
+
+## Profiles
+
+The `smoke` profile adds a response and tool-call budget. It validates the
+harness cheaply and is never mixed into public product claims.
+
+The `official` profile sends the published question without a Codetrip-authored
+answer constraint. It is the only profile eligible for the four-run comparison.
+
+## Repetitions
+
+The formal matrix is seven tasks by four arms by four repetitions. Arms are
+rotated per repetition so provider or machine drift does not always favor the
+same tool. Medians are reported per repository and arm; aggregate ratios are
+computed from paired task medians, not by pooling unrelated raw calls.
+
+Development follows a cost gate:
+
+1. deterministic validation with no LLM;
+2. Gin, four arms, one `smoke` repetition;
+3. Gin, Excalidraw, and VS Code, four arms, one repetition;
+4. only after the direction is useful, three repetitions;
+5. the seven-repository, four-repetition public run.
+
+## Metrics
+
+### Correctness gate
+
+- blinded answer pass rate;
+- correctness score from 0 to 2;
+- evidence score from 0 to 2;
+- unsupported-claim count.
+
+### User-value metrics
+
+- actual provider cost per correct answer, when reliably reported;
+- non-cached input plus output tokens per correct answer;
+- wall time per correct answer;
+- tool calls per correct answer;
+- file reads per correct answer;
+- success rate within the declared budget.
+
+Cost per correct answer is `sum(cost) / passed answers`. Token and time variants
+use the same denominator. A failed answer therefore cannot make a tool appear
+cheap or fast.
+
+### Diagnostic metrics
+
+- input, output, cache-read, and cache-creation tokens;
+- MCP calls by tool name;
+- built-in source-tool calls;
+- MCP payload bytes when observable;
+- errors, timeouts, and budget exhaustion.
+
+If a provider does not expose a reliable per-run dollar amount, the report must
+omit dollar savings and use billed tokens instead. Prices from a different
+model must never be substituted silently.
+
+`agent.costReporting` is therefore false by default. Codex JSONL supplies total
+input, cached input, and output usage but not a provider invoice for each
+session. Reports show all three fields and use non-cached input plus output as
+the primary token-cost proxy. Total context processed remains a separate
+diagnostic metric. Any later dollar figure must be explicitly labeled as an
+estimate using a dated public price.
+
+## Blind review
+
+Answers are deterministically assigned opaque labels. Reviewers see only the
+repository commit, question, answer, and rubric. They do not see arm, latency,
+tokens, or cost. Review is manual by default to avoid spending a second large
+LLM budget and to keep the correctness decision auditable.
+
+An answer passes only when its central architectural explanation is correct and
+it contains no dangerous false relationship. Missing secondary detail may
+reduce the score without necessarily failing the answer.
+
+## Publication rules
+
+- Publish the task set, repository and tool locks, Agent/model/date, commands,
+ raw responses, review sheets, aggregation script, and failures.
+- Label single-run results as preliminary.
+- Do not compare a Codex result numerically with CodeGraph's published Opus 4.8
+ result as though the Agent and model were controlled.
+- Do not describe different MCP operations as equivalent raw-latency queries.
+- State where another tool leads.
+- Update README headline numbers only from a frozen, committed result set.
diff --git a/benchmarks/competitive/README.md b/benchmarks/competitive/README.md
new file mode 100644
index 0000000..333bda3
--- /dev/null
+++ b/benchmarks/competitive/README.md
@@ -0,0 +1,78 @@
+# Codetrip public competitive benchmark
+
+This directory contains the public, tool-neutral benchmark used to compare a
+source-only Agent, Codetrip, CodeGraph, and codebase-memory-mcp.
+
+The architecture questions are copied from CodeGraph's published seven-repo
+benchmark. All four arms run under the same Codex model. Codetrip adds
+version/commit locks, MCP-only isolation, raw JSONL retention, manual blind
+correctness review, and value-per-correct-answer aggregation.
+
+Read [PROTOCOL.md](PROTOCOL.md) before interpreting any result.
+
+## Status
+
+The harness is being rebuilt. Existing numbers under `.validation/competitive`
+are historical internal evidence and are not inputs to this public benchmark.
+No README competitive headline should cite this directory until an `official`
+result set has four repetitions per arm and completed blind review.
+
+## Quick validation
+
+```bash
+python3 benchmarks/competitive/scripts/validate.py
+```
+
+Create a local configuration without committing machine paths or credentials:
+
+```bash
+cp benchmarks/competitive/config.example.json \
+ benchmarks/competitive/config.local.json
+```
+
+`config.local.json`, repository checkouts, indexes, and raw provider credentials
+must stay untracked. Public result bundles contain metadata and Agent output,
+never secrets.
+
+## Low-cost execution order
+
+```bash
+# No LLM: validate tasks, configuration, commands, and isolation.
+python3 benchmarks/competitive/scripts/validate.py \
+ --config benchmarks/competitive/config.local.json
+
+# Clone once, pin the commit, and create one checkout per arm.
+python3 benchmarks/competitive/scripts/prepare_repositories.py \
+ --workspace /path/to/persistent/codetrip-public-benchmark --task gin-middleware
+
+# Build isolated indexes without spending model tokens.
+python3 benchmarks/competitive/scripts/prepare_indexes.py \
+ --config benchmarks/competitive/config.local.json \
+ --task gin-middleware --arm all
+
+# Freeze tool hashes, versions, machine data, and repository commits.
+python3 benchmarks/competitive/scripts/capture_environment.py \
+ --config benchmarks/competitive/config.local.json --arm all
+
+# Four Agent sessions only; never publish this smoke result as a leaderboard.
+python3 benchmarks/competitive/scripts/run_agent.py \
+ --config benchmarks/competitive/config.local.json \
+ --task gin-middleware --arm all --profile smoke --repetitions 1
+```
+
+After a run, extract metrics and prepare the manual blind-review packet:
+
+```bash
+python3 benchmarks/competitive/scripts/analyze.py /path/to/result-directory
+python3 benchmarks/competitive/scripts/blind_review.py prepare /path/to/result-directory
+# Fill review.blind.json without opening review.mapping.private.json.
+python3 benchmarks/competitive/scripts/blind_review.py finalize /path/to/result-directory
+python3 benchmarks/competitive/scripts/value_report.py /path/to/result-directory
+```
+
+Formal runs use `--profile official --repetitions 4`. Do not start the full
+matrix until the smoke output, token accounting, and isolation audit pass.
+
+The headless Codex runner must allow injected MCP calls without interactive
+approval. For safety, run it only against the pinned public benchmark commits,
+never an untrusted checkout. The benchmark prompts prohibit file modification.
diff --git a/benchmarks/competitive/config.example.json b/benchmarks/competitive/config.example.json
new file mode 100644
index 0000000..501ac77
--- /dev/null
+++ b/benchmarks/competitive/config.example.json
@@ -0,0 +1,81 @@
+{
+ "schemaVersion": 1,
+ "workspace": "/tmp/codetrip-public-benchmark",
+ "agent": {
+ "kind": "codex",
+ "command": "codex",
+ "model": "gpt-5.6-sol",
+ "reasoningEffort": "low",
+ "bypassApprovalsAndSandbox": true,
+ "disabledFeatures": [
+ "apps",
+ "browser_use",
+ "computer_use",
+ "multi_agent",
+ "plugins",
+ "recommended_plugins",
+ "skill_mcp_dependency_install",
+ "skill_search"
+ ],
+ "costReporting": false,
+ "timeoutSeconds": 600,
+ "maxBudgetUSD": null,
+ "extraArgs": []
+ },
+ "profiles": {
+ "smoke": {
+ "promptSuffix": "Answer in at most 500 words. Do not use sub-agents or load skill files. Stop after at most 12 tool calls. Cite repository-relative files and symbols. Do not modify files.",
+ "publishable": false
+ },
+ "official": {
+ "promptSuffix": "",
+ "publishable": true
+ }
+ },
+ "arms": {
+ "baseline": {
+ "mcp": null
+ },
+ "codetrip": {
+ "treatmentPrompt": "Use the Codetrip MCP tools before any shell/source inspection. Prefer one aggregate natural-language query when the tool supports it and use at most 4 product MCP calls. Do not call list_mcp_resources. Use shell/source inspection only when returned evidence is insufficient.",
+ "versionArgs": ["version"],
+ "index": {
+ "command": "/absolute/path/to/codetrip",
+ "args": ["index", "{repo_path}", "--repo", "{repository}", "--dir", "{workspace}/stores/codetrip", "--replace"]
+ },
+ "mcp": {
+ "name": "codetrip",
+ "command": "/absolute/path/to/codetrip",
+ "args": ["mcp", "--dir", "{workspace}/stores/codetrip"]
+ }
+ },
+ "codegraph": {
+ "treatmentPrompt": "Use the CodeGraph MCP tools before any shell/source inspection. Prefer one aggregate natural-language query when the tool supports it and use at most 4 product MCP calls. Do not call list_mcp_resources. Use shell/source inspection only when returned evidence is insufficient.",
+ "versionArgs": ["--version"],
+ "index": {
+ "command": "/absolute/path/to/codegraph",
+ "args": ["init", "{repo_path}"]
+ },
+ "mcp": {
+ "name": "codegraph",
+ "command": "/absolute/path/to/codegraph",
+ "args": ["serve", "--mcp"]
+ }
+ },
+ "cbm": {
+ "treatmentPrompt": "Use the codebase-memory MCP tools before any shell/source inspection. Prefer one aggregate natural-language query when the tool supports it and use at most 4 product MCP calls. Do not call list_mcp_resources. Use shell/source inspection only when returned evidence is insufficient.",
+ "versionArgs": ["--version"],
+ "index": {
+ "command": "/absolute/path/to/codebase-memory-mcp",
+ "args": ["cli", "index_repository", "{cbm_index_json}"],
+ "env": {"CBM_CACHE_DIR": "{workspace}/stores/cbm"}
+ },
+ "mcp": {
+ "name": "codebase-memory",
+ "command": "/absolute/path/to/codebase-memory-mcp",
+ "args": [],
+ "env": {"CBM_CACHE_DIR": "{workspace}/stores/cbm"}
+ }
+ }
+ }
+}
diff --git a/benchmarks/competitive/scripts/analyze.py b/benchmarks/competitive/scripts/analyze.py
new file mode 100644
index 0000000..8a1567d
--- /dev/null
+++ b/benchmarks/competitive/scripts/analyze.py
@@ -0,0 +1,201 @@
+#!/usr/bin/env python3
+"""Extract auditable metrics from Claude Code stream-JSON sessions."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import statistics
+from collections import Counter
+from pathlib import Path
+from typing import Any, Iterable
+
+from common import read_json, write_json
+
+
+def events(path: Path) -> Iterable[dict[str, Any]]:
+ with path.open("r", encoding="utf-8") as handle:
+ for number, line in enumerate(handle, 1):
+ if not line.strip():
+ continue
+ try:
+ value = json.loads(line)
+ except json.JSONDecodeError as error:
+ raise ValueError(f"{path}:{number}: invalid JSON: {error}") from error
+ if isinstance(value, dict):
+ yield value
+
+
+def content_blocks(event: dict[str, Any]) -> list[dict[str, Any]]:
+ message = event.get("message", {})
+ content = message.get("content", []) if isinstance(message, dict) else []
+ return [block for block in content if isinstance(block, dict)]
+
+
+def parse_session(session_dir: Path) -> dict[str, Any]:
+ metadata = read_json(session_dir / "metadata.json")
+ outcome = read_json(session_dir / "outcome.json")
+ tools: Counter[str] = Counter()
+ forbidden_cli_attempts: list[str] = []
+ final: dict[str, Any] = {}
+ answer = ""
+ assistant_usage: Counter[str] = Counter()
+ codex_usage: dict[str, Any] = {}
+ turn_failed = False
+ agent_kind = metadata.get("agentKind", "claude")
+ for event in events(session_dir / "stream.jsonl"):
+ if agent_kind == "codex":
+ if event.get("type") == "turn.failed":
+ turn_failed = True
+ if event.get("type") == "turn.completed" and isinstance(event.get("usage"), dict):
+ codex_usage = event["usage"]
+ if event.get("type") in ("item.started", "item.completed") and isinstance(event.get("item"), dict):
+ item = event["item"]
+ item_type = str(item.get("type", ""))
+ if event.get("type") == "item.completed" and item_type == "agent_message":
+ answer = str(item.get("text", ""))
+ elif event.get("type") == "item.completed" and item_type == "command_execution":
+ tools["Shell"] += 1
+ command = str(item.get("command", ""))
+ lowered = command.lower()
+ if any(name in lowered for name in ("codetrip", "codegraph", "codebase-memory")):
+ forbidden_cli_attempts.append(command)
+ if any(token in lowered for token in ("rg ", "grep ", "cat ", "sed ", "head ", "tail ")):
+ tools["SourceShell"] += 1
+ elif event.get("type") == "item.completed" and item_type == "mcp_tool_call":
+ server = str(item.get("server", item.get("server_name", "mcp")))
+ tool = str(item.get("tool", item.get("tool_name", "unknown")))
+ tools[f"mcp__{server}__{tool}"] += 1
+ elif event.get("type") == "item.completed" and item_type in {
+ "web_search", "image_generation", "file_change", "computer_use",
+ }:
+ tools[item_type] += 1
+ continue
+ for block in content_blocks(event):
+ if block.get("type") == "tool_use" and block.get("name"):
+ tools[str(block["name"])] += 1
+ if block["name"] == "Bash":
+ tool_input = block.get("input", {})
+ command = tool_input.get("command", "") if isinstance(tool_input, dict) else ""
+ lowered = str(command).lower()
+ if any(name in lowered for name in ("codetrip", "codegraph", "codebase-memory")):
+ forbidden_cli_attempts.append(str(command))
+ if event.get("type") == "result":
+ final = event
+ if isinstance(event.get("result"), str):
+ answer = event["result"]
+ if event.get("type") == "assistant" and isinstance(event.get("message"), dict):
+ usage = event["message"].get("usage", {})
+ if isinstance(usage, dict):
+ for key in (
+ "input_tokens", "output_tokens", "cache_read_input_tokens",
+ "cache_creation_input_tokens",
+ ):
+ assistant_usage[key] += int(usage.get(key, 0) or 0)
+ result_usage = final.get("usage", {}) if isinstance(final.get("usage"), dict) else {}
+ usage = codex_usage if agent_kind == "codex" else (assistant_usage if sum(assistant_usage.values()) else result_usage)
+ input_tokens = int(usage.get("input_tokens", 0) or 0)
+ output_tokens = int(usage.get("output_tokens", 0) or 0)
+ cache_read = int(usage.get("cached_input_tokens", usage.get("cache_read_input_tokens", 0)) or 0)
+ cache_create = int(usage.get("cache_write_input_tokens", usage.get("cache_creation_input_tokens", 0)) or 0)
+ billed_tokens = input_tokens + output_tokens if agent_kind == "codex" else input_tokens + output_tokens + cache_read + cache_create
+ source_names = {"Read", "Grep", "Glob"}
+ source_calls = sum(count for name, count in tools.items() if name in source_names) + tools.get("SourceShell", 0)
+ expected_server = {
+ "codetrip": "codetrip",
+ "codegraph": "codegraph",
+ "cbm": "codebase-memory",
+ }.get(metadata["arm"])
+ mcp_calls = sum(
+ count for name, count in tools.items()
+ if expected_server and name.startswith(f"mcp__{expected_server}__")
+ )
+ contamination = sorted(
+ name for name in tools
+ if any(product in name.lower() for product in ("codetrip", "codegraph", "codebase"))
+ and metadata["arm"] not in name.lower()
+ and metadata["arm"] != "cbm"
+ )
+ return {
+ "task": metadata["task"],
+ "repository": metadata["repository"],
+ "arm": metadata["arm"],
+ "profile": metadata["profile"],
+ "agentKind": agent_kind,
+ "repetition": metadata["repetition"],
+ "exitCode": outcome["exitCode"],
+ "timedOut": outcome["timedOut"],
+ "authenticationFailed": bool(outcome.get("authenticationFailed")),
+ "wallTimeSeconds": outcome["wallTimeSeconds"],
+ "isError": bool(turn_failed or final.get("is_error", outcome["exitCode"] != 0)),
+ "resultSubtype": final.get("subtype", ""),
+ "costUSD": final.get("total_cost_usd") if agent_kind == "claude" and metadata.get("costReporting") else None,
+ "reportedCostUSD": final.get("total_cost_usd") if agent_kind == "claude" else None,
+ "costReporting": bool(metadata.get("costReporting")),
+ "inputTokens": input_tokens,
+ "outputTokens": output_tokens,
+ "cacheReadTokens": cache_read,
+ "cacheCreationTokens": cache_create,
+ "nonCachedInputTokens": max(0, input_tokens - cache_read),
+ "billedTokens": billed_tokens,
+ "toolCalls": sum(tools.values()),
+ "sourceToolCalls": source_calls,
+ "fileReads": tools.get("Read", 0),
+ "mcpCalls": mcp_calls,
+ "tools": dict(sorted(tools.items())),
+ "answerBytes": len(answer.encode("utf-8")),
+ "answer": answer,
+ "contamination": contamination,
+ "forbiddenCLIAttempts": forbidden_cli_attempts,
+ "sessionDirectory": str(session_dir),
+ }
+
+
+def median(values: list[float | int]) -> float | None:
+ return statistics.median(values) if values else None
+
+
+def summarize(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ keys = sorted({(row["task"], row["arm"]) for row in rows})
+ summaries = []
+ for task, arm in keys:
+ selected = [row for row in rows if row["task"] == task and row["arm"] == arm]
+ completed = [row for row in selected if not row["isError"] and row["answer"]]
+ costs = [float(row["costUSD"]) for row in completed if row["costUSD"] is not None]
+ summaries.append({
+ "task": task,
+ "arm": arm,
+ "samples": len(selected),
+ "completed": len(completed),
+ "medianCostUSD": median(costs),
+ "medianBilledTokens": median([row["billedTokens"] for row in completed]),
+ "medianNonCachedInputTokens": median([row["nonCachedInputTokens"] for row in completed]),
+ "medianWallTimeSeconds": median([row["wallTimeSeconds"] for row in completed]),
+ "medianToolCalls": median([row["toolCalls"] for row in completed]),
+ "medianFileReads": median([row["fileReads"] for row in completed]),
+ "medianMCPCalls": median([row["mcpCalls"] for row in completed]),
+ "contaminatedSamples": sum(bool(row["contamination"]) for row in selected),
+ })
+ return summaries
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("run_dir", type=Path)
+ args = parser.parse_args()
+ run_dir = args.run_dir.resolve()
+ rows = []
+ failures = []
+ for stream in sorted(run_dir.glob("*/r*/*/stream.jsonl")):
+ try:
+ rows.append(parse_session(stream.parent))
+ except (OSError, ValueError) as error:
+ failures.append({"session": str(stream.parent), "error": str(error)})
+ report = {"schemaVersion": 1, "rows": rows, "summary": summarize(rows), "parseFailures": failures}
+ write_json(run_dir / "metrics.json", report)
+ print(json.dumps({"sessions": len(rows), "parseFailures": len(failures), "summary": report["summary"]}, indent=2))
+ return int(bool(failures))
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/benchmarks/competitive/scripts/blind_review.py b/benchmarks/competitive/scripts/blind_review.py
new file mode 100644
index 0000000..adb7cdf
--- /dev/null
+++ b/benchmarks/competitive/scripts/blind_review.py
@@ -0,0 +1,103 @@
+#!/usr/bin/env python3
+"""Create or validate a deterministic manual blind-review packet."""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+from pathlib import Path
+from typing import Any
+
+from common import read_json, write_json
+
+
+def blind_label(run_id: str, task: str, repetition: int, arm: str) -> str:
+ digest = hashlib.sha256(f"{run_id}:{task}:{repetition}:{arm}".encode()).hexdigest()
+ return f"answer-{digest[:12]}"
+
+
+def prepare(run_dir: Path) -> int:
+ metrics = read_json(run_dir / "metrics.json")
+ run = read_json(run_dir / "run.json")
+ public = []
+ mapping = {}
+ for row in metrics.get("rows", []):
+ label = blind_label(run["runId"], row["task"], int(row["repetition"]), row["arm"])
+ mapping[label] = {
+ "task": row["task"], "arm": row["arm"], "repetition": row["repetition"],
+ }
+ public.append({
+ "label": label,
+ "task": row["task"],
+ "repository": row["repository"],
+ "repetition": row["repetition"],
+ "answer": row["answer"],
+ "review": {
+ "correctness": None,
+ "evidence": None,
+ "pass": None,
+ "unsupportedClaims": None,
+ "notes": "",
+ },
+ })
+ public.sort(key=lambda item: (item["task"], item["label"]))
+ write_json(run_dir / "review.blind.json", {
+ "schemaVersion": 1,
+ "instructions": {
+ "correctness": "0 materially wrong; 1 mostly correct with omissions; 2 accurate",
+ "evidence": "0 unsupported; 1 partial; 2 sufficient repository-relative evidence",
+ "pass": "true only when the central explanation is correct and has no dangerous false relationship",
+ "unsupportedClaims": "non-negative count of material claims not supported by source",
+ },
+ "answers": public,
+ })
+ write_json(run_dir / "review.mapping.private.json", mapping)
+ print(f"wrote {len(public)} blinded answers to {run_dir / 'review.blind.json'}")
+ return 0
+
+
+def validate_score(value: Any, minimum: int, maximum: int, name: str, label: str) -> str | None:
+ if not isinstance(value, int) or isinstance(value, bool) or not minimum <= value <= maximum:
+ return f"{label}: {name} must be an integer from {minimum} to {maximum}"
+ return None
+
+
+def finalize(run_dir: Path) -> int:
+ packet = read_json(run_dir / "review.blind.json")
+ mapping = read_json(run_dir / "review.mapping.private.json")
+ errors = []
+ scores = []
+ for item in packet.get("answers", []):
+ label = item.get("label", "")
+ review = item.get("review", {})
+ for field, low, high in (("correctness", 0, 2), ("evidence", 0, 2), ("unsupportedClaims", 0, 1000000)):
+ error = validate_score(review.get(field), low, high, field, label)
+ if error:
+ errors.append(error)
+ if not isinstance(review.get("pass"), bool):
+ errors.append(f"{label}: pass must be true or false")
+ if label not in mapping:
+ errors.append(f"{label}: missing private mapping")
+ continue
+ scores.append({**mapping[label], "label": label, **review})
+ if errors:
+ for error in errors:
+ print(f"error: {error}")
+ return 1
+ write_json(run_dir / "review.scores.private.json", {"schemaVersion": 1, "scores": scores})
+ print(f"validated {len(scores)} manual scores")
+ return 0
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("action", choices=("prepare", "finalize"))
+ parser.add_argument("run_dir", type=Path)
+ args = parser.parse_args()
+ run_dir = args.run_dir.resolve()
+ return prepare(run_dir) if args.action == "prepare" else finalize(run_dir)
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/benchmarks/competitive/scripts/capture_environment.py b/benchmarks/competitive/scripts/capture_environment.py
new file mode 100644
index 0000000..7a4d377
--- /dev/null
+++ b/benchmarks/competitive/scripts/capture_environment.py
@@ -0,0 +1,88 @@
+#!/usr/bin/env python3
+"""Capture tool, Agent, repository, and machine identities without secrets."""
+
+from __future__ import annotations
+
+import argparse
+import datetime as dt
+import hashlib
+import json
+import os
+import platform
+import subprocess
+from pathlib import Path
+
+from common import ARMS, read_json, resolve_command, sanitized_environment, write_json
+
+
+def sha256(path: Path) -> str:
+ digest = hashlib.sha256()
+ with path.open("rb") as handle:
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+def version(command: str, args: list[str], workspace: Path) -> dict:
+ executable = Path(resolve_command(command))
+ completed = subprocess.run(
+ [str(executable), *args], capture_output=True, text=True,
+ timeout=60, env=sanitized_environment(workspace),
+ )
+ return {
+ "command": str(executable),
+ "sha256": sha256(executable),
+ "args": args,
+ "exitCode": completed.returncode,
+ "stdout": completed.stdout.strip(),
+ "stderr": completed.stderr.strip(),
+ }
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--config", type=Path, required=True)
+ parser.add_argument("--arm", action="append", default=[])
+ args = parser.parse_args()
+ config = read_json(args.config.resolve())
+ workspace = Path(config["workspace"]).expanduser().resolve()
+ arms = [arm for arm in ARMS if arm != "baseline"] if not args.arm or "all" in args.arm else args.arm
+ tools = {}
+ failed = False
+ for arm in arms:
+ definition = config["arms"][arm]
+ try:
+ tools[arm] = version(
+ str(definition["mcp"]["command"]),
+ [str(value) for value in definition.get("versionArgs", ["--version"])],
+ workspace,
+ )
+ failed = failed or tools[arm]["exitCode"] != 0
+ except (OSError, ValueError, subprocess.SubprocessError) as error:
+ tools[arm] = {"error": str(error)}
+ failed = True
+ agent = version(str(config["agent"]["command"]), ["--version"], workspace)
+ lock = read_json(workspace / "repositories.lock.json") if (workspace / "repositories.lock.json").exists() else None
+ document = {
+ "schemaVersion": 1,
+ "capturedAt": dt.datetime.now(dt.UTC).isoformat(),
+ "machine": {
+ "system": platform.system(),
+ "release": platform.release(),
+ "machine": platform.machine(),
+ "processor": platform.processor(),
+ "python": platform.python_version(),
+ "cpuCount": os.cpu_count(),
+ },
+ "agent": {**agent, "model": config["agent"].get("model", "")},
+ "tools": tools,
+ "repositories": lock,
+ }
+ destination = workspace / "environment.json"
+ write_json(destination, document)
+ print(json.dumps(document, indent=2))
+ return int(failed)
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/benchmarks/competitive/scripts/common.py b/benchmarks/competitive/scripts/common.py
new file mode 100644
index 0000000..2fceeda
--- /dev/null
+++ b/benchmarks/competitive/scripts/common.py
@@ -0,0 +1,92 @@
+#!/usr/bin/env python3
+"""Shared helpers for the public competitive benchmark."""
+
+from __future__ import annotations
+
+import json
+import os
+import shutil
+from pathlib import Path
+from typing import Any
+
+
+ROOT = Path(__file__).resolve().parents[1]
+TASKS_PATH = ROOT / "tasks" / "architecture.json"
+SAFE_PATH = os.pathsep.join(("/usr/bin", "/bin", "/usr/sbin", "/sbin"))
+ARMS = ("baseline", "codetrip", "codegraph", "cbm")
+
+
+def read_json(path: Path) -> dict[str, Any]:
+ with path.open("r", encoding="utf-8") as handle:
+ data = json.load(handle)
+ if not isinstance(data, dict):
+ raise ValueError(f"expected a JSON object: {path}")
+ return data
+
+
+def write_json(path: Path, value: Any) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ with path.open("w", encoding="utf-8") as handle:
+ json.dump(value, handle, ensure_ascii=False, indent=2)
+ handle.write("\n")
+
+
+def tasks_by_id() -> dict[str, dict[str, Any]]:
+ document = read_json(TASKS_PATH)
+ return {entry["id"]: entry for entry in document.get("tasks", [])}
+
+
+def resolve_command(command: str) -> str:
+ candidate = Path(command).expanduser()
+ if candidate.is_absolute() or os.sep in command:
+ resolved = candidate.resolve()
+ if not resolved.is_file() or not os.access(resolved, os.X_OK):
+ raise ValueError(f"command is not executable: {command}")
+ return str(resolved)
+ resolved = shutil.which(command)
+ if not resolved:
+ raise ValueError(f"command not found on PATH: {command}")
+ return str(Path(resolved).resolve())
+
+
+def expand(value: str, context: dict[str, str]) -> str:
+ try:
+ return value.format_map(context)
+ except KeyError as error:
+ raise ValueError(f"unknown template placeholder {error.args[0]!r} in {value!r}") from error
+
+
+def sanitized_environment(workspace: Path | None = None) -> dict[str, str]:
+ environment = os.environ.copy()
+ paths = [SAFE_PATH]
+ # The npm CodeGraph launcher uses `#!/usr/bin/env node`. Expose the runtime
+ # without exposing the directory that also contains the codegraph CLI to
+ # Agent Bash calls.
+ node = shutil.which("node")
+ if workspace is not None and node:
+ runtime_bin = workspace / ".benchmark-runtime" / "bin"
+ runtime_bin.mkdir(parents=True, exist_ok=True)
+ link = runtime_bin / "node"
+ if not link.exists():
+ link.symlink_to(Path(node).resolve())
+ paths.insert(0, str(runtime_bin))
+ environment["PATH"] = os.pathsep.join(paths)
+ environment["NO_COLOR"] = "1"
+ environment["CI"] = "1"
+ return environment
+
+
+def repository_path(config: dict[str, Any], task: dict[str, Any], arm: str) -> Path:
+ explicit = config.get("repositoryPaths", {}).get(task["id"], {}).get(arm)
+ if explicit:
+ return Path(explicit).expanduser().resolve()
+ workspace = Path(config["workspace"]).expanduser().resolve()
+ return workspace / "repositories" / task["repository"] / arm
+
+
+def arm_order(repetition: int, selected: list[str]) -> list[str]:
+ """Rotate arm order without introducing hidden random state."""
+ if not selected:
+ return []
+ offset = (repetition - 1) % len(selected)
+ return selected[offset:] + selected[:offset]
diff --git a/benchmarks/competitive/scripts/prepare_indexes.py b/benchmarks/competitive/scripts/prepare_indexes.py
new file mode 100644
index 0000000..651757f
--- /dev/null
+++ b/benchmarks/competitive/scripts/prepare_indexes.py
@@ -0,0 +1,94 @@
+#!/usr/bin/env python3
+"""Build one isolated product index per prepared repository checkout."""
+
+from __future__ import annotations
+
+import argparse
+import datetime as dt
+import json
+import subprocess
+import time
+from pathlib import Path
+from typing import Any
+
+from common import expand, read_json, repository_path, resolve_command, sanitized_environment, tasks_by_id, write_json
+
+
+PRODUCT_ARMS = ("codetrip", "codegraph", "cbm")
+
+
+def command_for(definition: dict[str, Any], context: dict[str, str]) -> list[str]:
+ index = definition.get("index")
+ if not isinstance(index, dict):
+ raise ValueError("missing index command")
+ return [
+ resolve_command(expand(str(index["command"]), context)),
+ *[expand(str(value), context) for value in index.get("args", [])],
+ ]
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--config", type=Path, required=True)
+ parser.add_argument("--task", action="append", required=True, help="task ID or 'all'")
+ parser.add_argument("--arm", action="append", default=[], help="product arm or 'all'")
+ args = parser.parse_args()
+ config = read_json(args.config.resolve())
+ available = tasks_by_id()
+ task_ids = list(available) if "all" in args.task else args.task
+ arms = list(PRODUCT_ARMS) if not args.arm or "all" in args.arm else args.arm
+ if unknown := sorted(set(task_ids) - set(available)):
+ parser.error(f"unknown tasks: {', '.join(unknown)}")
+ if unknown := sorted(set(arms) - set(PRODUCT_ARMS)):
+ parser.error(f"unknown product arms: {', '.join(unknown)}")
+ workspace = Path(config["workspace"]).expanduser().resolve()
+ results = []
+ for task_id in task_ids:
+ task = available[task_id]
+ for arm in arms:
+ repo_path = repository_path(config, task, arm)
+ if not (repo_path / ".git").exists():
+ raise ValueError(f"repository has not been prepared: {repo_path}")
+ marker = workspace / "indexes" / task["repository"] / f"{arm}.json"
+ if marker.exists() and read_json(marker).get("exitCode") == 0:
+ raise ValueError(f"index marker already exists; use a fresh workspace for a clean run: {marker}")
+ context = {
+ "workspace": str(workspace),
+ "repository": task["repository"],
+ "task": task_id,
+ "repo_path": str(repo_path),
+ "arm": arm,
+ "cbm_index_json": json.dumps({"repo_path": str(repo_path)}, separators=(",", ":")),
+ }
+ command = command_for(config["arms"][arm], context)
+ environment = sanitized_environment(workspace)
+ for key, value in config["arms"][arm].get("index", {}).get("env", {}).items():
+ environment[str(key)] = expand(str(value), context)
+ started = time.monotonic()
+ completed = subprocess.run(
+ command, cwd=repo_path, capture_output=True, text=True,
+ timeout=3600, env=environment,
+ )
+ result = {
+ "schemaVersion": 1,
+ "task": task_id,
+ "repository": task["repository"],
+ "arm": arm,
+ "command": command,
+ "exitCode": completed.returncode,
+ "wallTimeSeconds": round(time.monotonic() - started, 6),
+ "stdout": completed.stdout,
+ "stderr": completed.stderr,
+ "createdAt": dt.datetime.now(dt.UTC).isoformat(),
+ }
+ write_json(marker, result)
+ results.append(result)
+ print(json.dumps({key: result[key] for key in ("task", "arm", "exitCode", "wallTimeSeconds")}))
+ if completed.returncode:
+ return 1
+ write_json(workspace / "indexes" / "summary.json", {"schemaVersion": 1, "results": results})
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/benchmarks/competitive/scripts/prepare_repositories.py b/benchmarks/competitive/scripts/prepare_repositories.py
new file mode 100644
index 0000000..664fb41
--- /dev/null
+++ b/benchmarks/competitive/scripts/prepare_repositories.py
@@ -0,0 +1,75 @@
+#!/usr/bin/env python3
+"""Clone and pin independent repository checkouts for every benchmark arm."""
+
+from __future__ import annotations
+
+import argparse
+import datetime as dt
+import json
+import subprocess
+from pathlib import Path
+
+from common import ARMS, tasks_by_id, write_json
+
+
+def run(command: list[str], cwd: Path | None = None) -> str:
+ completed = subprocess.run(command, cwd=cwd, text=True, capture_output=True, timeout=1800)
+ if completed.returncode:
+ raise RuntimeError(f"command failed ({completed.returncode}): {' '.join(command)}\n{completed.stderr}")
+ return completed.stdout.strip()
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--workspace", type=Path, required=True)
+ parser.add_argument("--task", action="append", required=True, help="task ID or 'all'")
+ args = parser.parse_args()
+ workspace = args.workspace.expanduser().resolve()
+ available = tasks_by_id()
+ task_ids = list(available) if "all" in args.task else args.task
+ unknown = sorted(set(task_ids) - set(available))
+ if unknown:
+ parser.error(f"unknown tasks: {', '.join(unknown)}")
+ lock_path = workspace / "repositories.lock.json"
+ existing = {}
+ if lock_path.exists():
+ existing = json.loads(lock_path.read_text(encoding="utf-8")).get("repositories", {})
+ locks = dict(existing)
+ for task_id in task_ids:
+ task = available[task_id]
+ name = task["repository"]
+ source = workspace / "sources" / name
+ if not source.exists():
+ source.parent.mkdir(parents=True, exist_ok=True)
+ run(["git", "clone", "--depth", "1", task["url"], str(source)])
+ commit = existing.get(name, {}).get("commit")
+ if commit:
+ run(["git", "fetch", "--depth", "1", "origin", commit], source)
+ else:
+ commit = run(["git", "rev-parse", "HEAD"], source)
+ remote = run(["git", "remote", "get-url", "origin"], source)
+ if remote != task["url"]:
+ raise RuntimeError(f"source URL mismatch for {name}: {remote} != {task['url']}")
+ locks[name] = {"url": task["url"], "commit": commit}
+ for arm in ARMS:
+ destination = workspace / "repositories" / name / arm
+ if destination.exists():
+ actual = run(["git", "rev-parse", "HEAD"], destination)
+ if actual != commit:
+ raise RuntimeError(f"checkout already exists at different commit: {destination}")
+ continue
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ run(["git", "clone", "--shared", "--no-checkout", str(source), str(destination)])
+ run(["git", "checkout", "--detach", commit], destination)
+ print(json.dumps({"task": task_id, "repository": name, "commit": commit}))
+ write_json(lock_path, {
+ "schemaVersion": 1,
+ "createdAt": dt.datetime.now(dt.UTC).isoformat(),
+ "repositories": locks,
+ })
+ print(f"repository lock: {lock_path}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/benchmarks/competitive/scripts/run_agent.py b/benchmarks/competitive/scripts/run_agent.py
new file mode 100644
index 0000000..cc0d380
--- /dev/null
+++ b/benchmarks/competitive/scripts/run_agent.py
@@ -0,0 +1,315 @@
+#!/usr/bin/env python3
+"""Run isolated four-arm Agent sessions for the public benchmark."""
+
+from __future__ import annotations
+
+import argparse
+import datetime as dt
+import json
+import os
+import signal
+import subprocess
+import sys
+import time
+from pathlib import Path
+from typing import Any
+
+from common import (
+ ARMS,
+ arm_order,
+ expand,
+ read_json,
+ repository_path,
+ resolve_command,
+ sanitized_environment,
+ tasks_by_id,
+ write_json,
+)
+
+
+def mcp_document(arm: str, definition: dict[str, Any], context: dict[str, str]) -> dict[str, Any]:
+ mcp = definition.get("mcp")
+ if mcp is None:
+ return {"mcpServers": {}}
+ command = resolve_command(expand(str(mcp["command"]), context))
+ server: dict[str, Any] = {
+ "type": "stdio",
+ "command": command,
+ "args": [expand(str(value), context) for value in mcp.get("args", [])],
+ }
+ if mcp.get("env"):
+ server["env"] = {key: expand(str(value), context) for key, value in mcp["env"].items()}
+ return {"mcpServers": {mcp.get("name", arm): server}}
+
+
+def codex_mcp_arguments(document: dict[str, Any]) -> list[str]:
+ arguments: list[str] = []
+ for name, server in document.get("mcpServers", {}).items():
+ prefix = f"mcp_servers.{name}"
+ arguments.extend(("-c", f"{prefix}.command={json.dumps(server['command'])}"))
+ arguments.extend(("-c", f"{prefix}.args={json.dumps(server.get('args', []))}"))
+ environment = server.get("env", {})
+ if environment:
+ inline = ", ".join(f"{key} = {json.dumps(value)}" for key, value in sorted(environment.items()))
+ arguments.extend(("-c", f"{prefix}.env={{ {inline} }}"))
+ return arguments
+
+
+def resource_snapshot() -> dict[str, int | None]:
+ """Capture cheap Darwin process/thread counts for leak detection."""
+ result: dict[str, int | None] = {"processes": None, "threadRows": None}
+ for key, command in (
+ ("processes", ["/bin/ps", "-A", "-o", "pid="]),
+ ("threadRows", ["/bin/ps", "-M", "-A", "-o", "pid="]),
+ ):
+ try:
+ completed = subprocess.run(command, capture_output=True, text=True, timeout=5, check=False)
+ if completed.returncode == 0:
+ result[key] = len(completed.stdout.splitlines())
+ except (OSError, subprocess.SubprocessError):
+ pass
+ return result
+
+
+def terminate_process_group(process: subprocess.Popen[str]) -> None:
+ """Reap MCP and helper descendants even after the Codex leader exits."""
+ try:
+ os.killpg(process.pid, signal.SIGTERM)
+ except ProcessLookupError:
+ return
+ time.sleep(0.1)
+ try:
+ os.killpg(process.pid, signal.SIGKILL)
+ except ProcessLookupError:
+ pass
+
+
+def run_session(
+ config: dict[str, Any],
+ task: dict[str, Any],
+ arm: str,
+ profile: str,
+ repetition: int,
+ run_dir: Path,
+ force: bool,
+) -> dict[str, Any]:
+ session_dir = run_dir / task["id"] / f"r{repetition}" / arm
+ stream_path = session_dir / "stream.jsonl"
+ if stream_path.exists() and not force:
+ raise ValueError(f"refusing to overwrite existing session: {session_dir}")
+ session_dir.mkdir(parents=True, exist_ok=True)
+ repo_path = repository_path(config, task, arm)
+ if not (repo_path / ".git").exists():
+ raise ValueError(f"missing independent repository checkout: {repo_path}")
+
+ workspace = str(Path(config["workspace"]).expanduser().resolve())
+ if arm != "baseline":
+ marker = Path(workspace) / "indexes" / task["repository"] / f"{arm}.json"
+ if not marker.exists() or read_json(marker).get("exitCode") != 0:
+ raise ValueError(f"missing successful index marker: {marker}")
+ context = {
+ "workspace": workspace,
+ "repository": task["repository"],
+ "task": task["id"],
+ "repo_path": str(repo_path),
+ "arm": arm,
+ }
+ mcp_path = session_dir / "mcp.json"
+ mcp_config = mcp_document(arm, config["arms"][arm], context)
+ write_json(mcp_path, mcp_config)
+
+ agent = config["agent"]
+ prompt = task["question"]
+ suffix = config["profiles"][profile].get("promptSuffix", "").strip()
+ if suffix:
+ prompt = f"{prompt}\n\n{suffix}"
+ treatment = config["arms"][arm].get("treatmentPrompt", "").strip()
+ if treatment:
+ prompt = f"{prompt}\n\n{treatment}"
+
+ agent_kind = str(agent.get("kind", "claude"))
+ if agent_kind == "codex":
+ command = [
+ resolve_command(str(agent["command"])),
+ "exec", "--json", "--ephemeral", "--ignore-user-config", "--ignore-rules",
+ "--skip-git-repo-check", "--color", "never",
+ ]
+ for feature in agent.get("disabledFeatures", []):
+ command.extend(("--disable", str(feature)))
+ if agent.get("bypassApprovalsAndSandbox"):
+ command.append("--dangerously-bypass-approvals-and-sandbox")
+ else:
+ command.extend(("-s", "read-only"))
+ command.extend(("-C", str(repo_path)))
+ if agent.get("model"):
+ command.extend(("-m", str(agent["model"])))
+ if agent.get("reasoningEffort"):
+ command.extend(("-c", f"model_reasoning_effort={json.dumps(str(agent['reasoningEffort']))}"))
+ command.extend(codex_mcp_arguments(mcp_config))
+ command.extend(str(value) for value in agent.get("extraArgs", []))
+ command.append(prompt)
+ elif agent_kind == "claude":
+ command = [
+ resolve_command(str(agent["command"])),
+ "-p", "--output-format", "stream-json", "--verbose",
+ "--no-session-persistence", "--dangerously-skip-permissions",
+ "--strict-mcp-config", "--mcp-config", str(mcp_path),
+ ]
+ if agent.get("modelArgument"):
+ command.extend(("--model", str(agent["modelArgument"])))
+ if agent.get("maxBudgetUSD") is not None:
+ command.extend(("--max-budget-usd", str(agent["maxBudgetUSD"])))
+ command.extend(str(value) for value in agent.get("extraArgs", []))
+ else:
+ raise ValueError(f"unsupported agent kind: {agent_kind}")
+
+ metadata = {
+ "schemaVersion": 1,
+ "task": task["id"],
+ "repository": task["repository"],
+ "repositoryPath": str(repo_path),
+ "arm": arm,
+ "profile": profile,
+ "publishable": bool(config["profiles"][profile].get("publishable")),
+ "costReporting": bool(agent.get("costReporting", False)),
+ "repetition": repetition,
+ "question": task["question"],
+ "effectivePrompt": prompt,
+ "declaredModel": agent.get("model", ""),
+ "agentKind": agent_kind,
+ "reasoningEffort": agent.get("reasoningEffort", ""),
+ "agentCommand": command,
+ "startedAt": dt.datetime.now(dt.UTC).isoformat(),
+ }
+ write_json(session_dir / "metadata.json", metadata)
+
+ resources_before = resource_snapshot()
+ started = time.monotonic()
+ timed_out = False
+ authentication_failed = False
+ stderr_path = session_dir / "stderr.txt"
+ with stream_path.open("w", encoding="utf-8") as stdout_handle, stderr_path.open("w", encoding="utf-8") as stderr_handle:
+ process = subprocess.Popen(
+ command,
+ stdin=subprocess.PIPE if agent_kind == "claude" else subprocess.DEVNULL,
+ stdout=stdout_handle,
+ stderr=stderr_handle,
+ text=True,
+ cwd=repo_path,
+ env=sanitized_environment(Path(workspace)),
+ start_new_session=True,
+ )
+ if agent_kind == "claude":
+ assert process.stdin is not None
+ process.stdin.write(prompt)
+ process.stdin.close()
+ deadline = started + int(agent.get("timeoutSeconds", 600))
+ read_offset = 0
+ while process.poll() is None:
+ if stream_path.exists() and stream_path.stat().st_size > read_offset:
+ with stream_path.open("r", encoding="utf-8", errors="replace") as monitor:
+ monitor.seek(read_offset)
+ new_output = monitor.read()
+ read_offset = monitor.tell()
+ if agent_kind == "claude" and ('"error_status":401' in new_output or '"error":"authentication_failed"' in new_output):
+ authentication_failed = True
+ break
+ if time.monotonic() >= deadline:
+ timed_out = True
+ break
+ time.sleep(0.25)
+ if timed_out or authentication_failed:
+ try:
+ os.killpg(process.pid, signal.SIGTERM)
+ except ProcessLookupError:
+ pass
+ try:
+ process.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ try:
+ os.killpg(process.pid, signal.SIGKILL)
+ except ProcessLookupError:
+ pass
+ process.wait()
+ exit_code = 77 if authentication_failed else 124
+ else:
+ exit_code = process.returncode
+ # Codex starts the injected MCP server and may also start helper
+ # processes. A successful leader exit does not guarantee every child
+ # closed, so always clean the isolated process group.
+ terminate_process_group(process)
+ elapsed = time.monotonic() - started
+ resources_after = resource_snapshot()
+ stdout_bytes = stream_path.stat().st_size
+ stderr_bytes = stderr_path.stat().st_size
+ outcome = {
+ "exitCode": exit_code,
+ "timedOut": timed_out,
+ "authenticationFailed": authentication_failed,
+ "wallTimeSeconds": round(elapsed, 6),
+ "stdoutBytes": stdout_bytes,
+ "stderrBytes": stderr_bytes,
+ "resourcesBefore": resources_before,
+ "resourcesAfter": resources_after,
+ }
+ write_json(session_dir / "outcome.json", outcome)
+ return {"task": task["id"], "arm": arm, "repetition": repetition, **outcome}
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--config", type=Path, required=True)
+ parser.add_argument("--task", action="append", required=True, help="task ID or 'all'")
+ parser.add_argument("--arm", action="append", required=True, help="arm name or 'all'")
+ parser.add_argument("--profile", choices=("smoke", "official"), default="smoke")
+ parser.add_argument("--repetitions", type=int, default=1)
+ parser.add_argument("--run-id")
+ parser.add_argument("--force", action="store_true")
+ args = parser.parse_args()
+ if args.repetitions < 1:
+ parser.error("--repetitions must be positive")
+ config = read_json(args.config.resolve())
+ available = tasks_by_id()
+ task_ids = list(available) if "all" in args.task else args.task
+ unknown_tasks = sorted(set(task_ids) - set(available))
+ if unknown_tasks:
+ parser.error(f"unknown tasks: {', '.join(unknown_tasks)}")
+ arms = list(ARMS) if "all" in args.arm else args.arm
+ unknown_arms = sorted(set(arms) - set(ARMS))
+ if unknown_arms:
+ parser.error(f"unknown arms: {', '.join(unknown_arms)}")
+ run_id = args.run_id or dt.datetime.now(dt.UTC).strftime("%Y%m%dT%H%M%SZ")
+ run_dir = Path(config["workspace"]).expanduser().resolve() / "results" / run_id
+ write_json(run_dir / "run.json", {
+ "schemaVersion": 1,
+ "runId": run_id,
+ "profile": args.profile,
+ "tasks": task_ids,
+ "arms": arms,
+ "repetitions": args.repetitions,
+ "agent": config["agent"],
+ "createdAt": dt.datetime.now(dt.UTC).isoformat(),
+ })
+ failed = False
+ for task_id in task_ids:
+ for repetition in range(1, args.repetitions + 1):
+ for arm in arm_order(repetition, arms):
+ try:
+ result = run_session(
+ config, available[task_id], arm, args.profile,
+ repetition, run_dir, args.force,
+ )
+ print(json.dumps(result), flush=True)
+ failed = failed or result["exitCode"] != 0
+ except (OSError, ValueError, subprocess.SubprocessError) as error:
+ print(json.dumps({
+ "task": task_id, "arm": arm, "repetition": repetition,
+ "harnessError": str(error),
+ }), flush=True)
+ failed = True
+ print(f"result directory: {run_dir}", file=sys.stderr)
+ return int(failed)
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/benchmarks/competitive/scripts/validate.py b/benchmarks/competitive/scripts/validate.py
new file mode 100644
index 0000000..baf8881
--- /dev/null
+++ b/benchmarks/competitive/scripts/validate.py
@@ -0,0 +1,116 @@
+#!/usr/bin/env python3
+"""Validate public benchmark tasks and an optional machine-local config."""
+
+from __future__ import annotations
+
+import argparse
+import os
+import sys
+from pathlib import Path
+
+from common import ARMS, ROOT, SAFE_PATH, TASKS_PATH, read_json, resolve_command
+
+
+EXPECTED_TASKS = {
+ "vscode-extension-host",
+ "excalidraw-canvas",
+ "django-queryset",
+ "tokio-runtime",
+ "okhttp-interceptors",
+ "gin-middleware",
+ "alamofire-request",
+}
+
+
+def validate_tasks(errors: list[str]) -> None:
+ try:
+ document = read_json(TASKS_PATH)
+ except (OSError, ValueError) as error:
+ errors.append(str(error))
+ return
+ tasks = document.get("tasks")
+ if not isinstance(tasks, list):
+ errors.append("tasks/architecture.json: tasks must be an array")
+ return
+ ids = [entry.get("id") for entry in tasks if isinstance(entry, dict)]
+ if len(ids) != len(set(ids)):
+ errors.append("tasks/architecture.json: duplicate task IDs")
+ if set(ids) != EXPECTED_TASKS:
+ errors.append(f"tasks/architecture.json: expected CodeGraph task set {sorted(EXPECTED_TASKS)}")
+ for index, entry in enumerate(tasks):
+ for key in ("id", "repository", "url", "language", "question"):
+ if not isinstance(entry.get(key), str) or not entry[key].strip():
+ errors.append(f"tasks[{index}].{key} must be a non-empty string")
+ if not str(entry.get("url", "")).startswith("https://github.com/"):
+ errors.append(f"tasks[{index}].url must be a public GitHub repository URL")
+
+
+def validate_config(path: Path, errors: list[str], warnings: list[str]) -> None:
+ try:
+ config = read_json(path)
+ except (OSError, ValueError) as error:
+ errors.append(str(error))
+ return
+ if not config.get("workspace"):
+ errors.append("config.workspace is required")
+ agent = config.get("agent", {})
+ if agent.get("kind") not in ("codex", "claude"):
+ errors.append("agent.kind must be 'codex' or 'claude'")
+ try:
+ resolve_command(str(agent.get("command", "")))
+ except ValueError as error:
+ errors.append(f"agent: {error}")
+ if not agent.get("model"):
+ warnings.append("agent.model is empty; the provider default will be recorded but is less reproducible")
+ arms = config.get("arms", {})
+ if set(arms) != set(ARMS):
+ errors.append(f"config.arms must contain exactly {', '.join(ARMS)}")
+ for arm in ARMS:
+ definition = arms.get(arm, {})
+ mcp = definition.get("mcp")
+ if arm == "baseline":
+ if mcp is not None:
+ errors.append("baseline.mcp must be null")
+ continue
+ if not isinstance(mcp, dict):
+ errors.append(f"{arm}.mcp must be an object")
+ continue
+ try:
+ command = resolve_command(str(mcp.get("command", "")))
+ if os.path.dirname(command) in SAFE_PATH.split(os.pathsep):
+ warnings.append(f"{arm} MCP executable is on sanitized Agent PATH: {command}")
+ except ValueError as error:
+ errors.append(f"{arm}: {error}")
+ if not isinstance(mcp.get("args", []), list):
+ errors.append(f"{arm}.mcp.args must be an array")
+ profiles = config.get("profiles", {})
+ for name in ("smoke", "official"):
+ if name not in profiles:
+ errors.append(f"config.profiles.{name} is required")
+ if profiles.get("official", {}).get("promptSuffix"):
+ errors.append("official.promptSuffix must be empty so the published question remains verbatim")
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--config", type=Path)
+ args = parser.parse_args()
+ errors: list[str] = []
+ warnings: list[str] = []
+ validate_tasks(errors)
+ if args.config:
+ validate_config(args.config.resolve(), errors, warnings)
+ for warning in warnings:
+ print(f"warning: {warning}", file=sys.stderr)
+ for error in errors:
+ print(f"error: {error}", file=sys.stderr)
+ if errors:
+ return 1
+ print(f"validated task set: {TASKS_PATH.relative_to(ROOT.parent.parent)}")
+ if args.config:
+ print(f"validated local config: {args.config.resolve()}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/benchmarks/competitive/scripts/value_report.py b/benchmarks/competitive/scripts/value_report.py
new file mode 100644
index 0000000..80af206
--- /dev/null
+++ b/benchmarks/competitive/scripts/value_report.py
@@ -0,0 +1,58 @@
+#!/usr/bin/env python3
+"""Combine runtime metrics and manual scores into value-per-correct-answer metrics."""
+
+from __future__ import annotations
+
+import argparse
+import json
+from collections import defaultdict
+from pathlib import Path
+
+from common import read_json, write_json
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("run_dir", type=Path)
+ args = parser.parse_args()
+ run_dir = args.run_dir.resolve()
+ metrics = read_json(run_dir / "metrics.json")
+ reviews = read_json(run_dir / "review.scores.private.json")
+ review_index = {
+ (row["task"], row["arm"], int(row["repetition"])): row
+ for row in reviews.get("scores", [])
+ }
+ groups = defaultdict(list)
+ for row in metrics.get("rows", []):
+ key = (row["task"], row["arm"], int(row["repetition"]))
+ review = review_index.get(key)
+ if review is None:
+ raise ValueError(f"missing review for {key}")
+ groups[row["arm"]].append({**row, "review": review})
+ report = []
+ for arm, rows in sorted(groups.items()):
+ passed = [row for row in rows if row["review"]["pass"]]
+ costs = [float(row["costUSD"]) for row in rows if row["costUSD"] is not None]
+ report.append({
+ "arm": arm,
+ "samples": len(rows),
+ "passed": len(passed),
+ "passRate": len(passed) / len(rows) if rows else None,
+ "averageCorrectness": sum(row["review"]["correctness"] for row in rows) / len(rows),
+ "averageEvidence": sum(row["review"]["evidence"] for row in rows) / len(rows),
+ "unsupportedClaims": sum(row["review"]["unsupportedClaims"] for row in rows),
+ "totalCostUSD": sum(costs) if len(costs) == len(rows) else None,
+ "costPerCorrectAnswerUSD": sum(costs) / len(passed) if len(costs) == len(rows) and passed else None,
+ "tokensPerCorrectAnswer": sum(row["billedTokens"] for row in rows) / len(passed) if passed else None,
+ "secondsPerCorrectAnswer": sum(row["wallTimeSeconds"] for row in rows) / len(passed) if passed else None,
+ "toolCallsPerCorrectAnswer": sum(row["toolCalls"] for row in rows) / len(passed) if passed else None,
+ "fileReadsPerCorrectAnswer": sum(row["fileReads"] for row in rows) / len(passed) if passed else None,
+ })
+ output = {"schemaVersion": 1, "valueByArm": report}
+ write_json(run_dir / "value-report.json", output)
+ print(json.dumps(output, indent=2))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/benchmarks/competitive/tasks/architecture.json b/benchmarks/competitive/tasks/architecture.json
new file mode 100644
index 0000000..9bb97ed
--- /dev/null
+++ b/benchmarks/competitive/tasks/architecture.json
@@ -0,0 +1,60 @@
+{
+ "schemaVersion": 1,
+ "source": {
+ "name": "CodeGraph published architecture benchmark",
+ "url": "https://github.com/colbymchenry/codegraph#why-codegraph",
+ "retrievedAt": "2026-08-10",
+ "note": "Questions are copied verbatim from the public CodeGraph benchmark. Repository commits are pinned separately when a run is prepared."
+ },
+ "tasks": [
+ {
+ "id": "vscode-extension-host",
+ "repository": "vscode",
+ "url": "https://github.com/microsoft/vscode.git",
+ "language": "typescript",
+ "question": "How does the extension host communicate with the main process?"
+ },
+ {
+ "id": "excalidraw-canvas",
+ "repository": "excalidraw",
+ "url": "https://github.com/excalidraw/excalidraw.git",
+ "language": "typescript",
+ "question": "How does Excalidraw render and update canvas elements?"
+ },
+ {
+ "id": "django-queryset",
+ "repository": "django",
+ "url": "https://github.com/django/django.git",
+ "language": "python",
+ "question": "How does Django's ORM build and execute a query from a QuerySet?"
+ },
+ {
+ "id": "tokio-runtime",
+ "repository": "tokio",
+ "url": "https://github.com/tokio-rs/tokio.git",
+ "language": "rust",
+ "question": "How does tokio schedule and run async tasks on its runtime?"
+ },
+ {
+ "id": "okhttp-interceptors",
+ "repository": "okhttp",
+ "url": "https://github.com/square/okhttp.git",
+ "language": "java",
+ "question": "How does OkHttp process a request through its interceptor chain?"
+ },
+ {
+ "id": "gin-middleware",
+ "repository": "gin",
+ "url": "https://github.com/gin-gonic/gin.git",
+ "language": "go",
+ "question": "How does gin route requests through its middleware chain?"
+ },
+ {
+ "id": "alamofire-request",
+ "repository": "alamofire",
+ "url": "https://github.com/Alamofire/Alamofire.git",
+ "language": "swift",
+ "question": "How does Alamofire build, send, and validate a request?"
+ }
+ ]
+}
diff --git a/benchmarks/competitive/tests/test_harness.py b/benchmarks/competitive/tests/test_harness.py
new file mode 100644
index 0000000..82bd2cc
--- /dev/null
+++ b/benchmarks/competitive/tests/test_harness.py
@@ -0,0 +1,101 @@
+from __future__ import annotations
+
+import json
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+
+SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
+sys.path.insert(0, str(SCRIPTS))
+
+from analyze import parse_session # noqa: E402
+from blind_review import blind_label # noqa: E402
+from common import ARMS, arm_order, tasks_by_id # noqa: E402
+
+
+class HarnessTest(unittest.TestCase):
+ def test_published_task_set_is_complete(self):
+ tasks = tasks_by_id()
+ self.assertEqual(len(tasks), 7)
+ self.assertIn("gin-middleware", tasks)
+ self.assertEqual(
+ tasks["gin-middleware"]["question"],
+ "How does gin route requests through its middleware chain?",
+ )
+
+ def test_arm_order_rotates(self):
+ selected = list(ARMS)
+ self.assertEqual(arm_order(1, selected), selected)
+ self.assertEqual(arm_order(2, selected), selected[1:] + selected[:1])
+
+ def test_blind_labels_are_stable_and_arm_specific(self):
+ first = blind_label("run", "task", 1, "baseline")
+ self.assertEqual(first, blind_label("run", "task", 1, "baseline"))
+ self.assertNotEqual(first, blind_label("run", "task", 1, "codetrip"))
+
+ def test_stream_metrics_include_cache_and_contamination(self):
+ with tempfile.TemporaryDirectory() as directory:
+ session = Path(directory)
+ (session / "metadata.json").write_text(json.dumps({
+ "task": "gin-middleware", "repository": "gin", "arm": "baseline",
+ "profile": "smoke", "repetition": 1,
+ }))
+ (session / "outcome.json").write_text(json.dumps({
+ "exitCode": 0, "timedOut": False, "authenticationFailed": False,
+ "wallTimeSeconds": 1.5,
+ }))
+ stream = [
+ {"type": "assistant", "message": {"content": [
+ {"type": "tool_use", "name": "Read", "input": {"file_path": "gin.go"}},
+ {"type": "tool_use", "name": "Bash", "input": {"command": "codegraph explore Gin"}},
+ ]}},
+ {"type": "result", "result": "answer", "usage": {
+ "input_tokens": 10, "output_tokens": 5,
+ "cache_read_input_tokens": 20, "cache_creation_input_tokens": 2,
+ }, "total_cost_usd": 0.01},
+ ]
+ (session / "stream.jsonl").write_text("".join(json.dumps(row) + "\n" for row in stream))
+ row = parse_session(session)
+ self.assertEqual(row["billedTokens"], 37)
+ self.assertEqual(row["fileReads"], 1)
+ self.assertEqual(row["toolCalls"], 2)
+ self.assertEqual(row["forbiddenCLIAttempts"], ["codegraph explore Gin"])
+
+ def test_codex_stream_metrics_do_not_double_count_cached_input(self):
+ with tempfile.TemporaryDirectory() as directory:
+ session = Path(directory)
+ (session / "metadata.json").write_text(json.dumps({
+ "task": "gin-middleware", "repository": "gin", "arm": "codetrip",
+ "profile": "smoke", "repetition": 1, "agentKind": "codex",
+ "costReporting": False,
+ }))
+ (session / "outcome.json").write_text(json.dumps({
+ "exitCode": 0, "timedOut": False, "authenticationFailed": False,
+ "wallTimeSeconds": 2.0,
+ }))
+ stream = [
+ {"type": "item.completed", "item": {
+ "type": "mcp_tool_call", "server": "codetrip", "tool": "brief",
+ }},
+ {"type": "item.completed", "item": {
+ "type": "command_execution", "command": "/bin/zsh -lc 'rg RouterGroup ./'",
+ }},
+ {"type": "item.completed", "item": {"type": "agent_message", "text": "answer"}},
+ {"type": "turn.completed", "usage": {
+ "input_tokens": 100, "cached_input_tokens": 80,
+ "cache_write_input_tokens": 0, "output_tokens": 10,
+ }},
+ ]
+ (session / "stream.jsonl").write_text("".join(json.dumps(row) + "\n" for row in stream))
+ row = parse_session(session)
+ self.assertEqual(row["billedTokens"], 110)
+ self.assertEqual(row["nonCachedInputTokens"], 20)
+ self.assertEqual(row["mcpCalls"], 1)
+ self.assertEqual(row["sourceToolCalls"], 1)
+ self.assertEqual(row["answer"], "answer")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/benchmarks/live_context/README.md b/benchmarks/live_context/README.md
new file mode 100644
index 0000000..df82895
--- /dev/null
+++ b/benchmarks/live_context/README.md
@@ -0,0 +1,72 @@
+# Live Code Context benchmark
+
+This benchmark measures the coding loop Codetrip v0.3 is designed for:
+
+```text
+edit code -> publish one consistent graph revision -> query one fresh brief
+```
+
+The public comparison uses the Go and TypeScript repositories from CodeGraph's
+published architecture set: Gin, VS Code, and Excalidraw. Each product receives
+the same pinned checkout and the same deterministic mutation. A result is
+publishable only when the product's best documented refresh workflow and all
+raw events are included.
+
+The low-cost probe uses no LLM. It records:
+
+- edit-to-correct-brief latency;
+- complete-old, complete-new, and mixed-revision observations;
+- stale observations after the first complete-new result;
+- whether the final incremental brief equals a clean full-index brief;
+- refresh mode and diagnostics from the watch server.
+
+Run against a disposable benchmark checkout, never a working repository:
+
+```bash
+python3 benchmarks/live_context/run.py \
+ --repo-source /path/to/pinned/gin \
+ --codetrip-bin ./bin/codetrip \
+ --workspace /path/to/persistent/benchmark-workspace
+```
+
+The runner copies the repository before adding its transparent benchmark
+fixture. It never modifies `--repo-source`.
+
+## Competitive workflow
+
+The same initial and mutated fixture is used for every product. The clock starts
+after the mutated file is durably written and stops only when one query returns
+new source and the new call edge together. Complete old snapshots are recorded
+as old; new source with an old edge (or the reverse) is recorded as mixed.
+
+For each pinned product version, the benchmark runs two lanes:
+
+1. **automatic lane:** query immediately after the edit using any documented
+ watcher or automatic refresh capability;
+2. **best-supported lane:** when automatic graph refresh is unavailable, run
+ the product's documented refresh/reindex operation and include that work in
+ edit-to-accurate-context latency and operation count.
+
+The headline comparison is time and operations to a correct post-edit context,
+not raw query latency. Unsupported automatic refresh is reported as unsupported,
+not converted into an artificial timeout. All competitor adapters must first
+prove their final result against their own clean index before being included.
+
+Use `run_matrix.py` for a comparable multi-round result. Five rounds are the
+minimum for an internal signal; public claims should use more rounds and pinned
+versions:
+
+```bash
+python3 benchmarks/live_context/run_matrix.py \
+ --repo-source /path/to/pinned/gin \
+ --codetrip-bin ./bin/codetrip \
+ --codegraph-bin /path/to/codegraph \
+ --cbm-bin /path/to/codebase-memory-mcp \
+ --workspace /path/to/persistent/benchmark-workspace \
+ --rounds 5
+```
+
+The matrix rotates product order between rounds. CodeGraph runs with its
+documented `CODEGRAPH_NO_DAEMON=1` mode so an interrupted benchmark cannot leave
+a shared background daemon consuming resources; its watcher and MCP behavior
+remain enabled.
diff --git a/benchmarks/live_context/results/gin-function-rename.json b/benchmarks/live_context/results/gin-function-rename.json
new file mode 100644
index 0000000..127875a
--- /dev/null
+++ b/benchmarks/live_context/results/gin-function-rename.json
@@ -0,0 +1,45 @@
+{
+ "schemaVersion": 1,
+ "recordedAt": "2026-08-10",
+ "repository": "gin",
+ "mutation": "rename-target-and-update-call",
+ "roundsPerProduct": 5,
+ "watchDebounceMs": 100,
+ "products": {
+ "codetrip": {
+ "editToAccurateContextP50Ms": 1168.515,
+ "editToAccurateContextP95Ms": 1195.53,
+ "engineRefreshP50Ms": 981.0,
+ "operationsToAccurateContextP50": 1,
+ "mixedRevisionObservations": 0,
+ "staleAfterPublication": 0,
+ "cleanFullEquivalentRate": 1.0
+ },
+ "codegraph-v1.5.0": {
+ "editToAccurateContextP50Ms": 298.372,
+ "editToAccurateContextP95Ms": 388.964,
+ "operationsToAccurateContextP50": 4,
+ "mixedRevisionObservations": 17,
+ "stalenessWarningObservations": 13,
+ "cleanFullEquivalentRate": 1.0
+ },
+ "codebase-memory-mcp-v0.9.0": {
+ "automaticLaneProducedAccurateGraph": false,
+ "editToAccurateContextP50Ms": 309.897,
+ "editToAccurateContextP95Ms": 396.211,
+ "operationsToAccurateContextP50": 3,
+ "mixedRevisionObservations": 5,
+ "cleanFullEquivalentRate": 1.0
+ }
+ },
+ "derived": {
+ "codetripRoundTripReductionVsCodeGraph": 0.75,
+ "codetripRoundTripReductionVsCBM": 0.6667
+ },
+ "limitations": [
+ "This deterministic benchmark uses no LLM.",
+ "Tool-round-trip reduction is not a token-savings claim.",
+ "The result covers one transparent structural mutation on one pinned repository.",
+ "Competitor results use their documented automatic or best-supported refresh workflow."
+ ]
+}
diff --git a/benchmarks/live_context/run.py b/benchmarks/live_context/run.py
new file mode 100644
index 0000000..9d4bdf8
--- /dev/null
+++ b/benchmarks/live_context/run.py
@@ -0,0 +1,273 @@
+#!/usr/bin/env python3
+"""Measure edit-to-consistent-brief latency without spending model tokens."""
+
+from __future__ import annotations
+
+import argparse
+import datetime as dt
+import json
+import os
+import queue
+import re
+import shutil
+import signal
+import subprocess
+import threading
+import time
+from pathlib import Path
+from typing import Any
+
+
+INITIAL = """package gin
+
+func codetripLiveTarget() string { return "old" }
+func codetripLiveCaller() string { return codetripLiveTarget() }
+"""
+
+MUTATED = """package gin
+
+func codetripLiveTargetRenamed() string { return "new" }
+func codetripLiveCaller() string { return codetripLiveTargetRenamed() }
+"""
+
+
+class MCPClient:
+ def __init__(self, command: list[str], cwd: Path, stderr_path: Path,
+ env: dict[str, str] | None = None):
+ self.process = subprocess.Popen(
+ command, cwd=cwd, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
+ stderr=stderr_path.open("w", encoding="utf-8"), text=True,
+ bufsize=1, start_new_session=True, env=env,
+ )
+ self.responses: queue.Queue[dict[str, Any]] = queue.Queue()
+ self.next_id = 1
+ self.reader = threading.Thread(target=self._read, daemon=True)
+ self.reader.start()
+
+ def _read(self) -> None:
+ assert self.process.stdout is not None
+ for line in self.process.stdout:
+ try:
+ value = json.loads(line)
+ except json.JSONDecodeError:
+ continue
+ if isinstance(value, dict) and "id" in value:
+ self.responses.put(value)
+
+ def send(self, method: str, params: dict[str, Any], timeout: float = 30) -> dict[str, Any]:
+ request_id = self.next_id
+ self.next_id += 1
+ assert self.process.stdin is not None
+ self.process.stdin.write(json.dumps({
+ "jsonrpc": "2.0", "id": request_id, "method": method, "params": params,
+ }, separators=(",", ":")) + "\n")
+ self.process.stdin.flush()
+ deadline = time.monotonic() + timeout
+ deferred: list[dict[str, Any]] = []
+ while time.monotonic() < deadline:
+ try:
+ response = self.responses.get(timeout=min(0.25, deadline-time.monotonic()))
+ except queue.Empty:
+ if self.process.poll() is not None:
+ raise RuntimeError(f"MCP server exited with {self.process.returncode}")
+ continue
+ if response.get("id") == request_id:
+ for item in deferred:
+ self.responses.put(item)
+ if "error" in response:
+ raise RuntimeError(f"MCP error: {response['error']}")
+ return response["result"]
+ deferred.append(response)
+ raise TimeoutError(f"MCP request {method} timed out")
+
+ def notify(self, method: str, params: dict[str, Any] | None = None) -> None:
+ assert self.process.stdin is not None
+ self.process.stdin.write(json.dumps({
+ "jsonrpc": "2.0", "method": method, "params": params or {},
+ }, separators=(",", ":")) + "\n")
+ self.process.stdin.flush()
+
+ def close(self) -> None:
+ try:
+ os.killpg(self.process.pid, signal.SIGTERM)
+ except ProcessLookupError:
+ return
+ try:
+ self.process.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ try:
+ os.killpg(self.process.pid, signal.SIGKILL)
+ except ProcessLookupError:
+ pass
+ self.process.wait()
+
+
+def tool_payload(result: dict[str, Any]) -> dict[str, Any]:
+ structured = result.get("structuredContent") or result.get("structured_content")
+ if isinstance(structured, dict):
+ return structured
+ for block in result.get("content", []):
+ if isinstance(block, dict) and isinstance(block.get("text"), str):
+ try:
+ value = json.loads(block["text"])
+ except json.JSONDecodeError:
+ continue
+ if isinstance(value, dict):
+ return value
+ raise ValueError(f"tool result has no JSON payload: {result}")
+
+
+def classify_brief(brief: dict[str, Any]) -> str:
+ source = brief.get("source", "")
+ outgoing = {
+ relation.get("node", {}).get("name")
+ for relation in brief.get("outgoing", [])
+ if isinstance(relation, dict)
+ }
+ old_source = "codetripLiveTarget()" in source and "TargetRenamed" not in source
+ new_source = "codetripLiveTargetRenamed()" in source
+ old_edge = "codetripLiveTarget" in outgoing and "codetripLiveTargetRenamed" not in outgoing
+ new_edge = "codetripLiveTargetRenamed" in outgoing and "codetripLiveTarget" not in outgoing
+ if old_source and old_edge:
+ return "complete-old"
+ if new_source and new_edge:
+ return "complete-new"
+ return "mixed"
+
+
+def canonical_brief(brief: dict[str, Any]) -> dict[str, Any]:
+ return {
+ "symbol": brief.get("symbol", {}).get("name"),
+ "signature": brief.get("signature"),
+ "source": brief.get("source"),
+ "incoming": sorted((r.get("type"), r.get("node", {}).get("name")) for r in brief.get("incoming", [])),
+ "outgoing": sorted((r.get("type"), r.get("node", {}).get("name")) for r in brief.get("outgoing", [])),
+ }
+
+
+def run(command: list[str], cwd: Path, timeout: int = 3600) -> subprocess.CompletedProcess[str]:
+ completed = subprocess.run(command, cwd=cwd, capture_output=True, text=True, timeout=timeout)
+ if completed.returncode:
+ raise RuntimeError(f"command failed {command}:\n{completed.stderr}")
+ return completed
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--repo-source", type=Path, required=True)
+ parser.add_argument("--codetrip-bin", type=Path, required=True)
+ parser.add_argument("--workspace", type=Path, required=True)
+ parser.add_argument("--run-id", default=dt.datetime.now(dt.UTC).strftime("%Y%m%dT%H%M%SZ"))
+ args = parser.parse_args()
+ source = args.repo_source.resolve()
+ binary = args.codetrip_bin.resolve()
+ result_dir = args.workspace.expanduser().resolve() / "results" / args.run_id
+ repository = result_dir / "repository"
+ store = result_dir / "incremental-store"
+ full_store = result_dir / "clean-full-store"
+ if result_dir.exists():
+ raise ValueError(f"result directory already exists: {result_dir}")
+ result_dir.mkdir(parents=True)
+ shutil.copytree(source, repository, symlinks=True)
+ fixture = repository / "codetrip_live_benchmark.go"
+ fixture.write_text(INITIAL, encoding="utf-8")
+
+ initial = run([str(binary), "index", str(repository), "--repo", "gin-live", "--dir", str(store)], repository)
+ (result_dir / "initial-index.stdout").write_text(initial.stdout, encoding="utf-8")
+ (result_dir / "initial-index.stderr").write_text(initial.stderr, encoding="utf-8")
+
+ client = MCPClient([
+ str(binary), "mcp", "--dir", str(store), "--watch", "--watch-debounce", "100ms",
+ ], repository, result_dir / "watch.stderr")
+ try:
+ client.send("initialize", {
+ "protocolVersion": "2025-06-18", "capabilities": {},
+ "clientInfo": {"name": "codetrip-live-context", "version": "1"},
+ })
+ client.notify("notifications/initialized")
+ before = tool_payload(client.send("tools/call", {
+ "name": "brief", "arguments": {"repo": "gin-live", "symbol": "codetripLiveCaller"},
+ }))
+ if classify_brief(before) != "complete-old":
+ raise AssertionError(f"invalid initial brief: {canonical_brief(before)}")
+
+ started = time.monotonic_ns()
+ fixture.write_text(MUTATED, encoding="utf-8")
+ timeline: list[dict[str, Any]] = []
+ deadline = time.monotonic() + 30
+ reached_new = False
+ while time.monotonic() < deadline:
+ call_started = time.monotonic_ns()
+ current = tool_payload(client.send("tools/call", {
+ "name": "brief", "arguments": {"repo": "gin-live", "symbol": "codetripLiveCaller"},
+ }))
+ observed = time.monotonic_ns()
+ state = classify_brief(current)
+ timeline.append({
+ "elapsedMs": round((observed-started)/1_000_000, 3),
+ "callMs": round((observed-call_started)/1_000_000, 3),
+ "state": state,
+ })
+ if state == "complete-new":
+ reached_new = True
+ final_incremental = current
+ break
+ time.sleep(0.025)
+ if not reached_new:
+ raise TimeoutError(f"fresh brief not observed: {timeline}")
+ for _ in range(3):
+ followup_started = time.monotonic_ns()
+ followup = tool_payload(client.send("tools/call", {
+ "name": "brief", "arguments": {"repo": "gin-live", "symbol": "codetripLiveCaller"},
+ }))
+ followup_observed = time.monotonic_ns()
+ timeline.append({
+ "elapsedMs": round((followup_observed-started)/1_000_000, 3),
+ "callMs": round((followup_observed-followup_started)/1_000_000, 3),
+ "state": classify_brief(followup), "phase": "post-publication",
+ })
+ finally:
+ client.close()
+
+ full = run([str(binary), "index", str(repository), "--repo", "gin-live", "--dir", str(full_store)], repository)
+ (result_dir / "clean-full-index.stdout").write_text(full.stdout, encoding="utf-8")
+ clean_brief = json.loads(run([
+ str(binary), "brief", "codetripLiveCaller", "--repo", "gin-live", "--dir", str(full_store),
+ ], repository).stdout)
+ equivalent = canonical_brief(final_incremental) == canonical_brief(clean_brief)
+ watch_log = (result_dir / "watch.stderr").read_text(encoding="utf-8")
+ refresh_match = re.search(
+ r"mode=(\S+) added=(\d+) modified=(\d+) deleted=(\d+) duration=([0-9.]+)s elapsed=([0-9.]+)s fallback=\"([^\"]*)\"",
+ watch_log,
+ )
+ refresh = None
+ if refresh_match:
+ refresh = {
+ "mode": refresh_match.group(1), "added": int(refresh_match.group(2)),
+ "modified": int(refresh_match.group(3)), "deleted": int(refresh_match.group(4)),
+ "engineSeconds": float(refresh_match.group(5)), "elapsedSeconds": float(refresh_match.group(6)),
+ "fallbackReason": refresh_match.group(7),
+ }
+ first_new = next(row for row in timeline if row["state"] == "complete-new")
+ post_publication = [row for row in timeline if row.get("phase") == "post-publication"]
+ report = {
+ "schemaVersion": 1, "repository": "gin", "arm": "codetrip",
+ "mutation": "rename-target-and-update-call", "timeline": timeline,
+ "editToAccurateContextMs": first_new["elapsedMs"],
+ "operationsToAccurateContext": timeline.index(first_new) + 1,
+ "firstBriefCallMs": timeline[0]["callMs"],
+ "mixedRevisionObservations": sum(row["state"] == "mixed" for row in timeline),
+ "oldRevisionObservations": sum(row["state"] == "complete-old" for row in timeline),
+ "staleAfterPublication": sum(row["state"] != "complete-new" for row in post_publication),
+ "refresh": refresh,
+ "cleanFullEquivalent": equivalent,
+ "incrementalBrief": canonical_brief(final_incremental),
+ "cleanFullBrief": canonical_brief(clean_brief),
+ }
+ (result_dir / "report.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
+ print(json.dumps(report, indent=2))
+ return 0 if equivalent and report["mixedRevisionObservations"] == 0 and report["staleAfterPublication"] == 0 else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/benchmarks/live_context/run_cbm.py b/benchmarks/live_context/run_cbm.py
new file mode 100644
index 0000000..e6250b7
--- /dev/null
+++ b/benchmarks/live_context/run_cbm.py
@@ -0,0 +1,162 @@
+#!/usr/bin/env python3
+"""Measure codebase-memory's best documented refresh workflow."""
+
+from __future__ import annotations
+
+import argparse
+import datetime as dt
+import json
+import os
+import shutil
+import time
+from pathlib import Path
+from typing import Any
+
+from run import INITIAL, MUTATED, MCPClient
+
+
+def result_text(result: dict[str, Any]) -> str:
+ structured = result.get("structuredContent") or result.get("structured_content")
+ parts = [json.dumps(structured, sort_keys=True)] if structured else []
+ parts.extend(
+ block.get("text", "") for block in result.get("content", [])
+ if isinstance(block, dict) and isinstance(block.get("text"), str)
+ )
+ return "\n".join(parts)
+
+
+def call(client: MCPClient, name: str, arguments: dict[str, Any], timeout: float = 3600) -> tuple[str, float]:
+ started = time.monotonic_ns()
+ result = client.send("tools/call", {"name": name, "arguments": arguments}, timeout=timeout)
+ return result_text(result), round((time.monotonic_ns()-started)/1_000_000, 3)
+
+
+def initialize(client: MCPClient) -> None:
+ client.send("initialize", {
+ "protocolVersion": "2025-06-18", "capabilities": {},
+ "clientInfo": {"name": "codetrip-live-context", "version": "1"},
+ })
+ client.notify("notifications/initialized")
+
+
+def context(client: MCPClient, project: str) -> tuple[str, float, int]:
+ search, search_ms = call(client, "search_graph", {
+ "project": project, "query": "codetripLiveCaller", "limit": 10,
+ "include_connected": True,
+ })
+ snippet, snippet_ms = call(client, "get_code_snippet", {
+ "project": project, "qualified_name": "codetripLiveCaller",
+ "include_neighbors": True,
+ })
+ return search + "\n" + snippet, round(search_ms+snippet_ms, 3), 2
+
+
+def classify(value: str) -> str:
+ old_source = "return codetripLiveTarget()" in value
+ new_source = "return codetripLiveTargetRenamed()" in value
+ old_graph = ('"callee_names": ["codetripLiveTarget"]' in value or
+ '"callee_names":["codetripLiveTarget"]' in value)
+ new_graph = ('"callee_names": ["codetripLiveTargetRenamed"]' in value or
+ '"callee_names":["codetripLiveTargetRenamed"]' in value)
+ if old_source and old_graph:
+ return "complete-old"
+ if new_source and new_graph and not old_source:
+ return "complete-new"
+ return "mixed"
+
+
+def make_client(binary: Path, cwd: Path, store: Path, stderr: Path) -> MCPClient:
+ env = os.environ.copy()
+ env["CBM_CACHE_DIR"] = str(store)
+ return MCPClient([str(binary)], cwd, stderr, env=env)
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--repo-source", type=Path, required=True)
+ parser.add_argument("--cbm-bin", type=Path, required=True)
+ parser.add_argument("--workspace", type=Path, required=True)
+ parser.add_argument("--run-id", default=dt.datetime.now(dt.UTC).strftime("%Y%m%dT%H%M%SZ"))
+ args = parser.parse_args()
+ result_dir = args.workspace.expanduser().resolve() / "results" / args.run_id
+ repository = result_dir / "repository"
+ clean_repository = result_dir / "clean-repository"
+ project = "gin-live-cbm"
+ if result_dir.exists():
+ raise ValueError(f"result directory already exists: {result_dir}")
+ result_dir.mkdir(parents=True)
+ shutil.copytree(args.repo_source.resolve(), repository, symlinks=True)
+ fixture = repository / "codetrip_live_benchmark.go"
+ fixture.write_text(INITIAL, encoding="utf-8")
+
+ client = make_client(args.cbm_bin.resolve(), repository, result_dir / "store", result_dir / "server.stderr")
+ try:
+ initialize(client)
+ initial_result, initial_index_ms = call(client, "index_repository", {
+ "repo_path": str(repository), "name": project, "mode": "fast",
+ })
+ before, before_ms, query_calls = context(client, project)
+ (result_dir / "initial-context.txt").write_text(before, encoding="utf-8")
+ if classify(before) != "complete-old":
+ raise AssertionError(f"invalid initial context: {before[:3000]}")
+
+ started = time.monotonic_ns()
+ fixture.write_text(MUTATED, encoding="utf-8")
+ automatic, automatic_ms, automatic_calls = context(client, project)
+ automatic_elapsed = round((time.monotonic_ns()-started)/1_000_000, 3)
+ automatic_state = classify(automatic)
+ (result_dir / "automatic-context.txt").write_text(automatic, encoding="utf-8")
+
+ refresh_result, refresh_ms = call(client, "index_repository", {
+ "repo_path": str(repository), "name": project, "mode": "fast",
+ })
+ refreshed, refreshed_ms, refreshed_calls = context(client, project)
+ best_elapsed = round((time.monotonic_ns()-started)/1_000_000, 3)
+ refreshed_state = classify(refreshed)
+ (result_dir / "refresh-result.txt").write_text(refresh_result, encoding="utf-8")
+ (result_dir / "refreshed-context.txt").write_text(refreshed, encoding="utf-8")
+ finally:
+ client.close()
+
+ shutil.copytree(repository, clean_repository, symlinks=True,
+ ignore=shutil.ignore_patterns(".codebase-memory"))
+ clean_client = make_client(args.cbm_bin.resolve(), clean_repository,
+ result_dir / "clean-store", result_dir / "clean.stderr")
+ try:
+ initialize(clean_client)
+ _, clean_index_ms = call(clean_client, "index_repository", {
+ "repo_path": str(clean_repository), "name": project, "mode": "fast",
+ })
+ clean_value, clean_query_ms, _ = context(clean_client, project)
+ finally:
+ clean_client.close()
+ (result_dir / "clean-full-context.txt").write_text(clean_value, encoding="utf-8")
+
+ equivalent = refreshed_state == classify(clean_value) == "complete-new"
+ report = {
+ "schemaVersion": 1, "repository": "gin", "arm": "cbm",
+ "mutation": "rename-target-and-update-call", "indexMode": "fast",
+ "initialIndexMs": initial_index_ms, "initialQueryMs": before_ms,
+ "automaticLane": {
+ "supported": automatic_state == "complete-new",
+ "editToContextMs": automatic_elapsed, "state": automatic_state,
+ "operations": automatic_calls,
+ },
+ "bestSupportedLane": {
+ "workflow": "index_repository + search_graph + get_code_snippet",
+ "editToAccurateContextMs": best_elapsed, "refreshMs": refresh_ms,
+ "queryMs": refreshed_ms, "state": refreshed_state,
+ "operations": 1 + refreshed_calls,
+ },
+ "cleanFullIndexMs": clean_index_ms, "cleanFullQueryMs": clean_query_ms,
+ "cleanFullEquivalent": equivalent,
+ "equivalenceLevel": "fixture-observable-source-and-call-target",
+ "initialIndexResult": initial_result[:1000],
+ }
+ (result_dir / "report.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
+ print(json.dumps(report, indent=2))
+ return 0 if equivalent else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/benchmarks/live_context/run_codegraph.py b/benchmarks/live_context/run_codegraph.py
new file mode 100644
index 0000000..4f07b11
--- /dev/null
+++ b/benchmarks/live_context/run_codegraph.py
@@ -0,0 +1,166 @@
+#!/usr/bin/env python3
+"""Measure CodeGraph's documented MCP auto-sync with the shared fixture."""
+
+from __future__ import annotations
+
+import argparse
+import datetime as dt
+import json
+import os
+import shutil
+import time
+from pathlib import Path
+from typing import Any
+
+from run import INITIAL, MUTATED, MCPClient, run
+
+
+def text_payload(result: dict[str, Any]) -> str:
+ return "\n".join(
+ block.get("text", "") for block in result.get("content", [])
+ if isinstance(block, dict) and isinstance(block.get("text"), str)
+ )
+
+
+def classify(value: str) -> str:
+ old_source = "return codetripLiveTarget()" in value
+ new_source = "return codetripLiveTargetRenamed()" in value
+ blast = value.split("**Source Code**", 1)[0]
+ old_edge = "`codetripLiveTarget`" in blast
+ new_edge = "`codetripLiveTargetRenamed`" in blast
+ if old_source and old_edge:
+ return "complete-old"
+ if new_source and new_edge:
+ return "complete-new"
+ return "mixed"
+
+
+def call_node(client: MCPClient, repository: Path) -> str:
+ result = client.send("tools/call", {
+ "name": "codegraph_explore",
+ "arguments": {"query": "codetripLiveCaller", "projectPath": str(repository)},
+ })
+ return text_payload(result)
+
+
+def initialize(client: MCPClient) -> None:
+ client.send("initialize", {
+ "protocolVersion": "2025-06-18", "capabilities": {},
+ "clientInfo": {"name": "codetrip-live-context", "version": "1"},
+ })
+ client.notify("notifications/initialized")
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--repo-source", type=Path, required=True)
+ parser.add_argument("--codegraph-bin", type=Path, required=True)
+ parser.add_argument("--workspace", type=Path, required=True)
+ parser.add_argument("--run-id", default=dt.datetime.now(dt.UTC).strftime("%Y%m%dT%H%M%SZ"))
+ args = parser.parse_args()
+ result_dir = args.workspace.expanduser().resolve() / "results" / args.run_id
+ repository = result_dir / "repository"
+ clean_repository = result_dir / "clean-repository"
+ if result_dir.exists():
+ raise ValueError(f"result directory already exists: {result_dir}")
+ result_dir.mkdir(parents=True)
+ shutil.copytree(args.repo_source.resolve(), repository, symlinks=True)
+ fixture = repository / "codetrip_live_benchmark.go"
+ fixture.write_text(INITIAL, encoding="utf-8")
+
+ initial_started = time.monotonic_ns()
+ initial = run([str(args.codegraph_bin), "init", "--no-color", str(repository)], repository)
+ initial_ms = round((time.monotonic_ns()-initial_started)/1_000_000, 3)
+ (result_dir / "initial-index.stdout").write_text(initial.stdout, encoding="utf-8")
+ (result_dir / "initial-index.stderr").write_text(initial.stderr, encoding="utf-8")
+
+ env = os.environ.copy()
+ env.update({
+ "CODEGRAPH_MCP_TOOLS": "explore",
+ "CODEGRAPH_WATCH_DEBOUNCE_MS": "100",
+ # Keep the benchmark server inside the runner's process group. The
+ # default shared daemon otherwise survives a killed/interrupted run.
+ "CODEGRAPH_NO_DAEMON": "1",
+ "NO_COLOR": "1",
+ })
+ client = MCPClient(
+ [str(args.codegraph_bin), "serve", "--mcp"], repository,
+ result_dir / "watch.stderr", env=env,
+ )
+ try:
+ initialize(client)
+ before = call_node(client, repository)
+ if classify(before) != "complete-old":
+ raise AssertionError(f"invalid initial response: {before[:2000]}")
+ started = time.monotonic_ns()
+ fixture.write_text(MUTATED, encoding="utf-8")
+ timeline: list[dict[str, Any]] = []
+ deadline = time.monotonic() + 30
+ while time.monotonic() < deadline:
+ call_started = time.monotonic_ns()
+ current = call_node(client, repository)
+ observed = time.monotonic_ns()
+ state = classify(current)
+ timeline.append({
+ "elapsedMs": round((observed-started)/1_000_000, 3),
+ "callMs": round((observed-call_started)/1_000_000, 3),
+ "state": state, "stalenessWarning": "may be stale" in current,
+ })
+ if state == "complete-new":
+ final_incremental = current
+ break
+ time.sleep(0.025)
+ else:
+ raise TimeoutError(f"fresh context not observed: {timeline}")
+ for _ in range(3):
+ call_started = time.monotonic_ns()
+ value = call_node(client, repository)
+ observed = time.monotonic_ns()
+ timeline.append({
+ "elapsedMs": round((observed-started)/1_000_000, 3),
+ "callMs": round((observed-call_started)/1_000_000, 3),
+ "state": classify(value), "phase": "post-publication",
+ "stalenessWarning": "may be stale" in value,
+ })
+ finally:
+ client.close()
+
+ shutil.copytree(repository, clean_repository, symlinks=True,
+ ignore=shutil.ignore_patterns(".codegraph"))
+ clean = run([str(args.codegraph_bin), "init", "--no-color", str(clean_repository)], clean_repository)
+ (result_dir / "clean-full-index.stdout").write_text(clean.stdout, encoding="utf-8")
+ clean_client = MCPClient(
+ [str(args.codegraph_bin), "serve", "--mcp"], clean_repository,
+ result_dir / "clean.stderr", env=env,
+ )
+ try:
+ initialize(clean_client)
+ clean_value = call_node(clean_client, clean_repository)
+ finally:
+ clean_client.close()
+
+ first_new = next(row for row in timeline if row["state"] == "complete-new")
+ post = [row for row in timeline if row.get("phase") == "post-publication"]
+ report = {
+ "schemaVersion": 1, "repository": "gin", "arm": "codegraph",
+ "mutation": "rename-target-and-update-call", "initialIndexMs": initial_ms,
+ "watchDebounceMs": 100, "timeline": timeline,
+ "editToAccurateContextMs": first_new["elapsedMs"],
+ "operationsToAccurateContext": timeline.index(first_new) + 1,
+ "firstQueryCallMs": timeline[0]["callMs"],
+ "mixedRevisionObservations": sum(r["state"] == "mixed" for r in timeline),
+ "oldRevisionObservations": sum(r["state"] == "complete-old" for r in timeline),
+ "staleAfterPublication": sum(r["state"] != "complete-new" for r in post),
+ "stalenessWarningObservations": sum(bool(r.get("stalenessWarning")) for r in timeline),
+ "cleanFullEquivalent": classify(final_incremental) == classify(clean_value) == "complete-new",
+ "equivalenceLevel": "fixture-observable-source-and-call-target",
+ }
+ (result_dir / "incremental-response.txt").write_text(final_incremental, encoding="utf-8")
+ (result_dir / "clean-full-response.txt").write_text(clean_value, encoding="utf-8")
+ (result_dir / "report.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
+ print(json.dumps(report, indent=2))
+ return 0 if report["cleanFullEquivalent"] and not report["staleAfterPublication"] else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/benchmarks/live_context/run_codetrip_matrix.py b/benchmarks/live_context/run_codetrip_matrix.py
new file mode 100644
index 0000000..c4e3cc9
--- /dev/null
+++ b/benchmarks/live_context/run_codetrip_matrix.py
@@ -0,0 +1,55 @@
+#!/usr/bin/env python3
+"""Run repeated Codetrip live-context gates without competitor processes."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import math
+import subprocess
+import sys
+from pathlib import Path
+from statistics import median
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--repo-source", type=Path, required=True)
+ parser.add_argument("--codetrip-bin", type=Path, required=True)
+ parser.add_argument("--workspace", type=Path, required=True)
+ parser.add_argument("--run-id", required=True)
+ parser.add_argument("--rounds", type=int, default=5)
+ args = parser.parse_args()
+ reports = []
+ runner = Path(__file__).resolve().parent / "run.py"
+ for number in range(1, args.rounds + 1):
+ child_id = f"{args.run_id}-r{number}"
+ subprocess.run([
+ sys.executable, "-B", str(runner),
+ "--repo-source", str(args.repo_source),
+ "--codetrip-bin", str(args.codetrip_bin),
+ "--workspace", str(args.workspace), "--run-id", child_id,
+ ], check=True, stdout=subprocess.DEVNULL, timeout=180)
+ path = args.workspace / "results" / child_id / "report.json"
+ reports.append(json.loads(path.read_text(encoding="utf-8")))
+ latencies = sorted(row["editToAccurateContextMs"] for row in reports)
+ engines = sorted(row["refresh"]["engineSeconds"] * 1000 for row in reports)
+ summary = {
+ "schemaVersion": 1, "runId": args.run_id, "rounds": args.rounds,
+ "editToAccurateContextP50Ms": round(median(latencies), 3),
+ "editToAccurateContextP95Ms": round(latencies[math.ceil(0.95*len(latencies))-1], 3),
+ "engineRefreshP50Ms": round(median(engines), 3),
+ "operationsToAccurateContextP50": median(row["operationsToAccurateContext"] for row in reports),
+ "mixedRevisionObservations": sum(row["mixedRevisionObservations"] for row in reports),
+ "staleAfterPublication": sum(row["staleAfterPublication"] for row in reports),
+ "cleanFullEquivalentRate": sum(bool(row["cleanFullEquivalent"]) for row in reports) / len(reports),
+ "reportPaths": [f"results/{args.run_id}-r{i}/report.json" for i in range(1, args.rounds+1)],
+ }
+ output = args.workspace / f"summary-{args.run_id}.json"
+ output.write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8")
+ print(json.dumps(summary, indent=2))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/benchmarks/live_context/run_matrix.py b/benchmarks/live_context/run_matrix.py
new file mode 100644
index 0000000..1efe6af
--- /dev/null
+++ b/benchmarks/live_context/run_matrix.py
@@ -0,0 +1,86 @@
+#!/usr/bin/env python3
+"""Run the no-LLM live-context comparison repeatedly and summarize it."""
+
+from __future__ import annotations
+
+import argparse
+import datetime as dt
+import json
+import math
+import subprocess
+import sys
+from pathlib import Path
+from statistics import median
+
+
+def percentile(values: list[float], p: float) -> float:
+ ordered = sorted(values)
+ return ordered[max(0, math.ceil(p * len(ordered)) - 1)]
+
+
+def metric(report: dict) -> tuple[float, int, int]:
+ if report["arm"] == "cbm":
+ lane = report["bestSupportedLane"]
+ return lane["editToAccurateContextMs"], lane["operations"], 1
+ return (
+ report["editToAccurateContextMs"],
+ report.get("operationsToAccurateContext", 1),
+ report["mixedRevisionObservations"],
+ )
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--repo-source", type=Path, required=True)
+ parser.add_argument("--codetrip-bin", type=Path, required=True)
+ parser.add_argument("--codegraph-bin", type=Path, required=True)
+ parser.add_argument("--cbm-bin", type=Path, required=True)
+ parser.add_argument("--workspace", type=Path, required=True)
+ parser.add_argument("--rounds", type=int, default=5)
+ parser.add_argument("--run-id", default=dt.datetime.now(dt.UTC).strftime("%Y%m%dT%H%M%SZ"))
+ args = parser.parse_args()
+ if args.rounds < 3:
+ raise ValueError("at least 3 rounds are required")
+ scripts = Path(__file__).resolve().parent
+ arms = {
+ "codetrip": ("run.py", "--codetrip-bin", args.codetrip_bin),
+ "codegraph": ("run_codegraph.py", "--codegraph-bin", args.codegraph_bin),
+ "cbm": ("run_cbm.py", "--cbm-bin", args.cbm_bin),
+ }
+ reports: dict[str, list[dict]] = {arm: [] for arm in arms}
+ arm_names = list(arms)
+ for round_number in range(1, args.rounds + 1):
+ # Rotate execution order so a product does not always receive the
+ # warmest or cleanest machine state.
+ order = arm_names[(round_number-1) % len(arm_names):] + arm_names[:(round_number-1) % len(arm_names)]
+ for arm in order:
+ script, binary_flag, binary = arms[arm]
+ child_id = f"{args.run_id}-{arm}-r{round_number}"
+ subprocess.run([
+ sys.executable, "-B", str(scripts / script),
+ "--repo-source", str(args.repo_source), binary_flag, str(binary),
+ "--workspace", str(args.workspace), "--run-id", child_id,
+ ], check=True, stdout=subprocess.DEVNULL, timeout=180)
+ report_path = args.workspace / "results" / child_id / "report.json"
+ reports[arm].append(json.loads(report_path.read_text(encoding="utf-8")))
+
+ summary = {"schemaVersion": 1, "runId": args.run_id, "rounds": args.rounds, "arms": {}}
+ for arm, rows in reports.items():
+ values = [metric(row) for row in rows]
+ latencies = [value[0] for value in values]
+ summary["arms"][arm] = {
+ "editToAccurateContextP50Ms": round(median(latencies), 3),
+ "editToAccurateContextP95Ms": round(percentile(latencies, 0.95), 3),
+ "operationsToAccurateContextP50": median(value[1] for value in values),
+ "mixedRevisionObservations": sum(value[2] for value in values),
+ "cleanFullEquivalentRate": sum(bool(row["cleanFullEquivalent"]) for row in rows) / len(rows),
+ "reportPaths": [f"results/{args.run_id}-{arm}-r{i}/report.json" for i in range(1, args.rounds+1)],
+ }
+ output = args.workspace / f"summary-{args.run_id}.json"
+ output.write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8")
+ print(json.dumps(summary, indent=2))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/benchmarks/live_context/test_run.py b/benchmarks/live_context/test_run.py
new file mode 100644
index 0000000..97e84e8
--- /dev/null
+++ b/benchmarks/live_context/test_run.py
@@ -0,0 +1,26 @@
+from run import canonical_brief, classify_brief
+from run_cbm import classify as classify_cbm
+from run_codegraph import classify as classify_codegraph
+
+
+def brief(source, target):
+ return {"symbol": {"name": "codetripLiveCaller"}, "source": source, "incoming": [], "outgoing": [
+ {"type": "CALLS", "node": {"name": target}},
+ ]}
+
+
+def test_classifies_complete_revisions_and_mixed_state():
+ old = brief("return codetripLiveTarget()", "codetripLiveTarget")
+ new = brief("return codetripLiveTargetRenamed()", "codetripLiveTargetRenamed")
+ mixed = brief("return codetripLiveTargetRenamed()", "codetripLiveTarget")
+ assert classify_brief(old) == "complete-old"
+ assert classify_brief(new) == "complete-new"
+ assert classify_brief(mixed) == "mixed"
+ assert canonical_brief(new)["outgoing"] == [("CALLS", "codetripLiveTargetRenamed")]
+
+
+def test_competitor_classifiers_reject_new_source_with_old_graph():
+ codegraph = """- `codetripLiveTarget` (x.go:3)\n**Source Code**\nreturn codetripLiveTargetRenamed()"""
+ cbm = '{"source":"return codetripLiveTargetRenamed()","callee_names":["codetripLiveTarget"]}'
+ assert classify_codegraph(codegraph) == "mixed"
+ assert classify_cbm(cbm) == "mixed"
diff --git a/body_delta.go b/body_delta.go
new file mode 100644
index 0000000..25c4513
--- /dev/null
+++ b/body_delta.go
@@ -0,0 +1,94 @@
+package codetrip
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "reflect"
+
+ "github.com/mengshi02/codetrip/internal/graph"
+ inc "github.com/mengshi02/codetrip/internal/incremental"
+ "github.com/mengshi02/codetrip/internal/ingest"
+ model "github.com/mengshi02/codetrip/internal/model"
+)
+
+func isLocalBodyOnlyChange(changes inc.ChangeSet) bool {
+ if len(changes.Modified) == 0 || len(changes.Added) != 0 || len(changes.Deleted) != 0 || len(changes.Renamed) != 0 {
+ return false
+ }
+ for _, modification := range changes.Modified {
+ if modification.Before.StructureHash == "" || modification.Before.CallHash == "" ||
+ modification.Before.StructureHash != modification.After.StructureHash ||
+ modification.Before.CallHash != modification.After.CallHash {
+ return false
+ }
+ }
+ return true
+}
+
+func buildLocalBodyDelta(store *graph.GraphStore, changes inc.ChangeSet, repoPath string) (*inc.Delta, bool, error) {
+ delta := &inc.Delta{
+ BaseRevision: changes.BaseRevision, Revision: changes.BaseRevision + 1,
+ PutSymbols: make(map[string][]byte), PutSources: make(map[string][]byte),
+ }
+ registry := ingest.NewLanguageRegistry()
+ for _, modification := range changes.Modified {
+ path := filepath.ToSlash(modification.After.Path)
+ content, err := os.ReadFile(filepath.Join(repoPath, filepath.FromSlash(path)))
+ if err != nil {
+ return nil, false, fmt.Errorf("read changed source %s: %w", path, err)
+ }
+ oldNodes, err := store.GetNodesByFile(store.Repo(), path)
+ if err != nil {
+ return nil, false, err
+ }
+ oldByID := make(map[string]*graph.Node, len(oldNodes))
+ for _, node := range oldNodes {
+ oldByID[node.ID] = node
+ }
+ parsed := model.NewKnowledgeGraph()
+ ingest.ProcessParsing(parsed, []ingest.FileInput{{Path: path, Content: string(content)}}, ingest.NewSymbolTable(), registry, nil)
+ for _, source := range parsed.Nodes() {
+ old := oldByID[source.ID]
+ if old == nil {
+ return nil, false, nil
+ }
+ updated := *old
+ candidate := graph.NodeFromModel(store.Repo(), source)
+ updated.Props.StartLine = candidate.Props.StartLine
+ updated.Props.EndLine = candidate.Props.EndLine
+ updated.Props.Line = candidate.Props.Line
+ if reflect.DeepEqual(old, &updated) {
+ continue
+ }
+ payload, err := graph.Encode(&updated)
+ if err != nil {
+ return nil, false, err
+ }
+ delta.PutNodes = append(delta.PutNodes, inc.NodeDelta{ID: updated.ID, Payload: payload})
+ if updated.Label.IsSymbol() {
+ delta.PutSymbols[updated.ID] = payload
+ invalidateNodeVectors(delta, updated.ID)
+ }
+ }
+ for _, node := range oldNodes {
+ if node.Label != graph.LabelFile {
+ continue
+ }
+ updated := *node
+ updated.Props.SetProp("fileSize", int64(len(content)))
+ if reflect.DeepEqual(node, &updated) {
+ break
+ }
+ payload, err := graph.Encode(&updated)
+ if err != nil {
+ return nil, false, err
+ }
+ delta.PutNodes = append(delta.PutNodes, inc.NodeDelta{ID: updated.ID, Payload: payload})
+ break
+ }
+ delta.PutSources[path] = content
+ }
+ sortIncrementalDelta(delta)
+ return delta, true, nil
+}
diff --git a/brief.go b/brief.go
new file mode 100644
index 0000000..cf7d75c
--- /dev/null
+++ b/brief.go
@@ -0,0 +1,859 @@
+package codetrip
+
+import (
+ "context"
+ "fmt"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ "github.com/mengshi02/codetrip/internal/graph"
+)
+
+const (
+ defaultBriefRelations = 30
+ maxBriefRelations = 200
+ defaultBriefLines = 40
+ maxBriefLines = 200
+ defaultBriefSymbols = 5
+ maxBriefSymbols = 8
+)
+
+// BriefRequest asks Codetrip for a compact, deterministic context pack for one
+// symbol. Symbol accepts either an exact symbol name or a persisted node ID.
+type BriefRequest struct {
+ Repo string `json:"repo"`
+ Symbol string `json:"symbol,omitempty" jsonschema:"exact symbol name or persisted node ID; mutually exclusive with query"`
+ Query string `json:"query,omitempty" jsonschema:"natural-language architecture question; mutually exclusive with symbol"`
+ MaxSymbols int `json:"maxSymbols,omitempty" jsonschema:"maximum production-code seed symbols for query mode; omit for the compact default of 5"`
+ MaxRelations int `json:"maxRelations,omitempty"`
+ SourceLines int `json:"sourceLines,omitempty"`
+}
+
+// BriefCandidate identifies one exact symbol a caller may select after an
+// ambiguous name lookup.
+type BriefCandidate struct {
+ NodeID string `json:"nodeId"`
+ Label string `json:"label"`
+ FilePath string `json:"filePath"`
+ StartLine int `json:"startLine"`
+}
+
+// BriefAmbiguityError preserves machine-readable candidates for library and
+// protocol adapters while retaining a useful command-line error message.
+type BriefAmbiguityError struct {
+ Symbol string `json:"symbol"`
+ Candidates []BriefCandidate `json:"candidates"`
+}
+
+func (e *BriefAmbiguityError) Error() string {
+ var message strings.Builder
+ fmt.Fprintf(&message, "%q is ambiguous; retry brief with one of these node IDs:", e.Symbol)
+ for _, candidate := range e.Candidates {
+ fmt.Fprintf(&message, "\n %s (%s %s:%d)",
+ candidate.NodeID, candidate.Label, candidate.FilePath, candidate.StartLine)
+ }
+ return message.String()
+}
+
+// Unwrap makes ambiguity errors compatible with errors.Is(err,
+// ErrInvalidRequest).
+func (e *BriefAmbiguityError) Unwrap() error {
+ return ErrInvalidRequest
+}
+
+// BriefRelation is one ranked, evidence-backed semantic relationship.
+type BriefRelation struct {
+ Direction string `json:"direction"`
+ Type string `json:"type"`
+ Confidence float64 `json:"confidence"`
+ Reason string `json:"reason"`
+ Node GraphNode `json:"node"`
+}
+
+// BriefTest is a test location related to the selected symbol.
+type BriefTest struct {
+ FilePath string `json:"filePath"`
+ Line int `json:"line,omitempty"`
+ NodeID string `json:"nodeId,omitempty"`
+ Name string `json:"name,omitempty"`
+ Confidence float64 `json:"confidence"`
+ Reason string `json:"reason"`
+}
+
+// BriefImpactSummary summarizes the direct reverse semantic neighborhood.
+type BriefImpactSummary struct {
+ DirectDependents int `json:"directDependents"`
+ AffectedFiles int `json:"affectedFiles"`
+ Callers int `json:"callers"`
+ Implementations int `json:"implementations"`
+ Entrypoints int `json:"entrypoints"`
+}
+
+// BriefEvidence provides bounded source for a high-value neighboring symbol so
+// an Agent can verify a relationship without a follow-up source read.
+type BriefEvidence struct {
+ Direction string `json:"direction"`
+ Type string `json:"type"`
+ Node GraphNode `json:"node"`
+ Signature string `json:"signature,omitempty"`
+ Source string `json:"source,omitempty"`
+}
+
+// BriefContext is one selected symbol and its bounded semantic evidence.
+type BriefContext struct {
+ Symbol GraphNode `json:"symbol"`
+ Signature string `json:"signature,omitempty"`
+ Source string `json:"source,omitempty"`
+ Incoming []BriefRelation `json:"incoming"`
+ Outgoing []BriefRelation `json:"outgoing"`
+ Contracts []BriefRelation `json:"contracts"`
+ Implementations []BriefRelation `json:"implementations"`
+ Entrypoints []BriefRelation `json:"entrypoints"`
+ RelatedTests []BriefTest `json:"relatedTests"`
+ Evidence []BriefEvidence `json:"evidence,omitempty"`
+ Impact BriefImpactSummary `json:"impact"`
+ Truncated bool `json:"truncated,omitempty"`
+ Truncation []string `json:"truncation,omitempty"`
+}
+
+// BriefResult is a deterministic, budgeted Agent Context Pack. Symbol mode
+// populates the embedded BriefContext; query mode populates Contexts.
+type BriefResult struct {
+ Query string `json:"query,omitempty"`
+ Contexts []BriefContext `json:"contexts,omitempty"`
+ BriefContext
+}
+
+type briefCandidate struct {
+ direction string
+ edge *graph.Edge
+ node *graph.Node
+ group string
+ priority int
+}
+
+// Brief returns the smallest bounded context Codetrip can assemble from the
+// persisted graph and source indexes without invoking an LLM.
+func (e *Engine) Brief(ctx context.Context, request *BriefRequest) (*BriefResult, error) {
+ if request == nil || strings.TrimSpace(request.Repo) == "" {
+ return nil, ErrInvalidRequest
+ }
+ symbol, query := strings.TrimSpace(request.Symbol), strings.TrimSpace(request.Query)
+ if (symbol == "") == (query == "") {
+ return nil, fmt.Errorf("%w: provide exactly one of symbol or query", ErrInvalidRequest)
+ }
+ maxRelations := request.MaxRelations
+ if maxRelations <= 0 {
+ maxRelations = defaultBriefRelations
+ }
+ if maxRelations > maxBriefRelations {
+ return nil, fmt.Errorf("%w: maxRelations must not exceed %d", ErrInvalidRequest, maxBriefRelations)
+ }
+ sourceLines := request.SourceLines
+ if sourceLines <= 0 {
+ sourceLines = defaultBriefLines
+ }
+ if sourceLines > maxBriefLines {
+ return nil, fmt.Errorf("%w: sourceLines must not exceed %d", ErrInvalidRequest, maxBriefLines)
+ }
+ // Preserve the actual store-open error. Falling through to Search would
+ // otherwise collapse lock and permission failures into "not indexed".
+ if err := e.ensureRepositoryOpen(request.Repo); err != nil {
+ return nil, err
+ }
+ if query != "" {
+ return e.buildQueryBrief(ctx, request, query, maxRelations, sourceLines)
+ }
+
+ nodeID, err := e.resolveBriefSymbol(ctx, request.Repo, symbol)
+ if err != nil {
+ return nil, err
+ }
+ result, err := e.buildBrief(ctx, request.Repo, nodeID, maxRelations, sourceLines)
+ if err != nil {
+ return nil, err
+ }
+ e.finalizeBrief(ctx, request.Repo, result, true)
+ return result, nil
+}
+
+func (e *Engine) finalizeBrief(ctx context.Context, repo string, result *BriefResult, textualTests bool) {
+ if textualTests {
+ e.addBriefTextualTests(ctx, repo, result)
+ }
+ sort.SliceStable(result.RelatedTests, func(i, j int) bool {
+ if result.RelatedTests[i].Confidence != result.RelatedTests[j].Confidence {
+ return result.RelatedTests[i].Confidence > result.RelatedTests[j].Confidence
+ }
+ if result.RelatedTests[i].FilePath != result.RelatedTests[j].FilePath {
+ return result.RelatedTests[i].FilePath < result.RelatedTests[j].FilePath
+ }
+ return result.RelatedTests[i].Line < result.RelatedTests[j].Line
+ })
+ if len(result.RelatedTests) > 10 {
+ result.RelatedTests = result.RelatedTests[:10]
+ result.Truncated = true
+ result.Truncation = appendUniqueString(result.Truncation, "relatedTests exceeded 10 results")
+ }
+}
+
+func (e *Engine) buildQueryBrief(ctx context.Context, request *BriefRequest, query string, maxRelations, sourceLines int) (*BriefResult, error) {
+ maxSymbols := request.MaxSymbols
+ if maxSymbols <= 0 {
+ maxSymbols = defaultBriefSymbols
+ }
+ if maxSymbols > maxBriefSymbols {
+ return nil, fmt.Errorf("%w: maxSymbols must not exceed %d", ErrInvalidRequest, maxBriefSymbols)
+ }
+ seeds, err := e.discoverBriefSeeds(ctx, request.Repo, query, maxSymbols)
+ if err != nil {
+ return nil, err
+ }
+ if len(seeds) == 0 {
+ return nil, fmt.Errorf("%w: %s", ErrSymbolNotFound, query)
+ }
+ result := &BriefResult{Query: query, Contexts: make([]BriefContext, 0, len(seeds))}
+ perContextRelations := maxRelations / len(seeds)
+ if perContextRelations < 4 {
+ perContextRelations = 4
+ }
+ seenNodes := make(map[string]struct{})
+ seenEvidence := make(map[string]struct{})
+ for _, seed := range seeds {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ contextPack, buildErr := e.buildBrief(ctx, request.Repo, seed.NodeID, perContextRelations, sourceLines)
+ if buildErr != nil {
+ continue
+ }
+ pruneDuplicateBriefRelations(contextPack, seenNodes)
+ e.addBriefRelationEvidence(request.Repo, contextPack, query, seenEvidence, 2, min(sourceLines, 30))
+ e.finalizeBrief(ctx, request.Repo, contextPack, false)
+ result.Contexts = append(result.Contexts, contextPack.BriefContext)
+ }
+ if len(result.Contexts) == 0 {
+ return nil, fmt.Errorf("%w: %s", ErrSymbolNotFound, query)
+ }
+ return result, nil
+}
+
+func (e *Engine) discoverBriefSeeds(ctx context.Context, repo, query string, limit int) ([]SearchItem, error) {
+ type rankedSeed struct {
+ item SearchItem
+ score float64
+ }
+ ranked := make(map[string]*rankedSeed)
+ terms := briefQueryTerms(query)
+ graphStore := e.graphStore(repo)
+ for _, term := range terms {
+ if strings.EqualFold(term, repo) {
+ continue
+ }
+ matches, err := e.Search(ctx, &SearchRequest{Repo: repo, Query: term, Limit: 100})
+ if err != nil {
+ return nil, err
+ }
+ var best *rankedSeed
+ for position, item := range matches.Results {
+ if isTestPath(item.FilePath) || !isBriefSeedLabel(item.Label) {
+ continue
+ }
+ score := 1.0 / float64(10+position)
+ name := strings.ToLower(item.Name)
+ if strings.Contains(name, term) {
+ score += 0.15
+ }
+ if strings.HasPrefix(name, "no") || strings.HasPrefix(name, "fake") || name == "new" || name == "error" {
+ score -= 0.20
+ }
+ if strings.HasPrefix(name, "has") || strings.HasPrefix(name, "is") {
+ score -= 0.10
+ }
+ if graphStore != nil {
+ node, nodeErr := graphStore.GetNode(item.NodeID)
+ if nodeErr == nil {
+ neighbors, neighborErr := collectBriefCandidates(graphStore, node)
+ if neighborErr == nil {
+ degree := len(neighbors)
+ if degree > 10 {
+ degree = 10
+ }
+ score += float64(degree) * 0.015
+ }
+ }
+ }
+ if best == nil || score > best.score || (score == best.score && item.NodeID < best.item.NodeID) {
+ best = &rankedSeed{item: item, score: score}
+ }
+ }
+ if best != nil {
+ candidate := ranked[best.item.NodeID]
+ if candidate == nil {
+ candidate = &rankedSeed{item: best.item}
+ ranked[best.item.NodeID] = candidate
+ }
+ candidate.score += 1 + best.score
+ }
+ }
+ ordered := make([]rankedSeed, 0, len(ranked))
+ for _, candidate := range ranked {
+ ordered = append(ordered, *candidate)
+ }
+ sort.SliceStable(ordered, func(i, j int) bool {
+ if ordered[i].score != ordered[j].score {
+ return ordered[i].score > ordered[j].score
+ }
+ return ordered[i].item.NodeID < ordered[j].item.NodeID
+ })
+ selected := make([]SearchItem, 0, limit)
+ seenNames := make(map[string]struct{})
+ for _, candidate := range ordered {
+ name := strings.ToLower(candidate.item.Name)
+ if _, duplicate := seenNames[name]; duplicate {
+ continue
+ }
+ seenNames[name] = struct{}{}
+ selected = append(selected, candidate.item)
+ if len(selected) == limit {
+ break
+ }
+ }
+ return selected, nil
+}
+
+func briefQueryTerms(query string) []string {
+ stop := map[string]struct{}{
+ "about": {}, "does": {}, "from": {}, "have": {}, "into": {}, "through": {},
+ "what": {}, "when": {}, "where": {}, "which": {}, "with": {}, "this": {},
+ "that": {}, "the": {}, "and": {}, "for": {}, "how": {}, "its": {},
+ "include": {}, "including": {}, "incoming": {}, "semantics": {}, "explain": {},
+ "engine": {}, "context": {}, "group": {}, "handler": {}, "handlers": {},
+ "matching": {}, "registration": {}, "combination": {}, "method": {},
+ "error": {}, "errors": {},
+ }
+ seen := make(map[string]struct{})
+ terms := make([]string, 0, 8)
+ for _, term := range strings.FieldsFunc(strings.ToLower(query), func(r rune) bool {
+ return (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '_'
+ }) {
+ if len(term) < 3 {
+ continue
+ }
+ if _, ignored := stop[term]; ignored {
+ continue
+ }
+ if len(term) > 4 && strings.HasSuffix(term, "s") && !strings.HasSuffix(term, "ss") {
+ term = strings.TrimSuffix(term, "s")
+ }
+ if _, duplicate := seen[term]; duplicate {
+ continue
+ }
+ seen[term] = struct{}{}
+ terms = append(terms, term)
+ if len(terms) == 20 {
+ break
+ }
+ }
+ return terms
+}
+
+func isBriefSeedLabel(label string) bool {
+ switch graph.Label(label) {
+ case graph.LabelFunction, graph.LabelMethod, graph.LabelConstructor:
+ return true
+ }
+ return false
+}
+
+func pruneDuplicateBriefRelations(result *BriefResult, seen map[string]struct{}) {
+ if result == nil {
+ return
+ }
+ seen[result.Symbol.ID] = struct{}{}
+ prune := func(relations []BriefRelation) []BriefRelation {
+ kept := relations[:0]
+ for _, relation := range relations {
+ if _, duplicate := seen[relation.Node.ID]; duplicate {
+ continue
+ }
+ seen[relation.Node.ID] = struct{}{}
+ kept = append(kept, relation)
+ }
+ return kept
+ }
+ result.Incoming = prune(result.Incoming)
+ result.Outgoing = prune(result.Outgoing)
+ result.Contracts = prune(result.Contracts)
+ result.Implementations = prune(result.Implementations)
+ result.Entrypoints = prune(result.Entrypoints)
+}
+
+func (e *Engine) addBriefRelationEvidence(repo string, result *BriefResult, query string, seen map[string]struct{}, limit, sourceLines int) {
+ if result == nil || limit <= 0 {
+ return
+ }
+ type evidenceCandidate struct {
+ relation BriefRelation
+ score float64
+ }
+ all := make([]BriefRelation, 0, len(result.Incoming)+len(result.Outgoing)+len(result.Entrypoints))
+ all = append(all, result.Incoming...)
+ all = append(all, result.Outgoing...)
+ all = append(all, result.Entrypoints...)
+ terms := briefQueryTerms(query)
+ graphStore := e.graphStore(repo)
+ if graphStore == nil {
+ return
+ }
+ candidates := make([]evidenceCandidate, 0, len(all))
+ for _, relation := range all {
+ if relation.Node.ID == "" || relation.Node.ID == result.Symbol.ID {
+ continue
+ }
+ if _, duplicate := seen[relation.Node.ID]; duplicate {
+ continue
+ }
+ name := strings.ToLower(relation.Node.Name)
+ score := relation.Confidence
+ for _, term := range terms {
+ if strings.Contains(name, term) || strings.Contains(term, name) {
+ score += 0.5
+ }
+ }
+ if strings.HasPrefix(name, "rebuild") || strings.HasPrefix(name, "no") || name == "error" {
+ score -= 0.4
+ }
+ if graphStore != nil {
+ node, err := graphStore.GetNode(relation.Node.ID)
+ if err == nil {
+ neighbors, neighborErr := collectBriefCandidates(graphStore, node)
+ if neighborErr == nil {
+ degree := len(neighbors)
+ if degree > 8 {
+ degree = 8
+ }
+ score += float64(degree) * 0.05
+ }
+ }
+ }
+ candidates = append(candidates, evidenceCandidate{relation: relation, score: score})
+ }
+ sort.SliceStable(candidates, func(i, j int) bool {
+ if candidates[i].score != candidates[j].score {
+ return candidates[i].score > candidates[j].score
+ }
+ return candidates[i].relation.Node.ID < candidates[j].relation.Node.ID
+ })
+ for _, candidate := range candidates {
+ if len(result.Evidence) == limit {
+ break
+ }
+ node, err := graphStore.GetNode(candidate.relation.Node.ID)
+ if err != nil {
+ continue
+ }
+ content := node.GetPropString("content")
+ if content == "" {
+ content = e.readSymbolContent(repo, node)
+ }
+ content, _ = limitSourceLines(content, sourceLines)
+ if strings.TrimSpace(content) == "" {
+ continue
+ }
+ seen[node.ID] = struct{}{}
+ result.Evidence = append(result.Evidence, BriefEvidence{
+ Direction: candidate.relation.Direction, Type: candidate.relation.Type,
+ Node: candidate.relation.Node, Signature: briefSignature(node, content), Source: content,
+ })
+ }
+}
+
+func (e *Engine) resolveBriefSymbol(ctx context.Context, repo, symbol string) (string, error) {
+ symbol = strings.TrimSpace(symbol)
+ if strings.Contains(symbol, ":") {
+ return symbol, nil
+ }
+ matches, err := e.Search(ctx, &SearchRequest{Repo: repo, Query: symbol, Limit: 100})
+ if err != nil {
+ return "", err
+ }
+ exact := make([]SearchItem, 0, len(matches.Results))
+ for _, match := range matches.Results {
+ if match.Name == symbol {
+ exact = append(exact, match)
+ }
+ }
+ if len(exact) == 1 {
+ return exact[0].NodeID, nil
+ }
+ if len(exact) == 0 {
+ return "", fmt.Errorf("%w: %s", ErrSymbolNotFound, symbol)
+ }
+ sort.Slice(exact, func(i, j int) bool {
+ if exact[i].FilePath != exact[j].FilePath {
+ return exact[i].FilePath < exact[j].FilePath
+ }
+ if exact[i].StartLine != exact[j].StartLine {
+ return exact[i].StartLine < exact[j].StartLine
+ }
+ return exact[i].NodeID < exact[j].NodeID
+ })
+ candidates := make([]BriefCandidate, 0, len(exact))
+ for _, match := range exact {
+ candidates = append(candidates, BriefCandidate{
+ NodeID: match.NodeID, Label: match.Label,
+ FilePath: match.FilePath, StartLine: match.StartLine,
+ })
+ }
+ return "", &BriefAmbiguityError{Symbol: symbol, Candidates: candidates}
+}
+
+func (e *Engine) buildBrief(ctx context.Context, repo, nodeID string, maxRelations, sourceLines int) (*BriefResult, error) {
+ operation := e.repositoryOperationLock(repo)
+ operation.RLock()
+ defer operation.RUnlock()
+ graphStore := e.graphStore(repo)
+ if graphStore == nil {
+ return nil, fmt.Errorf("%w: %s", ErrRepoNotFound, repo)
+ }
+ symbol, err := graphStore.GetNode(nodeID)
+ if err != nil {
+ return nil, fmt.Errorf("%w: %s", ErrSymbolNotFound, nodeID)
+ }
+
+ content := symbol.GetPropString("content")
+ if content == "" {
+ content = e.readSymbolContent(repo, symbol)
+ }
+ content, sourceTruncated := limitSourceLines(content, sourceLines)
+ result := &BriefResult{
+ BriefContext: BriefContext{
+ Symbol: publicGraphNode(symbol),
+ Signature: briefSignature(symbol, content),
+ Source: content,
+ Incoming: []BriefRelation{},
+ Outgoing: []BriefRelation{},
+ Contracts: []BriefRelation{},
+ Implementations: []BriefRelation{},
+ Entrypoints: []BriefRelation{},
+ RelatedTests: []BriefTest{},
+ },
+ }
+ if sourceTruncated {
+ result.Truncated = true
+ result.Truncation = append(result.Truncation, fmt.Sprintf("source exceeded %d lines", sourceLines))
+ }
+
+ candidates, err := collectBriefCandidates(graphStore, symbol)
+ if err != nil {
+ return nil, err
+ }
+ sort.SliceStable(candidates, func(i, j int) bool {
+ if candidates[i].priority != candidates[j].priority {
+ return candidates[i].priority < candidates[j].priority
+ }
+ leftConfidence, rightConfidence := candidates[i].edge.Confidence(), candidates[j].edge.Confidence()
+ if leftConfidence != rightConfidence {
+ return leftConfidence > rightConfidence
+ }
+ if candidates[i].edge.Type != candidates[j].edge.Type {
+ return candidates[i].edge.Type < candidates[j].edge.Type
+ }
+ if candidates[i].node.FilePath != candidates[j].node.FilePath {
+ return candidates[i].node.FilePath < candidates[j].node.FilePath
+ }
+ return candidates[i].node.ID < candidates[j].node.ID
+ })
+
+ seen := make(map[string]struct{}, len(candidates))
+ files := make(map[string]struct{})
+ relationCount := 0
+ for _, candidate := range candidates {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ key := candidate.direction + "\x00" + candidate.edge.ID
+ if _, ok := seen[key]; ok {
+ continue
+ }
+ seen[key] = struct{}{}
+ if isTestPath(candidate.node.FilePath) {
+ result.RelatedTests = append(result.RelatedTests, BriefTest{
+ FilePath: candidate.node.FilePath, Line: candidate.node.GetPropInt("startLine"),
+ NodeID: candidate.node.ID, Name: candidate.node.Name,
+ Confidence: candidate.edge.Confidence(), Reason: "direct-" + strings.ToLower(string(candidate.edge.Type)),
+ })
+ continue
+ }
+ if relationCount >= maxRelations {
+ result.Truncated = true
+ result.Truncation = appendUniqueString(result.Truncation, fmt.Sprintf("relationships exceeded %d results", maxRelations))
+ break
+ }
+ relationCount++
+ evidence := publicBriefGraphEdge(candidate.edge)
+ relation := BriefRelation{
+ Direction: candidate.direction, Type: evidence.Type,
+ Confidence: evidence.Confidence, Reason: evidence.Reason,
+ Node: publicGraphNode(candidate.node),
+ }
+ switch candidate.group {
+ case "contracts":
+ result.Contracts = append(result.Contracts, relation)
+ case "implementations":
+ result.Implementations = append(result.Implementations, relation)
+ case "entrypoints":
+ result.Entrypoints = append(result.Entrypoints, relation)
+ case "incoming":
+ result.Incoming = append(result.Incoming, relation)
+ default:
+ result.Outgoing = append(result.Outgoing, relation)
+ }
+ if candidate.direction == "in" && isDirectBriefDependent(candidate.edge.Type, candidate.node) {
+ result.Impact.DirectDependents++
+ if candidate.node.FilePath != "" {
+ files[candidate.node.FilePath] = struct{}{}
+ }
+ switch candidate.edge.Type {
+ case graph.RelCalls:
+ result.Impact.Callers++
+ case graph.RelImplements, graph.RelMethodImplements:
+ result.Impact.Implementations++
+ }
+ }
+ if candidate.group == "entrypoints" {
+ result.Impact.Entrypoints++
+ }
+ }
+ result.Impact.AffectedFiles = len(files)
+ return result, nil
+}
+
+func collectBriefCandidates(graphStore *graph.GraphStore, symbol *graph.Node) ([]briefCandidate, error) {
+ var candidates []briefCandidate
+ outgoing, err := graphStore.GetAllOutEdges(symbol.ID)
+ if err != nil {
+ return nil, err
+ }
+ for _, edge := range outgoing {
+ if !isBriefSemanticRelation(edge.Type) {
+ continue
+ }
+ node, err := graphStore.GetNode(edge.Target)
+ if err != nil || isBriefNoiseNode(node) {
+ continue
+ }
+ group, priority := classifyBriefCandidate("out", edge, node)
+ candidates = append(candidates, briefCandidate{
+ direction: "out", edge: edge, node: node, group: group, priority: priority,
+ })
+ }
+ incoming, err := graphStore.GetAllInEdges(symbol.ID)
+ if err != nil {
+ return nil, err
+ }
+ for _, edge := range incoming {
+ if !isBriefSemanticRelation(edge.Type) {
+ continue
+ }
+ node, err := graphStore.GetNode(edge.Source)
+ if err != nil || isBriefNoiseNode(node) {
+ continue
+ }
+ group, priority := classifyBriefCandidate("in", edge, node)
+ candidates = append(candidates, briefCandidate{
+ direction: "in", edge: edge, node: node, group: group, priority: priority,
+ })
+ }
+ return candidates, nil
+}
+
+func classifyBriefCandidate(direction string, edge *graph.Edge, node *graph.Node) (string, int) {
+ if isTestPath(node.FilePath) {
+ return directionGroup(direction), 0
+ }
+ switch edge.Type {
+ case graph.RelHandlesRoute, graph.RelHandlesTool, graph.RelBindsEventHandler, graph.RelEntryPointOf:
+ return "entrypoints", 1
+ case graph.RelImplements, graph.RelMethodImplements, graph.RelExtends,
+ graph.RelInherits, graph.RelMethodOverrides:
+ if direction == "in" {
+ return "implementations", 2
+ }
+ return "contracts", 2
+ case graph.RelDispatchesTo:
+ return directionGroup(direction), 3
+ case graph.RelCalls:
+ return directionGroup(direction), 4
+ case graph.RelWraps, graph.RelDecorates, graph.RelEmitsEvent:
+ return directionGroup(direction), 5
+ case graph.RelHasMethod, graph.RelHasProperty:
+ return directionGroup(direction), 6
+ case graph.RelImports:
+ return directionGroup(direction), 8
+ default:
+ return directionGroup(direction), 7
+ }
+}
+
+func directionGroup(direction string) string {
+ if direction == "in" {
+ return "incoming"
+ }
+ return "outgoing"
+}
+
+func isBriefSemanticRelation(relation graph.RelType) bool {
+ switch relation {
+ case graph.RelCalls, graph.RelImports, graph.RelExtends, graph.RelImplements,
+ graph.RelInherits, graph.RelDecorates, graph.RelWraps, graph.RelMethodOverrides,
+ graph.RelMethodImplements, graph.RelDispatchesTo, graph.RelEntryPointOf,
+ graph.RelHandlesRoute, graph.RelHandlesTool, graph.RelBindsEventHandler,
+ graph.RelEmitsEvent, graph.RelHasMethod, graph.RelHasProperty:
+ return true
+ }
+ return false
+}
+
+func isBriefNoiseNode(node *graph.Node) bool {
+ if node == nil {
+ return true
+ }
+ switch node.Label {
+ case graph.LabelFolder, graph.LabelCommunity, graph.LabelFile, graph.LabelGoFile,
+ graph.LabelTSFile, graph.LabelJSFile, graph.LabelPythonFile, graph.LabelJavaFile,
+ graph.LabelRustFile, graph.LabelCFile, graph.LabelCPPFile, graph.LabelCSharpFile,
+ graph.LabelMarkdownFile:
+ return true
+ }
+ return false
+}
+
+func isDirectBriefDependent(relation graph.RelType, node *graph.Node) bool {
+ if node == nil || !node.Label.IsActionableSymbol() {
+ return false
+ }
+ switch relation {
+ case graph.RelCalls, graph.RelImports, graph.RelExtends, graph.RelImplements,
+ graph.RelInherits, graph.RelMethodOverrides, graph.RelMethodImplements,
+ graph.RelDispatchesTo, graph.RelHandlesRoute, graph.RelHandlesTool,
+ graph.RelBindsEventHandler, graph.RelWraps:
+ return true
+ }
+ return false
+}
+
+func briefSignature(symbol *graph.Node, content string) string {
+ if signature := symbol.GetPropString("signature"); signature != "" {
+ return signature
+ }
+ for _, line := range strings.Split(content, "\n") {
+ line = strings.TrimSpace(line)
+ if line != "" {
+ return line
+ }
+ }
+ if receiver := symbol.GetPropString("receiver"); receiver != "" {
+ return receiver + "." + symbol.Name
+ }
+ return symbol.Name
+}
+
+func limitSourceLines(content string, limit int) (string, bool) {
+ lines := strings.Split(content, "\n")
+ if len(lines) <= limit {
+ return content, false
+ }
+ return strings.Join(lines[:limit], "\n"), true
+}
+
+func (e *Engine) addBriefTextualTests(ctx context.Context, repo string, result *BriefResult) {
+ if result == nil || result.Symbol.Name == "" || result.Symbol.FilePath == "" {
+ return
+ }
+ matches, err := e.SearchSource(ctx, &SourceSearchRequest{
+ Repo: repo, Query: result.Symbol.Name, Scope: SourceScopeCode, Limit: 200, ContextLines: 0,
+ })
+ if err != nil {
+ return
+ }
+ seenFiles := make(map[string]struct{}, len(result.RelatedTests))
+ for _, related := range result.RelatedTests {
+ seenFiles[related.FilePath] = struct{}{}
+ }
+ symbolDirectory := filepath.ToSlash(filepath.Dir(result.Symbol.FilePath))
+ symbolStem := sourceFileStem(result.Symbol.FilePath)
+ for _, match := range matches.Results {
+ if !isTestPath(match.FilePath) {
+ continue
+ }
+ testDirectory := filepath.ToSlash(filepath.Dir(match.FilePath))
+ sameDirectory := testDirectory == symbolDirectory
+ sameFileFamily := sourceFileStem(match.FilePath) == symbolStem
+ if !sameDirectory || !sameFileFamily {
+ continue
+ }
+ if _, ok := seenFiles[match.FilePath]; ok {
+ continue
+ }
+ seenFiles[match.FilePath] = struct{}{}
+ result.RelatedTests = append(result.RelatedTests, BriefTest{
+ FilePath: match.FilePath, Line: match.Line, Confidence: 0.75,
+ Reason: "matching-test-file-textual-reference",
+ })
+ }
+}
+
+func publicBriefGraphEdge(edge *graph.Edge) GraphEdge {
+ result := publicGraphEdge(edge)
+ if result.Reason != "" {
+ return result
+ }
+ switch edge.Type {
+ case graph.RelHasMethod:
+ result.Reason = "declared-method"
+ case graph.RelHasProperty:
+ result.Reason = "declared-property"
+ default:
+ result.Reason = "persisted-semantic-relation"
+ }
+ return result
+}
+
+func sourceFileStem(path string) string {
+ base := strings.ToLower(filepath.Base(filepath.ToSlash(path)))
+ for _, suffix := range []string{
+ ".test.tsx", ".spec.tsx", ".test.ts", ".spec.ts", "_test.go",
+ ".tsx", ".ts", ".go", ".js", ".jsx",
+ } {
+ if strings.HasSuffix(base, suffix) {
+ return strings.TrimSuffix(base, suffix)
+ }
+ }
+ return strings.TrimSuffix(base, filepath.Ext(base))
+}
+
+func isTestPath(path string) bool {
+ path = "/" + strings.ToLower(filepath.ToSlash(path))
+ base := filepath.Base(path)
+ return strings.HasSuffix(path, "_test.go") ||
+ strings.HasSuffix(path, ".test.ts") ||
+ strings.HasSuffix(path, ".test.tsx") ||
+ strings.HasSuffix(path, ".spec.ts") ||
+ strings.HasSuffix(path, ".spec.tsx") ||
+ strings.Contains(path, "/__tests__/") ||
+ strings.Contains(path, "/test/") ||
+ strings.Contains(path, "/tests/") ||
+ strings.HasPrefix(base, "test_")
+}
+
+func appendUniqueString(values []string, value string) []string {
+ for _, existing := range values {
+ if existing == value {
+ return values
+ }
+ }
+ return append(values, value)
+}
diff --git a/brief_test.go b/brief_test.go
new file mode 100644
index 0000000..08482ec
--- /dev/null
+++ b/brief_test.go
@@ -0,0 +1,350 @@
+package codetrip
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestBriefBuildsRankedContextAndRelatedTests(t *testing.T) {
+ repository := t.TempDir()
+ writeBriefFixture(t, repository, "main.go", `package fixture
+
+func Work() {
+ println("work")
+}
+
+func Run() {
+ Work()
+}
+`)
+ writeBriefFixture(t, repository, "main_test.go", `package fixture
+
+func TestWork() {
+ Work()
+}
+`)
+ writeBriefFixture(t, repository, "other/unrelated_test.go", `package other
+
+// Work is mentioned here but this is not a test for fixture.Work.
+func TestOther() {}
+`)
+ engine, err := Open(filepath.Join(t.TempDir(), "data"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer engine.Close()
+ if _, err := engine.IndexRepo(context.Background(), repository, WithRepoName("fixture")); err != nil {
+ t.Fatal(err)
+ }
+
+ result, err := engine.Brief(context.Background(), &BriefRequest{
+ Repo: "fixture", Symbol: "Work", MaxRelations: 10, SourceLines: 2,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result.Symbol.Name != "Work" || !strings.Contains(result.Signature, "func Work") {
+ t.Fatalf("unexpected symbol brief: %#v", result)
+ }
+ if len(result.Incoming) != 1 || result.Incoming[0].Node.Name != "Run" {
+ t.Fatalf("incoming=%#v, want only production caller Run", result.Incoming)
+ }
+ if result.Impact.Callers != 1 || result.Impact.DirectDependents != 1 ||
+ result.Impact.AffectedFiles != 1 {
+ t.Fatalf("unexpected impact summary: %#v", result.Impact)
+ }
+ if len(result.RelatedTests) == 0 || result.RelatedTests[0].FilePath != "main_test.go" {
+ t.Fatalf("related tests=%#v", result.RelatedTests)
+ }
+ for _, related := range result.RelatedTests {
+ if related.FilePath == "other/unrelated_test.go" {
+ t.Fatalf("unrelated textual test leaked into brief: %#v", result.RelatedTests)
+ }
+ }
+ if !result.Truncated || len(result.Truncation) == 0 ||
+ !strings.Contains(strings.Join(result.Truncation, " "), "source") {
+ t.Fatalf("source truncation was not reported: %#v", result)
+ }
+ if strings.Count(result.Source, "\n") != 1 {
+ t.Fatalf("source line budget not applied: %q", result.Source)
+ }
+ for _, relation := range result.Incoming {
+ if relation.Type != "CALLS" || relation.Direction != "in" ||
+ relation.Confidence <= 0 || relation.Reason == "" {
+ t.Fatalf("relation lacks evidence: %#v", relation)
+ }
+ }
+}
+
+func TestBriefIncludesBoundedTypeMembers(t *testing.T) {
+ repository := t.TempDir()
+ writeBriefFixture(t, repository, "service.ts", `export class Service {
+ run(): void {}
+ stop(): void {}
+}
+`)
+ engine, err := Open(filepath.Join(t.TempDir(), "data"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer engine.Close()
+ if _, err := engine.IndexRepo(context.Background(), repository, WithRepoName("fixture")); err != nil {
+ t.Fatal(err)
+ }
+ result, err := engine.Brief(context.Background(), &BriefRequest{
+ Repo: "fixture", Symbol: "Service", MaxRelations: 10,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ var methods int
+ for _, relation := range result.Outgoing {
+ if relation.Type == "HAS_METHOD" {
+ methods++
+ }
+ }
+ if methods != 2 {
+ t.Fatalf("type members missing from brief: %#v", result.Outgoing)
+ }
+}
+
+func TestBriefSeparatesContractsAndImplementations(t *testing.T) {
+ repository := t.TempDir()
+ writeBriefFixture(t, repository, "go.mod", "module example.com/brief\n\ngo 1.26\n")
+ writeBriefFixture(t, repository, "contract.go", "package fixture\ntype Repo interface { Create() error }\n")
+ writeBriefFixture(t, repository, "repository.go", "package fixture\ntype repo struct{}\nfunc (*repo) Create() error { return nil }\n")
+ writeBriefFixture(t, repository, "service.go", "package fixture\ntype Service struct { Repo Repo }\nfunc (service *Service) Run() error { return service.Repo.Create() }\n")
+ writeBriefFixture(t, repository, "wire.go", "package fixture\nfunc Build() error {\n repository := &repo{}\n service := &Service{Repo: repository}\n return service.Run()\n}\n")
+ engine, err := Open(filepath.Join(t.TempDir(), "data"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer engine.Close()
+ if _, err := engine.IndexRepo(context.Background(), repository, WithRepoName("fixture")); err != nil {
+ t.Fatal(err)
+ }
+ worker, err := engine.Brief(context.Background(), &BriefRequest{
+ Repo: "fixture", Symbol: "Repo",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(worker.Implementations) != 1 || worker.Implementations[0].Node.Name != "repo" ||
+ worker.Implementations[0].Type != "IMPLEMENTS" {
+ t.Fatalf("interface implementations=%#v", worker.Implementations)
+ }
+ service, err := engine.Brief(context.Background(), &BriefRequest{
+ Repo: "fixture", Symbol: "repo",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(service.Contracts) != 1 || service.Contracts[0].Node.Name != "Repo" ||
+ service.Contracts[0].Type != "IMPLEMENTS" {
+ t.Fatalf("service contracts=%#v", service.Contracts)
+ }
+}
+
+func TestBriefRelationBudgetIsDeterministic(t *testing.T) {
+ repository := t.TempDir()
+ writeBriefFixture(t, repository, "main.go", `package fixture
+
+func Work() {}
+func First() { Work() }
+func Second() { Work() }
+`)
+ engine, err := Open(filepath.Join(t.TempDir(), "data"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer engine.Close()
+ if _, err := engine.IndexRepo(context.Background(), repository, WithRepoName("fixture")); err != nil {
+ t.Fatal(err)
+ }
+ request := &BriefRequest{Repo: "fixture", Symbol: "Work", MaxRelations: 1}
+ first, err := engine.Brief(context.Background(), request)
+ if err != nil {
+ t.Fatal(err)
+ }
+ second, err := engine.Brief(context.Background(), request)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(first.Incoming) != 1 || !first.Truncated {
+ t.Fatalf("budget not enforced: %#v", first)
+ }
+ if first.Incoming[0] != second.Incoming[0] ||
+ strings.Join(first.Truncation, "\x00") != strings.Join(second.Truncation, "\x00") {
+ t.Fatalf("brief is not deterministic:\nfirst=%#v\nsecond=%#v", first, second)
+ }
+}
+
+func TestBriefQueryBuildsMultipleProductionContextsInOneRequest(t *testing.T) {
+ repository := t.TempDir()
+ writeBriefFixture(t, repository, "router.go", `package fixture
+
+type Router struct{}
+func (router *Router) HandleRequest() { router.runMiddleware() }
+func (router *Router) runMiddleware() { Next() }
+func Next() {}
+`)
+ writeBriefFixture(t, repository, "router_test.go", `package fixture
+func TestMiddlewareChain() { (&Router{}).HandleRequest() }
+`)
+ engine, err := Open(filepath.Join(t.TempDir(), "data"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer engine.Close()
+ if _, err := engine.IndexRepo(context.Background(), repository, WithRepoName("fixture")); err != nil {
+ t.Fatal(err)
+ }
+ request := &BriefRequest{
+ Repo: "fixture", Query: "How does the router handle a request through its middleware chain?",
+ MaxSymbols: 3, MaxRelations: 12, SourceLines: 20,
+ }
+ first, err := engine.Brief(context.Background(), request)
+ if err != nil {
+ t.Fatal(err)
+ }
+ second, err := engine.Brief(context.Background(), request)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if first.Query != request.Query || len(first.Contexts) < 2 || len(first.Contexts) > 3 {
+ t.Fatalf("unexpected query brief: %#v", first)
+ }
+ names := make([]string, 0, len(first.Contexts))
+ for _, contextPack := range first.Contexts {
+ if isTestPath(contextPack.Symbol.FilePath) {
+ t.Fatalf("test seed leaked into query brief: %#v", contextPack.Symbol)
+ }
+ names = append(names, contextPack.Symbol.Name)
+ }
+ var evidenceCount int
+ for _, contextPack := range first.Contexts {
+ evidenceCount += len(contextPack.Evidence)
+ }
+ if evidenceCount == 0 {
+ t.Fatalf("query brief omitted neighboring source evidence: %#v", first.Contexts)
+ }
+ if !containsString(names, "HandleRequest") || !containsString(names, "runMiddleware") {
+ t.Fatalf("missing architecture seeds: %v", names)
+ }
+ if strings.Join(names, "\x00") != strings.Join(contextNames(second.Contexts), "\x00") {
+ t.Fatalf("query brief is not deterministic: %v != %v", names, contextNames(second.Contexts))
+ }
+}
+
+func contextNames(contexts []BriefContext) []string {
+ result := make([]string, 0, len(contexts))
+ for _, contextPack := range contexts {
+ result = append(result, contextPack.Symbol.Name)
+ }
+ return result
+}
+
+func TestBriefRejectsAmbiguousNamesAndInvalidBudgets(t *testing.T) {
+ repository := t.TempDir()
+ writeBriefFixture(t, repository, "a.go", "package fixture\nfunc Work() {}\n")
+ writeBriefFixture(t, repository, "b.go", "package fixture\nfunc Work() {}\n")
+ engine, err := Open(filepath.Join(t.TempDir(), "data"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer engine.Close()
+ if _, err := engine.IndexRepo(context.Background(), repository, WithRepoName("fixture")); err != nil {
+ t.Fatal(err)
+ }
+ _, ambiguityErr := engine.Brief(context.Background(), &BriefRequest{Repo: "fixture", Symbol: "Work"})
+ var ambiguity *BriefAmbiguityError
+ if ambiguityErr == nil || !errors.Is(ambiguityErr, ErrInvalidRequest) ||
+ !errors.As(ambiguityErr, &ambiguity) ||
+ !strings.Contains(ambiguityErr.Error(), "ambiguous") || !strings.Contains(ambiguityErr.Error(), "a.go") ||
+ !strings.Contains(ambiguityErr.Error(), "b.go") {
+ t.Fatalf("ambiguous brief error=%v", ambiguityErr)
+ }
+ if ambiguity.Symbol != "Work" || len(ambiguity.Candidates) != 2 ||
+ ambiguity.Candidates[0].FilePath != "a.go" ||
+ ambiguity.Candidates[1].FilePath != "b.go" {
+ t.Fatalf("ambiguous brief error=%v", ambiguityErr)
+ }
+ if _, err := engine.Brief(context.Background(), nil); !errors.Is(err, ErrInvalidRequest) {
+ t.Fatalf("nil request error=%v", err)
+ }
+ if _, err := engine.Brief(context.Background(), &BriefRequest{
+ Repo: "fixture", Symbol: "Work", MaxRelations: maxBriefRelations + 1,
+ }); !errors.Is(err, ErrInvalidRequest) {
+ t.Fatalf("maxRelations error=%v", err)
+ }
+ if _, err := engine.Brief(context.Background(), &BriefRequest{
+ Repo: "fixture", Symbol: "Work", SourceLines: maxBriefLines + 1,
+ }); !errors.Is(err, ErrInvalidRequest) {
+ t.Fatalf("sourceLines error=%v", err)
+ }
+ if _, err := engine.Brief(context.Background(), &BriefRequest{
+ Repo: "fixture", Symbol: "Work", Query: "work",
+ }); !errors.Is(err, ErrInvalidRequest) {
+ t.Fatalf("symbol/query exclusivity error=%v", err)
+ }
+ if _, err := engine.Brief(context.Background(), &BriefRequest{
+ Repo: "fixture", Query: "work", MaxSymbols: maxBriefSymbols + 1,
+ }); !errors.Is(err, ErrInvalidRequest) {
+ t.Fatalf("maxSymbols error=%v", err)
+ }
+}
+
+func TestBriefPreservesRepositoryOpenError(t *testing.T) {
+ repository := t.TempDir()
+ writeBriefFixture(t, repository, "main.go", "package fixture\nfunc Work() {}\n")
+ dataDir := filepath.Join(t.TempDir(), "data")
+ first, err := Open(dataDir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer first.Close()
+ if _, err := first.IndexRepo(context.Background(), repository, WithRepoName("fixture")); err != nil {
+ t.Fatal(err)
+ }
+
+ second, err := Open(dataDir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer second.Close()
+ _, err = second.Brief(context.Background(), &BriefRequest{Repo: "fixture", Symbol: "Work"})
+ if err == nil {
+ t.Fatal("brief unexpectedly opened a repository locked by another engine")
+ }
+ if strings.Contains(err.Error(), "is not indexed") ||
+ !strings.Contains(err.Error(), `open repository "fixture"`) {
+ t.Fatalf("brief lost repository open error: %v", err)
+ }
+}
+
+func writeBriefFixture(t *testing.T, repository, path, content string) {
+ t.Helper()
+ fullPath := filepath.Join(repository, path)
+ if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(fullPath, []byte(content), 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestSourceFileStemMatchesTestFamilies(t *testing.T) {
+ for _, pair := range [][2]string{
+ {"graph-builder.ts", "graph-builder.test.ts"},
+ {"widget.tsx", "widget.spec.tsx"},
+ {"server.go", "server_test.go"},
+ } {
+ if sourceFileStem(pair[0]) != sourceFileStem(pair[1]) {
+ t.Errorf("stems differ for %q and %q", pair[0], pair[1])
+ }
+ }
+}
diff --git a/cmd/codetrip/cli.go b/cmd/codetrip/cli.go
index 075287a..f51d3b8 100644
--- a/cmd/codetrip/cli.go
+++ b/cmd/codetrip/cli.go
@@ -44,7 +44,7 @@ func newRootCmd(flags *cliFlags) *cobra.Command {
}
root.PersistentFlags().StringVar(&flags.tripDir, "dir", "", "data directory (default: ~/.codetrip)")
root.PersistentFlags().BoolVarP(&flags.verbose, "verbose", "v", false, "enable info logging")
- root.AddCommand(newIndexCmd(flags), newDeleteCmd(flags), newSearchCmd(flags), newSourceCmd(flags), newEmbedCmd(flags), newHybridCmd(flags), newTraverseCmd(flags), newPathCmd(flags), newContextCmd(flags), newImpactCmd(flags), newCheckCmd(flags), newDiffCmd(flags), newRenameCmd(flags), newExportCmd(flags), newListCmd(flags), newMCPCmd(flags), newVersionCmd())
+ root.AddCommand(newIndexCmd(flags), newDeleteCmd(flags), newSearchCmd(flags), newSourceCmd(flags), newEmbedCmd(flags), newHybridCmd(flags), newTraverseCmd(flags), newPathCmd(flags), newContextCmd(flags), newBriefCmd(flags), newImpactCmd(flags), newCheckCmd(flags), newDiffCmd(flags), newRenameCmd(flags), newExportCmd(flags), newListCmd(flags), newMCPCmd(flags), newVersionCmd())
return root
}
@@ -237,8 +237,45 @@ func newContextCmd(flags *cliFlags) *cobra.Command {
return command
}
+func newBriefCmd(flags *cliFlags) *cobra.Command {
+ var repo, format string
+ var maxRelations, maxSymbols, sourceLines int
+ var queryMode bool
+ command := &cobra.Command{
+ Use: "brief ", Short: "Build a compact, evidence-backed code context pack", Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ trip, err := flags.openTrip()
+ if err != nil {
+ return err
+ }
+ defer trip.Close()
+ request := &codetrip.BriefRequest{
+ Repo: repo, MaxSymbols: maxSymbols, MaxRelations: maxRelations, SourceLines: sourceLines,
+ }
+ if queryMode {
+ request.Query = args[0]
+ } else {
+ request.Symbol = args[0]
+ }
+ result, err := trip.Brief(cmd.Context(), request)
+ if err != nil {
+ return err
+ }
+ return writeBrief(cmd.OutOrStdout(), result, format)
+ },
+ }
+ command.Flags().StringVar(&repo, "repo", "", "repository name")
+ command.Flags().IntVar(&maxRelations, "max-relations", 30, "maximum ranked semantic relationships")
+ command.Flags().IntVar(&maxSymbols, "max-symbols", 5, "maximum production-code seed symbols in query mode")
+ command.Flags().IntVar(&sourceLines, "source-lines", 40, "maximum source lines")
+ command.Flags().BoolVar(&queryMode, "query", false, "interpret the argument as a natural-language architecture question")
+ command.Flags().StringVar(&format, "format", "json", "output format: json or text")
+ _ = command.MarkFlagRequired("repo")
+ return command
+}
+
func newImpactCmd(flags *cliFlags) *cobra.Command {
- var repo, relations, format string
+ var repo, relations, format, changeKind string
var depth, limit int
command := &cobra.Command{
Use: "impact ", Short: "Analyze symbols affected by changing a graph node", Args: cobra.ExactArgs(1),
@@ -253,7 +290,7 @@ func newImpactCmd(flags *cliFlags) *cobra.Command {
return err
}
result, err := trip.Impact(cmd.Context(), &codetrip.ImpactRequest{
- Repo: repo, NodeID: nodeID, MaxDepth: depth,
+ Repo: repo, NodeID: nodeID, ChangeKind: changeKind, MaxDepth: depth,
RelationTypes: splitRelations(relations), Limit: limit,
})
if err != nil {
@@ -263,6 +300,7 @@ func newImpactCmd(flags *cliFlags) *cobra.Command {
},
}
command.Flags().StringVar(&repo, "repo", "", "repository name")
+ command.Flags().StringVar(&changeKind, "change", "", "change intent: behavior, signature, contract, or remove")
command.Flags().IntVar(&depth, "depth", 3, "maximum reverse dependency depth")
command.Flags().StringVar(&relations, "relations", "", "comma-separated relationship types")
command.Flags().IntVar(&limit, "limit", 100, "maximum impacted symbols")
@@ -472,6 +510,7 @@ func newIndexCmd(flags *cliFlags) *cobra.Command {
var csvPath string
var strict bool
var replace bool
+ var full bool
command := &cobra.Command{
Use: "index ",
Short: "Parse a repository and persist its code graph",
@@ -482,15 +521,16 @@ func newIndexCmd(flags *cliFlags) *cobra.Command {
return err
}
defer trip.Close()
- options := make([]codetrip.IndexOption, 0, 3)
+ options := make([]codetrip.IndexOption, 0, 4)
+ options = append(options, codetrip.WithIndexMode(codetrip.IndexModeAuto))
if repoName != "" {
options = append(options, codetrip.WithRepoName(repoName))
}
if csvPath != "" {
options = append(options, codetrip.WithCSVExport(csvPath), codetrip.WithCSVExportStrict(strict))
}
- if replace {
- options = append(options, codetrip.WithReplaceExisting(true))
+ if replace || full {
+ options = append(options, codetrip.WithIndexMode(codetrip.IndexModeFull))
}
result, err := trip.IndexRepo(cmd.Context(), args[0], options...)
if err != nil {
@@ -505,6 +545,7 @@ func newIndexCmd(flags *cliFlags) *cobra.Command {
command.Flags().StringVar(&csvPath, "export", "", "write validation CSV files to this directory")
command.Flags().BoolVar(&strict, "export-strict", false, "fail indexing when CSV export fails")
command.Flags().BoolVar(&replace, "replace", false, "atomically replace an existing repository snapshot")
+ command.Flags().BoolVar(&full, "full", false, "force a complete repository snapshot rebuild")
return command
}
diff --git a/cmd/codetrip/cli_test.go b/cmd/codetrip/cli_test.go
index 094bfc1..b73c54d 100644
--- a/cmd/codetrip/cli_test.go
+++ b/cmd/codetrip/cli_test.go
@@ -14,7 +14,7 @@ import (
func TestRootCommandsUseSingleWords(t *testing.T) {
root := newRootCmd(newCLIFlags())
want := map[string]bool{
- "check": false, "context": false, "delete": false, "diff": false, "embed": false, "export": false, "hybrid": false,
+ "brief": false, "check": false, "context": false, "delete": false, "diff": false, "embed": false, "export": false, "hybrid": false,
"impact": false, "index": false, "list": false, "mcp": false, "path": false, "search": false,
"rename": false, "source": false, "traverse": false, "version": false,
}
@@ -62,6 +62,21 @@ func TestNodeCommandsResolveExactSymbolNames(t *testing.T) {
args: []string{"--dir", dataDir, "context", "Work", "--repo", "fixture"},
want: `"name": "Work"`,
},
+ {
+ name: "brief",
+ args: []string{"--dir", dataDir, "brief", "Work", "--repo", "fixture"},
+ want: `"callers": 1`,
+ },
+ {
+ name: "brief text",
+ args: []string{"--dir", dataDir, "brief", "Work", "--repo", "fixture", "--format", "text"},
+ want: "Incoming (1)",
+ },
+ {
+ name: "brief query",
+ args: []string{"--dir", dataDir, "brief", "How does Run call Work?", "--query", "--repo", "fixture"},
+ want: `"contexts"`,
+ },
{
name: "context preserves node ID",
args: []string{"--dir", dataDir, "context", "Function:main.go:Work", "--repo", "fixture"},
@@ -70,7 +85,7 @@ func TestNodeCommandsResolveExactSymbolNames(t *testing.T) {
{
name: "impact tree",
args: []string{"--dir", dataDir, "impact", "Work", "--repo", "fixture", "--format", "tree"},
- want: "CALLS ← Run main.go:3",
+ want: "CALLS ← Run main.go:4",
},
{
name: "traverse",
diff --git a/cmd/codetrip/mcp.go b/cmd/codetrip/mcp.go
index 0f30443..702de8c 100644
--- a/cmd/codetrip/mcp.go
+++ b/cmd/codetrip/mcp.go
@@ -4,8 +4,11 @@ import (
"context"
"errors"
"fmt"
+ "sync"
+ "time"
"github.com/mengshi02/codetrip"
+ inc "github.com/mengshi02/codetrip/internal/incremental"
protocol "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/spf13/cobra"
)
@@ -16,21 +19,44 @@ type listOutput struct {
Repositories []codetrip.RepoInfo `json:"repositories"`
}
-// engineAccess serializes MCP requests and keeps the durable store open only
-// for the duration of one request. A long-running stdio server therefore does
-// not prevent CLI commands from opening the same data directory.
+type indexInput struct {
+ Repository string `json:"repository" jsonschema:"absolute or working-directory-relative repository path to index"`
+ Repo string `json:"repo,omitempty" jsonschema:"logical repository name; defaults to the repository directory name"`
+ Full bool `json:"full,omitempty" jsonschema:"force a complete rebuild instead of automatic no-op, incremental, or safe fallback selection"`
+}
+
+// engineAccess serializes MCP requests and reuses one Engine across a burst of
+// agent calls. The Engine is released after a short idle period so a
+// long-running stdio server does not permanently prevent CLI commands from
+// opening the same data directory.
type engineAccess struct {
- gate chan struct{}
- open func() (*codetrip.Engine, error)
+ gate chan struct{}
+ open func() (*codetrip.Engine, error)
+ mu sync.Mutex
+ freshness map[string]*inc.FreshnessTracker
+ engine *codetrip.Engine
+ idleTimer *time.Timer
+ idleFor time.Duration
+ closed bool
}
func newEngineAccess(open func() (*codetrip.Engine, error)) *engineAccess {
gate := make(chan struct{}, 1)
gate <- struct{}{}
- return &engineAccess{gate: gate, open: open}
+ return &engineAccess{
+ gate: gate, open: open, freshness: make(map[string]*inc.FreshnessTracker),
+ idleFor: time.Second,
+ }
}
func (access *engineAccess) use(ctx context.Context, operation func(*codetrip.Engine) error) error {
+ if err := access.waitFresh(ctx); err != nil {
+ return err
+ }
+ return access.useRaw(ctx, operation)
+}
+
+func (access *engineAccess) useRaw(ctx context.Context, operation func(*codetrip.Engine) error) error {
select {
case <-ctx.Done():
return ctx.Err()
@@ -38,16 +64,130 @@ func (access *engineAccess) use(ctx context.Context, operation func(*codetrip.En
}
defer func() { access.gate <- struct{}{} }()
- engine, err := access.open()
+ engine, err := access.acquireEngine()
if err != nil {
- return fmt.Errorf("open codetrip engine for MCP request: %w", err)
+ return err
}
operationErr := operation(engine)
- closeErr := engine.Close()
- return errors.Join(operationErr, closeErr)
+ access.scheduleIdleClose()
+ return operationErr
+}
+
+func (access *engineAccess) acquireEngine() (*codetrip.Engine, error) {
+ access.mu.Lock()
+ defer access.mu.Unlock()
+ if access.closed {
+ return nil, errors.New("codetrip MCP engine access is closed")
+ }
+ if access.idleTimer != nil {
+ access.idleTimer.Stop()
+ access.idleTimer = nil
+ }
+ if access.engine != nil {
+ return access.engine, nil
+ }
+ engine, err := access.open()
+ if err != nil {
+ return nil, fmt.Errorf("open codetrip engine for MCP request: %w", err)
+ }
+ access.engine = engine
+ return engine, nil
+}
+
+func (access *engineAccess) scheduleIdleClose() {
+ access.mu.Lock()
+ defer access.mu.Unlock()
+ if access.closed || access.engine == nil {
+ return
+ }
+ if access.idleTimer != nil {
+ access.idleTimer.Stop()
+ }
+ access.idleTimer = time.AfterFunc(access.idleFor, access.closeIdle)
+}
+
+func (access *engineAccess) closeIdle() {
+ <-access.gate
+ defer func() { access.gate <- struct{}{} }()
+ access.mu.Lock()
+ engine := access.engine
+ access.engine = nil
+ access.idleTimer = nil
+ access.mu.Unlock()
+ if engine != nil {
+ _ = engine.Close()
+ }
+}
+
+func (access *engineAccess) close() error {
+ <-access.gate
+ defer func() { access.gate <- struct{}{} }()
+ access.mu.Lock()
+ access.closed = true
+ if access.idleTimer != nil {
+ access.idleTimer.Stop()
+ access.idleTimer = nil
+ }
+ engine := access.engine
+ access.engine = nil
+ access.mu.Unlock()
+ if engine != nil {
+ return engine.Close()
+ }
+ return nil
+}
+
+func (access *engineAccess) observe(repo string) inc.Generation {
+ access.mu.Lock()
+ tracker := access.freshness[repo]
+ if tracker == nil {
+ tracker = inc.NewFreshnessTracker(0)
+ access.freshness[repo] = tracker
+ }
+ access.mu.Unlock()
+ return tracker.Observe()
+}
+
+func (access *engineAccess) applied(repo string, generation inc.Generation, revision uint64) {
+ access.mu.Lock()
+ tracker := access.freshness[repo]
+ access.mu.Unlock()
+ if tracker != nil {
+ tracker.Applied(generation, inc.Revision(revision))
+ }
+}
+
+func (access *engineAccess) failed(repo string, generation inc.Generation, err error) {
+ access.mu.Lock()
+ tracker := access.freshness[repo]
+ access.mu.Unlock()
+ if tracker != nil {
+ tracker.Failed(generation, err)
+ }
+}
+
+func (access *engineAccess) waitFresh(ctx context.Context) error {
+ access.mu.Lock()
+ type target struct {
+ tracker *inc.FreshnessTracker
+ generation inc.Generation
+ }
+ targets := make([]target, 0, len(access.freshness))
+ for _, tracker := range access.freshness {
+ targets = append(targets, target{tracker: tracker, generation: tracker.Target()})
+ }
+ access.mu.Unlock()
+ for _, target := range targets {
+ if _, err := target.tracker.Wait(ctx, target.generation); err != nil {
+ return err
+ }
+ }
+ return nil
}
func newMCPCmd(flags *cliFlags) *cobra.Command {
+ var watch bool
+ var watchDebounce time.Duration
command := &cobra.Command{
Use: "mcp", Short: "Start the codetrip MCP server", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
@@ -58,9 +198,34 @@ func newMCPCmd(flags *cliFlags) *cobra.Command {
access := newEngineAccess(func() (*codetrip.Engine, error) {
return codetrip.Open(dir)
})
- return newMCPServer(access).Run(cmd.Context(), &protocol.StdioTransport{})
+ defer access.close()
+ server := newMCPServer(access)
+ if !watch {
+ return server.Run(cmd.Context(), &protocol.StdioTransport{})
+ }
+ watcher, err := newMCPRepositoryWatchService(access, watchDebounce, cmd.ErrOrStderr())
+ if err != nil {
+ return fmt.Errorf("start repository watcher: %w", err)
+ }
+ ctx, cancel := context.WithCancel(cmd.Context())
+ defer cancel()
+ results := make(chan error, 2)
+ go func() { results <- watcher.Run(ctx) }()
+ go func() { results <- server.Run(ctx, &protocol.StdioTransport{}) }()
+ first := <-results
+ cancel()
+ second := <-results
+ if errors.Is(first, context.Canceled) {
+ first = nil
+ }
+ if errors.Is(second, context.Canceled) {
+ second = nil
+ }
+ return errors.Join(first, second)
},
}
+ command.Flags().BoolVar(&watch, "watch", false, "watch indexed repositories and incrementally refresh changed code")
+ command.Flags().DurationVar(&watchDebounce, "watch-debounce", 750*time.Millisecond, "quiet period used to merge filesystem events")
command.AddCommand(newMCPSetupCmd(flags))
return command
}
@@ -68,6 +233,29 @@ func newMCPCmd(flags *cliFlags) *cobra.Command {
func newMCPServer(access *engineAccess) *protocol.Server {
server := protocol.NewServer(&protocol.Implementation{Name: "codetrip", Version: codetrip.Version}, nil)
+ protocol.AddTool(server, &protocol.Tool{
+ Name: "index",
+ Description: "Refresh a repository after source edits. Automatically returns noop when unchanged and incrementally updates affected graph, symbol, source, and vector records across supported languages; full=true explicitly rebuilds the complete snapshot.",
+ }, func(ctx context.Context, _ *protocol.CallToolRequest, input indexInput) (*protocol.CallToolResult, codetrip.IndexResult, error) {
+ var output codetrip.IndexResult
+ err := access.use(ctx, func(engine *codetrip.Engine) error {
+ mode := codetrip.IndexModeAuto
+ if input.Full {
+ mode = codetrip.IndexModeFull
+ }
+ options := []codetrip.IndexOption{codetrip.WithIndexMode(mode)}
+ if input.Repo != "" {
+ options = append(options, codetrip.WithRepoName(input.Repo))
+ }
+ result, err := engine.IndexRepo(ctx, input.Repository, options...)
+ if err == nil {
+ output = *result
+ }
+ return err
+ })
+ return nil, output, err
+ })
+
protocol.AddTool(server, &protocol.Tool{
Name: "list",
Description: "List repositories indexed in the codetrip data directory.",
@@ -156,9 +344,24 @@ func newMCPServer(access *engineAccess) *protocol.Server {
return nil, output, err
})
+ protocol.AddTool(server, &protocol.Tool{
+ Name: "brief",
+ Description: "Build a deterministic, budgeted Agent Context Pack. Provide symbol for one exact symbol/node ID, or query for a natural-language architecture question. Query mode discovers complementary production-code seeds and returns deduplicated source and semantic relationships in one call. Prefer one query-mode call over repeated search + brief calls; omit budgets unless the compact default is demonstrably insufficient.",
+ }, func(ctx context.Context, _ *protocol.CallToolRequest, input codetrip.BriefRequest) (*protocol.CallToolResult, codetrip.BriefResult, error) {
+ var output codetrip.BriefResult
+ err := access.use(ctx, func(engine *codetrip.Engine) error {
+ result, err := engine.Brief(ctx, &input)
+ if err == nil {
+ output = *result
+ }
+ return err
+ })
+ return nil, output, err
+ })
+
protocol.AddTool(server, &protocol.Tool{
Name: "impact",
- Description: "Find callers, importers, implementations, derived types, overrides, and bound entry points affected by changing a graph node.",
+ Description: "Classify confirmed dependents, contract impacts, entry points, tests, and review candidates for a behavior, signature, contract, or removal change. Results include evidence paths and coverage assumptions.",
}, func(ctx context.Context, _ *protocol.CallToolRequest, input codetrip.ImpactRequest) (*protocol.CallToolResult, codetrip.ImpactResult, error) {
var output codetrip.ImpactResult
err := access.use(ctx, func(engine *codetrip.Engine) error {
diff --git a/cmd/codetrip/mcp_setup.go b/cmd/codetrip/mcp_setup.go
index 049a5b3..db8d31c 100644
--- a/cmd/codetrip/mcp_setup.go
+++ b/cmd/codetrip/mcp_setup.go
@@ -25,6 +25,7 @@ type mcpSetupOptions struct {
all bool
dryRun bool
force bool
+ watch bool
clients []string
executable string
dataDir string
@@ -49,6 +50,7 @@ func newMCPSetupCmd(flags *cliFlags) *cobra.Command {
command.Flags().BoolVar(&options.all, "all", false, "configure every supported client that is installed")
command.Flags().BoolVar(&options.dryRun, "dry-run", false, "show changes without applying them")
command.Flags().BoolVar(&options.force, "force", false, "replace an existing Codetrip MCP configuration")
+ command.Flags().BoolVar(&options.watch, "watch", false, "configure the MCP server to refresh indexed repositories after source edits")
return command
}
@@ -205,7 +207,7 @@ func (options *mcpSetupOptions) install(output io.Writer, client string) (string
}
func (options *mcpSetupOptions) addCommand(client string) (string, []string) {
- serverArgs := []string{"mcp", "--dir", options.dataDir}
+ serverArgs := options.serverArgs()
switch client {
case "codex":
return "codex", append([]string{"mcp", "add", mcpServerName, "--", options.executable}, serverArgs...)
@@ -222,6 +224,14 @@ func (options *mcpSetupOptions) addCommand(client string) (string, []string) {
}
}
+func (options *mcpSetupOptions) serverArgs() []string {
+ result := []string{"mcp", "--dir", options.dataDir}
+ if options.watch {
+ result = append(result, "--watch")
+ }
+ return result
+}
+
func (options *mcpSetupOptions) removeCommand(client string) (string, []string) {
switch client {
case "vscode":
@@ -265,7 +275,7 @@ func (options *mcpSetupOptions) installCursor(output io.Writer) (string, error)
}
server := map[string]any{
"command": options.executable,
- "args": []string{"mcp", "--dir", options.dataDir},
+ "args": options.serverArgs(),
}
if existing, exists := servers[mcpServerName]; exists {
if jsonEqual(existing, server) {
diff --git a/cmd/codetrip/mcp_setup_test.go b/cmd/codetrip/mcp_setup_test.go
index 1922a1d..5073405 100644
--- a/cmd/codetrip/mcp_setup_test.go
+++ b/cmd/codetrip/mcp_setup_test.go
@@ -74,6 +74,27 @@ func TestMCPSetupAddCommands(t *testing.T) {
}
}
+func TestMCPSetupWatchAddsServerFlag(t *testing.T) {
+ options := &mcpSetupOptions{executable: "/opt/codetrip", dataDir: "/data/codetrip", watch: true}
+ _, args := options.addCommand("codex")
+ if !reflect.DeepEqual(args[len(args)-2:], []string{"/data/codetrip", "--watch"}) {
+ t.Fatalf("watch command args=%v", args)
+ }
+
+ home := t.TempDir()
+ options.homeDir = home
+ if _, err := options.installCursor(&bytes.Buffer{}); err != nil {
+ t.Fatal(err)
+ }
+ encoded, err := os.ReadFile(filepath.Join(home, ".cursor", "mcp.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !bytes.Contains(encoded, []byte(`"--watch"`)) {
+ t.Fatalf("Cursor configuration does not enable watch: %s", encoded)
+ }
+}
+
func TestMCPSetupCursorPreservesExistingServers(t *testing.T) {
home := t.TempDir()
configPath := filepath.Join(home, ".cursor", "mcp.json")
diff --git a/cmd/codetrip/mcp_test.go b/cmd/codetrip/mcp_test.go
index 58f8b92..ee4f55a 100644
--- a/cmd/codetrip/mcp_test.go
+++ b/cmd/codetrip/mcp_test.go
@@ -4,8 +4,11 @@ import (
"context"
"encoding/json"
"errors"
+ "os"
+ "path/filepath"
"strings"
"testing"
+ "time"
"github.com/mengshi02/codetrip"
protocol "github.com/modelcontextprotocol/go-sdk/mcp"
@@ -14,6 +17,7 @@ import (
func TestMCPServerTools(t *testing.T) {
dataDir := t.TempDir()
access := newEngineAccess(func() (*codetrip.Engine, error) { return codetrip.Open(dataDir) })
+ t.Cleanup(func() { _ = access.close() })
ctx := context.Background()
clientTransport, serverTransport := protocol.NewInMemoryTransports()
@@ -35,10 +39,12 @@ func TestMCPServerTools(t *testing.T) {
t.Fatal(err)
}
want := map[string]bool{
+ "brief": false,
"check": false,
"context": false,
"diff": false,
"impact": false,
+ "index": false,
"list": false,
"search": false,
"source": false,
@@ -77,6 +83,17 @@ func TestMCPServerTools(t *testing.T) {
}
}
}
+ if tool.Name == "brief" {
+ schema, err := json.Marshal(tool.InputSchema)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, text := range []string{"symbol", "query", "maxSymbols"} {
+ if !strings.Contains(string(schema), text) {
+ t.Errorf("brief input schema does not describe %q: %s", text, schema)
+ }
+ }
+ }
}
for name, found := range want {
if !found {
@@ -91,9 +108,29 @@ func TestMCPServerTools(t *testing.T) {
if result.IsError {
t.Fatalf("list returned tool error: %v", result.Content)
}
+ repository := t.TempDir()
+ if err := os.WriteFile(filepath.Join(repository, "main.go"), []byte("package sample\nfunc Run() {}\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ indexed, err := clientSession.CallTool(ctx, &protocol.CallToolParams{Name: "index", Arguments: map[string]any{
+ "repository": repository, "repo": "sample",
+ }})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if indexed.IsError {
+ t.Fatalf("index returned tool error: %v", indexed.Content)
+ }
+ indexed, err = clientSession.CallTool(ctx, &protocol.CallToolParams{Name: "index", Arguments: map[string]any{
+ "repository": repository, "repo": "sample",
+ }})
+ if err != nil || indexed.IsError {
+ t.Fatalf("noop index result=%v error=%v", indexed, err)
+ }
- // An idle MCP server must not retain the Pebble lock. CLI commands open the
- // same directory through a separate Engine instance.
+ // An active request burst retains the Engine, but an idle MCP server must
+ // eventually release the durable-store lock for standalone CLI commands.
+ time.Sleep(access.idleFor + 250*time.Millisecond)
probe, err := codetrip.Open(dataDir)
if err != nil {
t.Fatalf("MCP server retained the engine lock after a request: %v", err)
@@ -103,6 +140,64 @@ func TestMCPServerTools(t *testing.T) {
}
}
+func TestEngineAccessWaitsForObservedWatchGeneration(t *testing.T) {
+ dataDir := t.TempDir()
+ access := newEngineAccess(func() (*codetrip.Engine, error) { return codetrip.Open(dataDir) })
+ generation := access.observe("fixture")
+ finished := make(chan error, 1)
+ go func() {
+ finished <- access.use(context.Background(), func(*codetrip.Engine) error { return nil })
+ }()
+ select {
+ case err := <-finished:
+ t.Fatalf("query passed stale generation barrier: %v", err)
+ case <-time.After(50 * time.Millisecond):
+ }
+ access.applied("fixture", generation, 2)
+ select {
+ case err := <-finished:
+ if err != nil {
+ t.Fatal(err)
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("query did not resume after refresh")
+ }
+}
+
+func TestEngineAccessReusesEngineWithinRequestBurst(t *testing.T) {
+ dataDir := t.TempDir()
+ opens := 0
+ access := newEngineAccess(func() (*codetrip.Engine, error) {
+ opens++
+ return codetrip.Open(dataDir)
+ })
+ t.Cleanup(func() { _ = access.close() })
+ for range 3 {
+ if err := access.use(context.Background(), func(*codetrip.Engine) error { return nil }); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if opens != 1 {
+ t.Fatalf("opened Engine %d times for one request burst, want 1", opens)
+ }
+}
+
+func TestEngineAccessReturnsWatchFailureInsteadOfOpeningStaleEngine(t *testing.T) {
+ opened := false
+ dataDir := t.TempDir()
+ access := newEngineAccess(func() (*codetrip.Engine, error) {
+ opened = true
+ return codetrip.Open(dataDir)
+ })
+ generation := access.observe("fixture")
+ sentinel := errors.New("refresh failed")
+ access.failed("fixture", generation, sentinel)
+ err := access.use(context.Background(), func(*codetrip.Engine) error { return nil })
+ if !errors.Is(err, sentinel) || opened {
+ t.Fatalf("error=%v opened=%v", err, opened)
+ }
+}
+
func TestEngineAccessClosesAfterOperationError(t *testing.T) {
dataDir := t.TempDir()
access := newEngineAccess(func() (*codetrip.Engine, error) { return codetrip.Open(dataDir) })
@@ -111,6 +206,9 @@ func TestEngineAccessClosesAfterOperationError(t *testing.T) {
if !errors.Is(err, sentinel) {
t.Fatalf("use error=%v, want sentinel", err)
}
+ if err := access.close(); err != nil {
+ t.Fatal(err)
+ }
probe, err := codetrip.Open(dataDir)
if err != nil {
t.Fatalf("engine lock was retained after an operation error: %v", err)
diff --git a/cmd/codetrip/mcp_watch.go b/cmd/codetrip/mcp_watch.go
new file mode 100644
index 0000000..8d190c3
--- /dev/null
+++ b/cmd/codetrip/mcp_watch.go
@@ -0,0 +1,380 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/fsnotify/fsnotify"
+ "github.com/mengshi02/codetrip"
+ inc "github.com/mengshi02/codetrip/internal/incremental"
+)
+
+type watchRepository struct {
+ name string
+ path string
+ generation inc.Generation
+}
+
+type repositoryWatchService struct {
+ watcher *fsnotify.Watcher
+ debounce time.Duration
+ log io.Writer
+ list func(context.Context) ([]watchRepository, error)
+ refresh func(context.Context, watchRepository) (*codetrip.IndexResult, error)
+
+ mu sync.Mutex
+ repos map[string]watchRepository
+ dirRepos map[string]map[string]bool
+ pending map[string]time.Time
+ queued map[string]bool
+ refreshC chan watchRepository
+ doneC chan string
+ observe func(string) inc.Generation
+ generations map[string]inc.Generation
+}
+
+func newRepositoryWatchService(
+ debounce time.Duration,
+ log io.Writer,
+ list func(context.Context) ([]watchRepository, error),
+ refresh func(context.Context, watchRepository) (*codetrip.IndexResult, error),
+) (*repositoryWatchService, error) {
+ watcher, err := fsnotify.NewWatcher()
+ if err != nil {
+ return nil, err
+ }
+ if debounce <= 0 {
+ debounce = 750 * time.Millisecond
+ }
+ return &repositoryWatchService{
+ watcher: watcher, debounce: debounce, log: log, list: list, refresh: refresh,
+ repos: make(map[string]watchRepository), dirRepos: make(map[string]map[string]bool),
+ pending: make(map[string]time.Time), queued: make(map[string]bool),
+ generations: make(map[string]inc.Generation),
+ refreshC: make(chan watchRepository, 64), doneC: make(chan string, 64),
+ }, nil
+}
+
+func newMCPRepositoryWatchService(access *engineAccess, debounce time.Duration, log io.Writer) (*repositoryWatchService, error) {
+ service, err := newRepositoryWatchService(debounce, log,
+ func(ctx context.Context) ([]watchRepository, error) {
+ var repositories []watchRepository
+ err := access.useRaw(ctx, func(engine *codetrip.Engine) error {
+ listed, err := engine.ListRepositorySources()
+ if err != nil {
+ return err
+ }
+ for _, repository := range listed {
+ if repository.Path != "" {
+ repositories = append(repositories, watchRepository{name: repository.Name, path: repository.Path})
+ }
+ }
+ return nil
+ })
+ return repositories, err
+ },
+ func(ctx context.Context, repository watchRepository) (*codetrip.IndexResult, error) {
+ var result *codetrip.IndexResult
+ err := access.useRaw(ctx, func(engine *codetrip.Engine) error {
+ var err error
+ result, err = engine.IndexRepo(ctx, repository.path,
+ codetrip.WithRepoName(repository.name),
+ codetrip.WithIndexMode(codetrip.IndexModeAuto),
+ )
+ return err
+ })
+ if err != nil {
+ access.failed(repository.name, repository.generation, err)
+ } else if result != nil {
+ access.applied(repository.name, repository.generation, result.Revision)
+ }
+ return result, err
+ },
+ )
+ if err != nil {
+ return nil, err
+ }
+ service.observe = access.observe
+ return service, nil
+}
+
+func (service *repositoryWatchService) Run(ctx context.Context) error {
+ defer service.watcher.Close()
+ if err := service.reconcile(ctx); err != nil {
+ return fmt.Errorf("discover watched repositories: %w", err)
+ }
+ go service.refreshWorker(ctx)
+
+ debounceTick := time.NewTicker(100 * time.Millisecond)
+ reconcileTick := time.NewTicker(5 * time.Second)
+ defer debounceTick.Stop()
+ defer reconcileTick.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return nil
+ case event, ok := <-service.watcher.Events:
+ if !ok {
+ return nil
+ }
+ service.handleEvent(event)
+ case err, ok := <-service.watcher.Errors:
+ if ok {
+ service.logf("watch error: %v", err)
+ }
+ case repo := <-service.doneC:
+ service.mu.Lock()
+ service.queued[repo] = false
+ service.mu.Unlock()
+ case now := <-debounceTick.C:
+ service.enqueueDue(now)
+ case <-reconcileTick.C:
+ if err := service.reconcile(ctx); err != nil {
+ service.logf("repository discovery failed: %v", err)
+ }
+ }
+ }
+}
+
+func (service *repositoryWatchService) refreshWorker(ctx context.Context) {
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case repository := <-service.refreshC:
+ service.mu.Lock()
+ current, active := service.repos[repository.name]
+ service.mu.Unlock()
+ if !active || current.path != repository.path {
+ select {
+ case service.doneC <- repository.name:
+ case <-ctx.Done():
+ return
+ }
+ continue
+ }
+ started := time.Now()
+ result, err := service.refresh(ctx, repository)
+ if err != nil {
+ service.logf("repo=%s refresh failed: %v", repository.name, err)
+ } else {
+ service.logf("repo=%s mode=%s added=%d modified=%d deleted=%d duration=%.3fs elapsed=%.3fs fallback=%q",
+ repository.name, result.Mode, result.Added, result.Modified, result.Deleted,
+ result.Duration, time.Since(started).Seconds(), result.FallbackReason)
+ }
+ select {
+ case service.doneC <- repository.name:
+ case <-ctx.Done():
+ return
+ }
+ }
+ }
+}
+
+func (service *repositoryWatchService) reconcile(ctx context.Context) error {
+ repositories, err := service.list(ctx)
+ if err != nil {
+ return err
+ }
+ seen := make(map[string]bool, len(repositories))
+ for _, repository := range repositories {
+ seen[repository.name] = true
+ absolute, err := filepath.Abs(repository.path)
+ if err != nil {
+ service.logf("repo=%s resolve source path failed: %v", repository.name, err)
+ continue
+ }
+ repository.path = filepath.Clean(absolute)
+ service.mu.Lock()
+ known, exists := service.repos[repository.name]
+ service.mu.Unlock()
+ if exists && known.path == repository.path {
+ continue
+ }
+ if exists {
+ service.removeRepository(repository.name)
+ }
+ if err := service.addRepository(repository); err != nil {
+ service.logf("repo=%s watch setup failed: %v", repository.name, err)
+ }
+ }
+ service.mu.Lock()
+ var removed []string
+ for name := range service.repos {
+ if !seen[name] {
+ removed = append(removed, name)
+ }
+ }
+ service.mu.Unlock()
+ for _, name := range removed {
+ service.removeRepository(name)
+ service.logf("repo=%s stopped", name)
+ }
+ return nil
+}
+
+func (service *repositoryWatchService) removeRepository(name string) {
+ service.mu.Lock()
+ var directories []string
+ for directory, repoNames := range service.dirRepos {
+ if repoNames[name] {
+ delete(repoNames, name)
+ if len(repoNames) == 0 {
+ directories = append(directories, directory)
+ delete(service.dirRepos, directory)
+ }
+ }
+ }
+ delete(service.repos, name)
+ delete(service.pending, name)
+ delete(service.queued, name)
+ service.mu.Unlock()
+ for _, directory := range directories {
+ _ = service.watcher.Remove(directory)
+ }
+}
+
+func (service *repositoryWatchService) addRepository(repository watchRepository) error {
+ info, err := os.Stat(repository.path)
+ if err != nil || !info.IsDir() {
+ return fmt.Errorf("source path %q is unavailable", repository.path)
+ }
+ added, err := service.addDirectoryTree(repository.path, repository.name)
+ if err != nil {
+ service.removeWatchedDirectories(repository.name, added)
+ return err
+ }
+ service.mu.Lock()
+ service.repos[repository.name] = repository
+ service.mu.Unlock()
+ service.logf("repo=%s watching=%s", repository.name, repository.path)
+ return nil
+}
+
+func (service *repositoryWatchService) addDirectoryTree(root, repoName string) ([]string, error) {
+ associated := make([]string, 0)
+ err := filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error {
+ if walkErr != nil {
+ return nil
+ }
+ if !entry.IsDir() {
+ return nil
+ }
+ if path != root && ignoreWatchDirectory(entry.Name()) {
+ return filepath.SkipDir
+ }
+ path = filepath.Clean(path)
+ service.mu.Lock()
+ repositories := service.dirRepos[path]
+ alreadyWatched := repositories != nil
+ if repositories == nil {
+ repositories = make(map[string]bool)
+ service.dirRepos[path] = repositories
+ }
+ repositories[repoName] = true
+ service.mu.Unlock()
+ associated = append(associated, path)
+ if alreadyWatched {
+ return nil
+ }
+ if err := service.watcher.Add(path); err != nil {
+ service.mu.Lock()
+ delete(repositories, repoName)
+ if len(repositories) == 0 {
+ delete(service.dirRepos, path)
+ }
+ service.mu.Unlock()
+ return err
+ }
+ return nil
+ })
+ return associated, err
+}
+
+func (service *repositoryWatchService) removeWatchedDirectories(repoName string, directories []string) {
+ for _, directory := range directories {
+ service.mu.Lock()
+ repositories := service.dirRepos[directory]
+ delete(repositories, repoName)
+ remove := len(repositories) == 0
+ if remove {
+ delete(service.dirRepos, directory)
+ }
+ service.mu.Unlock()
+ if remove {
+ _ = service.watcher.Remove(directory)
+ }
+ }
+}
+
+func (service *repositoryWatchService) handleEvent(event fsnotify.Event) {
+ if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Remove|fsnotify.Rename) == 0 {
+ return
+ }
+ directory := filepath.Clean(filepath.Dir(event.Name))
+ service.mu.Lock()
+ repoNames := make([]string, 0, len(service.dirRepos[directory]))
+ for repoName := range service.dirRepos[directory] {
+ repoNames = append(repoNames, repoName)
+ service.pending[repoName] = time.Now().Add(service.debounce)
+ if service.observe != nil {
+ service.generations[repoName] = service.observe(repoName)
+ }
+ }
+ service.mu.Unlock()
+ if len(repoNames) == 0 || event.Op&fsnotify.Create == 0 {
+ return
+ }
+ if info, err := os.Stat(event.Name); err == nil && info.IsDir() && !ignoreWatchDirectory(info.Name()) {
+ for _, repoName := range repoNames {
+ associated, err := service.addDirectoryTree(event.Name, repoName)
+ if err != nil {
+ service.removeWatchedDirectories(repoName, associated)
+ service.logf("repo=%s watch new directory failed: %v", repoName, err)
+ }
+ }
+ }
+}
+
+func (service *repositoryWatchService) enqueueDue(now time.Time) {
+ service.mu.Lock()
+ defer service.mu.Unlock()
+ for name, deadline := range service.pending {
+ if now.Before(deadline) || service.queued[name] {
+ continue
+ }
+ repository, exists := service.repos[name]
+ if !exists {
+ delete(service.pending, name)
+ continue
+ }
+ service.queued[name] = true
+ delete(service.pending, name)
+ repository.generation = service.generations[name]
+ service.refreshC <- repository
+ }
+}
+
+func ignoreWatchDirectory(name string) bool {
+ if strings.HasPrefix(name, ".") {
+ return true
+ }
+ switch strings.ToLower(name) {
+ case "node_modules", "vendor", "dist", "build", "target", "coverage", "out", "bin", "obj", "__pycache__", ".codetrip":
+ return true
+ default:
+ return strings.HasSuffix(strings.ToLower(name), "-csv")
+ }
+}
+
+func (service *repositoryWatchService) logf(format string, values ...any) {
+ if service.log != nil {
+ fmt.Fprintf(service.log, "[codetrip watch] "+format+"\n", values...)
+ }
+}
diff --git a/cmd/codetrip/mcp_watch_test.go b/cmd/codetrip/mcp_watch_test.go
new file mode 100644
index 0000000..f45b624
--- /dev/null
+++ b/cmd/codetrip/mcp_watch_test.go
@@ -0,0 +1,174 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "os"
+ "path/filepath"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/mengshi02/codetrip"
+)
+
+func TestRepositoryWatchServiceDebouncesSourceChanges(t *testing.T) {
+ repository := t.TempDir()
+ if err := os.Mkdir(filepath.Join(repository, ".git"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ path := filepath.Join(repository, "main.ts")
+ if err := os.WriteFile(path, []byte("export function run() {}\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ var calls atomic.Int32
+ refreshed := make(chan struct{}, 4)
+ var logs bytes.Buffer
+ service, err := newRepositoryWatchService(100*time.Millisecond, &logs,
+ func(context.Context) ([]watchRepository, error) {
+ return []watchRepository{{name: "fixture", path: repository}}, nil
+ },
+ func(context.Context, watchRepository) (*codetrip.IndexResult, error) {
+ calls.Add(1)
+ refreshed <- struct{}{}
+ return &codetrip.IndexResult{Repo: "fixture", Mode: "incremental", Modified: 1}, nil
+ },
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ done := make(chan error, 1)
+ go func() { done <- service.Run(ctx) }()
+ t.Cleanup(func() {
+ cancel()
+ if err := <-done; err != nil {
+ t.Errorf("watch service stopped with error: %v", err)
+ }
+ })
+
+ deadline := time.Now().Add(2 * time.Second)
+ for len(service.watcher.WatchList()) == 0 && time.Now().Before(deadline) {
+ time.Sleep(10 * time.Millisecond)
+ }
+ if len(service.watcher.WatchList()) == 0 {
+ t.Fatal("repository was not watched")
+ }
+ for index := 0; index < 3; index++ {
+ if err := os.WriteFile(path, []byte("export function run() { return 1; }\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ select {
+ case <-refreshed:
+ case <-time.After(3 * time.Second):
+ t.Fatalf("source edit did not trigger refresh; logs=%s", logs.String())
+ }
+ time.Sleep(350 * time.Millisecond)
+ if got := calls.Load(); got != 1 {
+ t.Fatalf("refresh calls=%d, want one debounced update; logs=%s", got, logs.String())
+ }
+}
+
+func TestRepositoryWatchServiceSkipsIgnoredDirectories(t *testing.T) {
+ repository := t.TempDir()
+ ignored := filepath.Join(repository, "node_modules")
+ if err := os.Mkdir(ignored, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ var calls atomic.Int32
+ service, err := newRepositoryWatchService(50*time.Millisecond, nil,
+ func(context.Context) ([]watchRepository, error) {
+ return []watchRepository{{name: "fixture", path: repository}}, nil
+ },
+ func(context.Context, watchRepository) (*codetrip.IndexResult, error) {
+ calls.Add(1)
+ return &codetrip.IndexResult{Mode: "noop"}, nil
+ },
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ done := make(chan error, 1)
+ go func() { done <- service.Run(ctx) }()
+ deadline := time.Now().Add(2 * time.Second)
+ for len(service.watcher.WatchList()) == 0 && time.Now().Before(deadline) {
+ time.Sleep(10 * time.Millisecond)
+ }
+ if err := os.WriteFile(filepath.Join(ignored, "package.js"), []byte("changed\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ time.Sleep(250 * time.Millisecond)
+ cancel()
+ if err := <-done; err != nil {
+ t.Fatal(err)
+ }
+ if calls.Load() != 0 {
+ t.Fatal("ignored dependency change triggered refresh")
+ }
+}
+
+func TestMCPRepositoryWatchServiceRefreshesPersistedSource(t *testing.T) {
+ repository := t.TempDir()
+ path := filepath.Join(repository, "main.ts")
+ if err := os.WriteFile(path, []byte("export function run() { return 1; }\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ dataDir := t.TempDir()
+ engine, err := codetrip.Open(dataDir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := engine.IndexRepo(context.Background(), repository,
+ codetrip.WithRepoName("fixture"), codetrip.WithIndexMode(codetrip.IndexModeAuto)); err != nil {
+ t.Fatal(err)
+ }
+ if err := engine.Close(); err != nil {
+ t.Fatal(err)
+ }
+
+ access := newEngineAccess(func() (*codetrip.Engine, error) { return codetrip.Open(dataDir) })
+ var logs bytes.Buffer
+ service, err := newMCPRepositoryWatchService(access, 100*time.Millisecond, &logs)
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ done := make(chan error, 1)
+ go func() { done <- service.Run(ctx) }()
+ deadline := time.Now().Add(2 * time.Second)
+ for len(service.watcher.WatchList()) == 0 && time.Now().Before(deadline) {
+ time.Sleep(10 * time.Millisecond)
+ }
+ if err := os.WriteFile(path, []byte("export function run() { return 2; }\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ updated := false
+ deadline = time.Now().Add(5 * time.Second)
+ for time.Now().Before(deadline) {
+ err := access.use(context.Background(), func(engine *codetrip.Engine) error {
+ result, err := engine.SearchSource(context.Background(), &codetrip.SourceSearchRequest{
+ Repo: "fixture", Query: "return 2", Limit: 5,
+ })
+ updated = err == nil && len(result.Results) > 0
+ return err
+ })
+ if err == nil && updated {
+ break
+ }
+ time.Sleep(50 * time.Millisecond)
+ }
+ cancel()
+ if err := <-done; err != nil {
+ t.Fatal(err)
+ }
+ if !updated {
+ t.Fatalf("watch refresh did not publish updated source; logs=%s", logs.String())
+ }
+ if !bytes.Contains(logs.Bytes(), []byte("mode=incremental")) {
+ t.Fatalf("watch refresh did not report incremental mode: %s", logs.String())
+ }
+}
diff --git a/cmd/codetrip/output.go b/cmd/codetrip/output.go
index 432cd24..6940e8f 100644
--- a/cmd/codetrip/output.go
+++ b/cmd/codetrip/output.go
@@ -66,6 +66,20 @@ func selectNodeID(input string, matches []codetrip.SearchItem) (string, error) {
}
func writeImpact(output io.Writer, result *codetrip.ImpactResult, format string) error {
+ switch strings.ToLower(strings.TrimSpace(format)) {
+ case "", "json":
+ // JSON is the Agent/script format. Keep it compact; terminal users have
+ // the purpose-built text format instead of paying indentation tokens.
+ return json.NewEncoder(output).Encode(result)
+ case "tree":
+ _, err := fmt.Fprint(output, formatImpactTree(result))
+ return err
+ default:
+ return fmt.Errorf("unsupported impact format %q: expected json or tree", format)
+ }
+}
+
+func writeBrief(output io.Writer, result *codetrip.BriefResult, format string) error {
switch strings.ToLower(strings.TrimSpace(format)) {
case "", "json":
encoded, err := json.MarshalIndent(result, "", " ")
@@ -74,14 +88,87 @@ func writeImpact(output io.Writer, result *codetrip.ImpactResult, format string)
}
_, err = fmt.Fprintln(output, string(encoded))
return err
- case "tree":
- _, err := fmt.Fprint(output, formatImpactTree(result))
+ case "text":
+ _, err := fmt.Fprint(output, formatBriefText(result))
return err
default:
- return fmt.Errorf("unsupported impact format %q: expected json or tree", format)
+ return fmt.Errorf("unsupported brief format %q: expected json or text", format)
}
}
+func formatBriefText(result *codetrip.BriefResult) string {
+ if result == nil {
+ return ""
+ }
+ if len(result.Contexts) > 0 {
+ var output strings.Builder
+ fmt.Fprintf(&output, "Query: %s\nContexts: %d\n", result.Query, len(result.Contexts))
+ for index := range result.Contexts {
+ fmt.Fprintf(&output, "\n--- Context %d ---\n", index+1)
+ output.WriteString(formatBriefText(&codetrip.BriefResult{BriefContext: result.Contexts[index]}))
+ }
+ return output.String()
+ }
+ var output strings.Builder
+ fmt.Fprintln(&output, formatGraphNode(result.Symbol))
+ if result.Symbol.Label != "" || result.Symbol.Language != "" {
+ fmt.Fprintf(&output, "Type: %s", result.Symbol.Label)
+ if result.Symbol.Language != "" {
+ fmt.Fprintf(&output, " · %s", result.Symbol.Language)
+ }
+ output.WriteByte('\n')
+ }
+ if result.Signature != "" {
+ fmt.Fprintf(&output, "Signature: %s\n", result.Signature)
+ }
+ writeBriefRelations := func(title string, relations []codetrip.BriefRelation) {
+ if len(relations) == 0 {
+ return
+ }
+ fmt.Fprintf(&output, "\n%s (%d)\n", title, len(relations))
+ for _, relation := range relations {
+ arrow := "→"
+ if relation.Direction == "in" {
+ arrow = "←"
+ }
+ fmt.Fprintf(&output, " %s %s %s", arrow, relation.Type, formatGraphNode(relation.Node))
+ if relation.Confidence > 0 {
+ fmt.Fprintf(&output, " confidence=%.2f", relation.Confidence)
+ }
+ if relation.Reason != "" {
+ fmt.Fprintf(&output, " reason=%s", relation.Reason)
+ }
+ output.WriteByte('\n')
+ }
+ }
+ writeBriefRelations("Contracts", result.Contracts)
+ writeBriefRelations("Implementations", result.Implementations)
+ writeBriefRelations("Entrypoints", result.Entrypoints)
+ writeBriefRelations("Incoming", result.Incoming)
+ writeBriefRelations("Outgoing", result.Outgoing)
+ if len(result.RelatedTests) > 0 {
+ fmt.Fprintf(&output, "\nRelated tests (%d)\n", len(result.RelatedTests))
+ for _, test := range result.RelatedTests {
+ fmt.Fprintf(&output, " %s", test.FilePath)
+ if test.Line > 0 {
+ fmt.Fprintf(&output, ":%d", test.Line)
+ }
+ fmt.Fprintf(&output, " confidence=%.2f reason=%s\n", test.Confidence, test.Reason)
+ }
+ }
+ fmt.Fprintf(&output, "\nImpact: %d direct dependents across %d files; %d callers, %d implementations, %d entrypoints\n",
+ result.Impact.DirectDependents, result.Impact.AffectedFiles, result.Impact.Callers,
+ result.Impact.Implementations, result.Impact.Entrypoints)
+ if result.Source != "" {
+ fmt.Fprintln(&output, "\nSource")
+ fmt.Fprintln(&output, result.Source)
+ }
+ if result.Truncated {
+ fmt.Fprintf(&output, "\nTruncated: %s\n", strings.Join(result.Truncation, "; "))
+ }
+ return output.String()
+}
+
func formatImpactTree(result *codetrip.ImpactResult) string {
if result == nil {
return ""
@@ -105,6 +192,9 @@ func formatImpactTree(result *codetrip.ImpactResult) string {
var output strings.Builder
fmt.Fprintln(&output, formatGraphNode(result.Origin))
+ if result.ChangeKind != "" {
+ fmt.Fprintf(&output, "Change: %s (%s)\n", result.ChangeKind, result.Coverage.Assumption)
+ }
var renderChildren func(string, string)
renderChildren = func(parentID, prefix string) {
nodes := children[parentID]
@@ -114,7 +204,15 @@ func formatImpactTree(result *codetrip.ImpactResult) string {
if last {
branch, nextPrefix = "└── ", prefix+" "
}
- fmt.Fprintf(&output, "%s%s%s ← %s\n", prefix, branch, impacted.Via.Type, formatGraphNode(impacted.Node))
+ review := ""
+ if impacted.RequiresReview {
+ review = " [review]"
+ }
+ relation := impacted.Via.Type
+ if impacted.Category != "" {
+ relation = impacted.Category + "/" + relation
+ }
+ fmt.Fprintf(&output, "%s%s%s%s ← %s\n", prefix, branch, relation, review, formatGraphNode(impacted.Node))
renderChildren(impacted.Node.ID, nextPrefix)
}
}
@@ -134,6 +232,11 @@ func formatImpactTree(result *codetrip.ImpactResult) string {
output.WriteString(" (truncated)")
}
output.WriteByte('\n')
+ if result.ChangeKind != "" {
+ fmt.Fprintf(&output, "Confirmed %d · Contracts %d · Entries %d · Tests %d · Review %d\n",
+ result.Coverage.Confirmed, result.Coverage.Contract, result.Coverage.EntryPoints,
+ result.Coverage.RelatedTests, result.Coverage.ReviewCandidates)
+ }
return output.String()
}
diff --git a/docs/AGENT_VALUE.md b/docs/AGENT_VALUE.md
new file mode 100644
index 0000000..9a6e31b
--- /dev/null
+++ b/docs/AGENT_VALUE.md
@@ -0,0 +1,133 @@
+# Agent value and command guide
+
+[English](AGENT_VALUE.md) | [简体中文](AGENT_VALUE_ZH.md)
+
+Codetrip is most useful when it replaces repeated Agent search/read/reason
+cycles instead of adding another search box. This document contains the detail
+intentionally kept out of the project homepage.
+
+## Command value map
+
+| Command | Replaces this Agent tool loop | Concrete value |
+|---|---|---|
+| `brief` | `grep → read definition → find references → read callers → find implementations → search tests` | One bounded result returns source, typed callers/callees, contracts, related tests, impact counts, confidence, and evidence. A 20-task Codex A/B evaluation measured **7.0% fewer commands, 5.4% fewer input tokens, and 7.7% fewer output tokens**, without observed answer-quality regression. |
+| `index` | Continue from a stale graph, or rebuild the entire repository after every edit | `mcp --watch` detects and debounces edits, blocks stale reads after observing a change, and atomically publishes graph, symbol, source, vector invalidation, and metadata at one revision. Explicit MCP `index` remains available for read-after-write refresh and recovery. |
+| `source` | `find files/guess path → grep → read around every hit` | Regex plus context lines searches and reads matches in one call. A blinded 20-task Codex A/B evaluation measured **32.8% fewer commands, 50.0% fewer file reads, 29.6% fewer input tokens, and 18.5% fewer output tokens**; both arms passed 20/20 quality reviews. `code/docs/all` scopes prevent documentation from drowning code. |
+| `search` | `find files → grep declarations → inspect same-name matches` | Returns indexed symbols, stable node IDs, and locations so an Agent can disambiguate definitions before reading source. |
+| `context` | `read definition → find callers → find callees → inspect interfaces and overrides` | Returns the direct typed neighborhood without File/Folder noise. Relationships include direction, confidence, and resolution evidence. |
+| `impact` | Recursively `find references → read caller → find references again → deduplicate` | Separates confirmed dependents, contract impacts, entry points, tests, and review candidates for a declared change kind. Reviewed Golden Facts measured **100% precision and 89.9% recall**. No Agent-efficiency claim is made because the post-remediation A/B rerun was invalidated by failed tool invocations. |
+| `diff` | `git diff → map lines to functions → inspect every function's references → merge risk` | Converts hunks to changed symbols and aggregated downstream impact so review starts from semantic risk. |
+| `path` | Repeat cross-file caller search and source reads for every hop | Returns the shortest typed route between two symbols, including every relationship and its evidence. |
+| `traverse` | Query callers or callees one level at a time | Performs bounded BFS filtered by direction, depth, and relation type, returning nodes and traversed edges together. |
+| `rename` | `grep identifier → inspect every text hit → guess conflicts` | Separates declarations and graph-backed references from comments, strings, reflection, and review candidates. It detects conflicts and produces a plan without editing source. |
+| `check` | Wait for a build failure or manually inspect graph anomalies | Detects dangling endpoints, invalid self-dependencies, inheritance cycles, import cycles, and low-confidence relationships before commit or CI. |
+| `hybrid` | Rephrase a concept until lexical search happens to match project vocabulary | Fuses lexical and optional vector retrieval when the behavior is known but the exact symbol name is not. |
+| `mcp setup` | Locate and hand-edit each Agent's MCP configuration | Detects and configures Codex, Claude Code, Cursor, VS Code/Copilot, and GitHub Copilot CLI while preserving unrelated MCP servers. |
+
+Step reductions without percentages describe directly observable tool-call
+structure, not full-task benchmark results. A measured cost or speed claim is
+published only when the evaluation also checks answer quality.
+
+## Revision consistency is a separate capability
+
+Incremental parsing answers “how much work is repeated?” Revision consistency
+answers a different question: “what can a query observe while that work is in
+progress?” Codetrip couples four properties:
+
+1. language-neutral change fingerprints and semantic invalidation closure;
+2. graph, symbol, source, vector invalidation, and metadata in one revision;
+3. an MCP freshness barrier after a filesystem event is observed;
+4. journal recovery plus atomic full fallback when a local delta is unsafe.
+
+The result is an explicit query contract: readers see a complete old revision
+or wait for the complete new revision, never a mixture of independently
+settling indexes.
+
+Public documentation and reproducible behavior reviewed through 2026-08-10
+show a meaningful distinction:
+
+- [GitNexus](https://github.com/nxpatterns/gitnexus) documents post-tool hooks
+ that detect a stale index and prompt an Agent to reindex; incremental indexing
+ is still listed in its active roadmap.
+- [CodeGraph](https://github.com/colbymchenry/codegraph) v1.5.0 documents an
+ automatic watcher, per-file staleness banners, and connect-time catch-up. In
+ the five-run Gin test it converged quickly, but produced 17 mixed-revision
+ responses before convergence; 13 carried the documented stale warning.
+- [codebase-memory-mcp](https://github.com/DeusData/codebase-memory-mcp) v0.9.0
+ returned new on-disk source with an old callee in its immediate automatic
+ lane. Its documented `index_repository` workflow produced a clean-full-
+ equivalent result after an explicit refresh.
+- [Joern's CPG specification](https://cpg.joern.io/) defines optional hashes
+ useful in incremental pipelines, but does not define atomic multi-index
+ publication or an Agent query-freshness contract.
+- [FalkorDB code-graph](https://github.com/FalkorDB/code-graph) documents index
+ and auto-index operations, but not a live incremental query-consistency contract.
+
+None of those public documents specifies the same multi-index atomic revision
+and freshness barrier. This supports “distinct among the reviewed leading
+open-source tools,” not an absolute “world's only” claim: private systems,
+unpublished behavior, and future releases cannot be ruled out.
+
+In that live-context scenario, Codetrip used one product operation versus four
+for CodeGraph and three for CBM: **67%–75% fewer product tool round trips**.
+This is not presented as equivalent token savings. See the
+[protocol and machine-readable result](../benchmarks/live_context/README.md).
+
+## Common queries
+
+| Goal | Command |
+|---|---|
+| Understand one symbol | `codetrip brief ParseConfig --repo project --format text` |
+| Search source without knowing the file | `codetrip source 'Parse.*Config' --repo project --context 5` |
+| Find reverse change impact | `codetrip impact ParseConfig --repo project --format tree` |
+| Analyze a Git diff | `codetrip diff HEAD~1 --target HEAD --repo project` |
+| Trace a directed route | `codetrip path LoadConfig ParseConfig --repo project` |
+| Explore callers | `codetrip traverse ParseConfig --repo project --direction in --relations CALLS` |
+| Plan a rename without editing files | `codetrip rename ParseConfig NewName --repo project` |
+
+The MCP server exposes the same core operations:
+
+```text
+index list brief search source context impact check diff rename traverse path
+```
+
+## Why a code graph?
+
+| Plain text or vector search | Codetrip |
+|---|---|
+| Finds matching or similar text | Resolves symbols and typed relationships |
+| Shows isolated occurrences | Connects calls, imports, inheritance, implementation, and overrides |
+| Does not know dependency direction | Traverses forward and reverse dependencies |
+| Leaves change impact to the reader | Maps Git changes to symbols and affected code |
+| Treats rename hits alike | Separates semantic references from review candidates |
+
+Source and symbol search remain first-class capabilities. The graph adds
+structural context where search alone cannot answer the question.
+
+## Product shape and alternatives
+
+These projects overlap but optimize for different operating models:
+
+| Project | Best fit | Deployment model | Current strengths |
+|---|---|---|---|
+| **Codetrip** | Local structural context for coding agents, CLI users, and embedded tools | One native executable; local MCP; Go library | Revision-consistent incremental refresh, atomic repository storage, Git-diff-to-symbol impact, typed graph queries, no required model service |
+| [GitNexus](https://github.com/abhigyanpatwari/GitNexus) | Rich graph-agent workflows and exploration | Node/npm CLI, MCP, skills/hooks, Web UI | Execution flows, clusters, Cypher, repository groups, PDG/taint tools, broad Agent automation |
+| [Serena](https://github.com/oraios/serena) | IDE-like semantic retrieval and source editing | MCP backed by language servers or JetBrains | Broad language-server integration, symbolic editing, refactoring, and diagnostics |
+| [Sourcegraph](https://sourcegraph.com/docs) | Organization-wide search and navigation | Managed/enterprise service plus IDE and Web integrations | Multi-repository and code-host search, history search, SCIP navigation, enterprise scale |
+
+Choose Codetrip when local deployment, deterministic snapshots, a native
+binary, and embeddability matter more than a Web UI or full Agent workflow
+suite. This table describes public product shape; it is not a cross-product
+speed or accuracy benchmark.
+
+## Evidence
+
+- [`brief` controlled Agent A/B evaluation](V0.3.0_AGENT_AB.md)
+- [`source` blinded Agent A/B evaluation](V0.3.0_SOURCE_AGENT_AB.md)
+- [`impact` correctness baseline and invalidated rerun](V0.3.0_IMPACT_AGENT_AB.md)
+- [v0.3.0 real-repository validation baseline](V0.3.0_BASELINE.md)
+- [semantic quality report](QUALITY.md)
+- [indexing benchmarks](BENCHMARKS.md)
+
+For complete flags, MCP schemas, storage, embeddings, CSV export, and
+limitations, see the [user guide](USER_GUIDE.md).
diff --git a/docs/AGENT_VALUE_ZH.md b/docs/AGENT_VALUE_ZH.md
new file mode 100644
index 0000000..7488d96
--- /dev/null
+++ b/docs/AGENT_VALUE_ZH.md
@@ -0,0 +1,119 @@
+# Agent 价值与命令指南
+
+[English](AGENT_VALUE.md) | [简体中文](AGENT_VALUE_ZH.md)
+
+Codetrip 的核心价值不是增加一个搜索框,而是替代 Agent 反复执行的
+“搜索—读文件—继续搜索—人工拼接”循环。本文承载项目首页有意省略的详细材料。
+
+## 命令价值地图
+
+| 命令 | 替代的 Agent 工具链 | 具体用户价值 |
+|---|---|---|
+| `brief` | `grep → 读取定义 → 查引用 → 读调用者 → 查实现类 → 搜索测试` | 一次返回受限源码、类型化调用关系、接口契约、相关测试、影响统计、置信度和证据。20 任务 Codex A/B 测试测得**命令数减少 7.0%、输入 token 减少 5.4%、输出 token 减少 7.7%**,且未观察到答案质量下降。 |
+| `index` | 修改后继续使用旧图,或每次完整重建仓库 | `mcp --watch` 检测并合并保存事件;观察到变化后阻止过期读取,并将图谱、符号、源码、向量失效状态和元数据原子发布到同一 revision。MCP `index` 仍可用于显式刷新和恢复。 |
+| `source` | `查找文件/猜路径 → grep → 逐个读取命中上下文` | 正则和上下文行一次完成搜索与读取。20 任务盲评 A/B 测试测得**命令数减少 32.8%、文件读取减少 50.0%、输入 token 减少 29.6%、输出 token 减少 18.5%**,两组均通过 20/20 质量评审;`code/docs/all` 避免文档淹没代码。 |
+| `search` | `查找文件 → grep 声明 → 检查同名结果` | 返回已索引符号、稳定节点 ID 和位置,让 Agent 在读源码前消除定义歧义。 |
+| `context` | `读取定义 → 查调用者 → 查被调用者 → 查接口和重写` | 一次返回无 File/Folder 噪音的直接类型化邻域,每条关系包含方向、置信度和解析证据。 |
+| `impact` | 递归执行 `查引用 → 读调用者 → 再查引用 → 手工去重` | 针对指定改动类型,区分确定依赖、契约影响、入口、测试和待核验候选。人工复核 Golden Facts 达到 **100% Precision、89.9% Recall**。修复后的 Agent A/B 重跑因工具调用失败而作废,因此不宣传 Agent 提效数据。 |
+| `diff` | `git diff → 将行映射到函数 → 检查每个函数的引用 → 合并风险` | 将 hunk 转换为修改符号和聚合后的下游影响,让审查直接从语义风险开始。 |
+| `path` | 每一跳都跨文件搜索调用者并读取源码 | 一次返回两个符号之间的最短类型化路径,以及每条关系和证据。 |
+| `traverse` | 每次只查询一层调用者或被调用者 | 按方向、深度和关系类型执行有界 BFS,同时返回节点和实际经过的边。 |
+| `rename` | `grep 标识符 → 检查所有文本命中 → 猜测冲突` | 区分声明、图谱支持的引用、注释、字符串、反射和待核验候选;检测冲突,只生成计划、不编辑源码。 |
+| `check` | 等待构建失败,或人工检查图谱异常 | 在提交或 CI 前检查悬空端点、非法自依赖、继承环、导入环和低置信度关系。 |
+| `hybrid` | 不断改写自然语言,直到词法搜索碰巧匹配项目术语 | 当知道功能含义但不知道准确符号名时,融合词法和可选向量检索。 |
+| `mcp setup` | 查找并手工修改不同 Agent 的 MCP 配置 | 检测并配置 Codex、Claude Code、Cursor、VS Code/Copilot 和 GitHub Copilot CLI,同时保留无关 MCP 服务。 |
+
+没有百分比的步骤缩减只描述可直接观察的调用结构,不等同于完整任务基准。只有评测
+同时检查答案质量时,项目才会公布实测速度或成本结论。
+
+## Revision 一致性是一项独立能力
+
+增量解析回答“需要重复多少计算”,revision 一致性回答另一个问题:“计算进行时,
+查询能够看到什么”。Codetrip 将四项能力组合为一个契约:
+
+1. 语言无关的变化指纹与语义失效闭包;
+2. 图谱、符号、源码、向量失效状态和元数据共同进入一个 revision;
+3. 文件事件被观察后,MCP 通过新鲜度屏障阻止旧数据查询;
+4. 局部差量不安全时,通过日志恢复和原子全量回退保证可查询状态。
+
+因此查询只能读取完整旧 revision,或等待完整新 revision,不会读取多个索引分别
+收敛形成的混合状态。
+
+截至 2026-08-10 审查的公开资料和可复现行为体现了明确差异:
+
+- [GitNexus](https://github.com/nxpatterns/gitnexus) 公开说明通过工具调用后的 hook
+ 检测过期索引并提示 Agent 重新索引;增量索引仍列在正在推进的路线图中。
+- [CodeGraph](https://github.com/colbymchenry/codegraph) v1.5.0 公开说明自动
+ watcher、文件级过期提示和连接时补齐机制。在五轮 Gin 测试中它收敛很快,但收敛前
+ 返回了 17 次混合 revision,其中 13 次带有公开说明中的过期警告。
+- [codebase-memory-mcp](https://github.com/DeusData/codebase-memory-mcp) v0.9.0
+ 在立即自动查询中返回了新磁盘源码和旧 callee;显式执行其 `index_repository`
+ 工作流后,结果才与干净全量索引等价。
+- [Joern CPG 规范](https://cpg.joern.io/) 定义了可供增量管线使用的可选 hash,
+ 但没有定义多索引原子发布或面向 Agent 的查询新鲜度契约。
+- [FalkorDB code-graph](https://github.com/FalkorDB/code-graph) 公开了索引和自动索引
+ 操作,但没有公开实时增量查询的一致性契约。
+
+这些公开资料都没有说明与 Codetrip 相同的多索引原子 revision 和新鲜度屏障。
+因此可以宣传“在已审查的主流开源工具中具有明确差异”,但不应写“全球唯一”:
+我们无法排除私有系统、未公开实现或其他项目的未来版本。
+
+在该实时上下文场景中,Codetrip 使用一次产品操作,CodeGraph 使用四次,CBM 使用
+三次,即 Codetrip **减少 67%–75% 的产品工具往返**。这不代表等比例的 token
+节省。参见[测试协议和机器可读结果](../benchmarks/live_context/README.md)。
+
+## 常用查询
+
+| 目标 | 命令 |
+|---|---|
+| 理解一个符号 | `codetrip brief ParseConfig --repo project --format text` |
+| 不知道文件路径时搜索源码 | `codetrip source 'Parse.*Config' --repo project --context 5` |
+| 查找反向改动影响 | `codetrip impact ParseConfig --repo project --format tree` |
+| 分析 Git 差异 | `codetrip diff HEAD~1 --target HEAD --repo project` |
+| 追踪有向路径 | `codetrip path LoadConfig ParseConfig --repo project` |
+| 探索调用者 | `codetrip traverse ParseConfig --repo project --direction in --relations CALLS` |
+| 在不编辑文件的情况下规划重命名 | `codetrip rename ParseConfig NewName --repo project` |
+
+MCP Server 提供相同的核心操作:
+
+```text
+index list brief search source context impact check diff rename traverse path
+```
+
+## 为什么使用代码图?
+
+| 纯文本或向量搜索 | Codetrip |
+|---|---|
+| 查找匹配或相似文本 | 解析符号及类型化关系 |
+| 显示孤立命中 | 连接调用、导入、继承、实现和重写 |
+| 不知道依赖方向 | 沿正向和反向依赖关系遍历 |
+| 由读者自行判断改动影响 | 将 Git 修改映射到符号和受影响代码 |
+| 将重命名命中一视同仁 | 区分语义引用和待核验候选 |
+
+源码和符号搜索仍是一等能力;代码图负责回答仅靠搜索无法解决的结构性问题。
+
+## 产品定位与替代方案
+
+这些项目存在能力重叠,但针对不同运行模式优化:
+
+| 项目 | 最适合 | 部署模式 | 当前优势 |
+|---|---|---|---|
+| **Codetrip** | 编程 Agent、CLI 用户和嵌入式工具的本地结构化上下文 | 单个原生可执行文件、本地 MCP、Go Lib | revision 一致的增量刷新、原子仓库存储、Git diff 到符号影响、类型化图查询、不要求模型服务 |
+| [GitNexus](https://github.com/abhigyanpatwari/GitNexus) | 丰富的图 Agent 工作流与探索 | Node/npm CLI、MCP、技能/钩子、Web UI | 执行流程、聚类、Cypher、仓库组、PDG/污点工具和广泛的 Agent 自动化 |
+| [Serena](https://github.com/oraios/serena) | 类 IDE 的语义检索和源码编辑 | 由语言服务器或 JetBrains 支持的 MCP | 广泛的语言服务器集成、符号化编辑、重构和诊断 |
+| [Sourcegraph](https://sourcegraph.com/docs) | 组织级搜索和导航 | 托管/企业服务及 IDE、Web 集成 | 多仓库和代码托管搜索、历史搜索、SCIP 导航及企业规模 |
+
+如果本地部署、确定性快照、原生二进制和可嵌入性比 Web UI 或完整 Agent 工作流
+套件更重要,可以选择 Codetrip。此表描述公开产品形态,不是跨产品速度或精度基准。
+
+## 证据材料
+
+- [`brief` 受控 Agent A/B 评测](V0.3.0_AGENT_AB.md)
+- [`source` 盲评 Agent A/B 评测](V0.3.0_SOURCE_AGENT_AB.md)
+- [`impact` 正确性基线与作废重跑](V0.3.0_IMPACT_AGENT_AB.md)
+- [v0.3.0 真实仓库验证基线](V0.3.0_BASELINE.md)
+- [语义质量报告](QUALITY.md)
+- [索引基准](BENCHMARKS.md)
+
+完整 flags、MCP Schema、存储、嵌入、CSV 导出和限制请参阅
+[用户手册](USER_GUIDE_ZH.md)。
diff --git a/docs/BENCHMARKS.md b/docs/BENCHMARKS.md
index 68ffa47..072c154 100644
--- a/docs/BENCHMARKS.md
+++ b/docs/BENCHMARKS.md
@@ -43,7 +43,9 @@ Graph density and indexing cost depend on language semantics, not only file
count. RocksDB is the clearest example: its C++ graph contains almost 199,000
edges across 2,006 indexed files and took longer to build than Kubernetes.
-Current replacement indexing rebuilds and atomically publishes a complete
-snapshot. This protects readers from partial state, but makes repeated indexing
-of large repositories expensive. Incremental indexing is therefore a roadmap
-priority.
+Codetrip automatically selects no-op, incremental, or safe full-fallback
+indexing. Incremental updates reanalyze invalidated files and their direct
+semantic dependents, then atomically publish graph, symbol, source, vector, and
+index-state changes as one revision. Readers never observe a partially updated
+repository. Full replacement remains available explicitly and as the safe
+fallback for changes that cannot be proven locally rebuildable.
diff --git a/docs/COMPETITIVE_BENCHMARK.md b/docs/COMPETITIVE_BENCHMARK.md
new file mode 100644
index 0000000..32899d9
--- /dev/null
+++ b/docs/COMPETITIVE_BENCHMARK.md
@@ -0,0 +1,149 @@
+# Competitive engineering benchmark
+
+> Preliminary v0.3.0 release-candidate evidence, measured on 2026-08-04 and
+> extended with a five-run live-context test on 2026-08-10. This
+> is not an independent benchmark or a claim that one product wins every use
+> case.
+
+## What we tested
+
+Codetrip targets the coding-Agent loop:
+
+```text
+understand → edit → refresh → query → continue reasoning
+```
+
+The comparison therefore separates cold indexing, persistent MCP latency,
+manually reviewed semantic facts, structural refresh, and publication
+consistency.
+
+- Codetrip: v0.3.0 release-candidate working tree
+- [CodeGraph](https://github.com/colbymchenry/codegraph): v1.5.0
+- [codebase-memory-mcp](https://github.com/DeusData/codebase-memory-mcp): v0.9.0, `full` mode
+- Apple M2 Pro, 16 GiB RAM, macOS 13.5
+- runc, etcd, Kubernetes, Understand-Anything, pi, and openclaw
+- Separate repository copies and data directories for each tool
+- Checksum-verified third-party archives; no installer or Agent-config changes
+
+Full-index numbers are one measured run with unavoidable background security
+processes. Treat large differences as directional and do not treat small ones
+as statistically significant. Node and edge counts are not quality scores:
+each product models a different set of relationships.
+
+## Cold full indexing
+
+Wall time in seconds / peak RSS in MiB.
+
+| Repository | Codetrip | CodeGraph | codebase-memory-mcp |
+|---|---:|---:|---:|
+| runc | 4.05 / 584 | 2.86 / 432 | **0.53 / 275** |
+| Understand-Anything | 4.73 / 261 | 0.98 / 376 | **0.63 / 213** |
+| etcd | 34.87 / 3,313 | 5.18 / 1,126 | **2.43 / 731** |
+| pi | 26.25 / 478 | 3.56 / 1,112 | **1.81 / 572** |
+| openclaw | 274.78 / 1,279 | 16.83 / 2,486 | **8.95 / 2,218** |
+| Kubernetes | 212.70 / 5,039 | 41.05 / 3,812 | **39.25 / 2,644** |
+
+Codetrip is not the current cold-index leader. Ingestion and multi-index
+publication remain explicit optimization targets.
+
+## Persistent MCP latency
+
+One stdio connection remained open and process startup was excluded.
+
+| Repository and operation | P50 | P95 | Result contract |
+|---|---:|---:|---|
+| runc: Codetrip `brief` | 36.75 ms | 46.41 ms | Source, typed relations, impact, related tests |
+| runc: CodeGraph `codegraph_explore` | 16.16 ms | 18.18 ms | Source, paths, blast-radius context |
+| runc: CBM snippet + trace | ~0.48 ms | ~0.75 ms | Two calls; narrower result |
+| runc: Codetrip symbol search, post-fix | 0.22 ms | 0.42 ms | 100 calls |
+| Kubernetes: Codetrip `brief` | **52.27 ms** | **61.87 ms** | Deterministic context pack |
+| Kubernetes: CodeGraph `codegraph_explore` | 883.85 ms | 1,118.12 ms | Wider graph/context algorithm |
+| Kubernetes: CBM search + trace | ~26.65 ms | ~27.51 ms | Two calls; narrower result |
+
+These operations do not return identical evidence. The product question is
+whether the result avoids later Agent searches and file reads, not whether the
+narrowest lookup wins by a few milliseconds.
+
+## Live post-edit consistency
+
+The no-LLM test renamed a Go function in Gin, updated its caller, queried
+immediately after the durable save, and stopped only when source and the call
+edge both reflected the new revision. Every final result was compared with a
+fresh full index of the edited repository.
+
+| Five-run result | Codetrip | CodeGraph v1.5.0 | codebase-memory-mcp v0.9.0 |
+|---|---:|---:|---:|
+| Accurate context P50 / P95 | 1.169 s / 1.196 s | **0.298 s / 0.389 s** | 0.310 s / 0.396 s |
+| Product operations to accurate context, P50 | **1** | 4 | 3 |
+| Mixed-revision observations | **0** | 17; 13 carried a stale warning | 5 |
+| Clean-full equivalence | **5/5** | 5/5 | 5/5 after explicit refresh |
+
+CodeGraph and CBM converged sooner in wall-clock time. Their earlier responses,
+however, combined new source read from disk with old graph relations. Codetrip
+blocked its first query until one atomic revision was available. It therefore
+used **67%–75% fewer product tool round trips** in this scenario and never
+delegated stale-state recovery to the Agent. This measures operations, not
+equivalent token or end-to-end task savings. The protocol and result are in
+[`benchmarks/live_context`](../benchmarks/live_context/README.md).
+
+## Structural refresh
+
+| Change | Codetrip | CodeGraph | codebase-memory-mcp |
+|---|---:|---:|---:|
+| runc: add function + call | 2.10 s | 0.74 s | **0.42 s** |
+| Kubernetes: body-only edit | 3.44 s | **2.93 s** | 23.35 s |
+| Kubernetes: add function + call | 13.92 s | **3.47 s** | 22.36 s |
+
+After the Kubernetes structural edit, Codetrip published 114,092 nodes and
+489,174 edges with zero integrity findings. The new compiler-resolved `CALLS`
+edge was immediately queryable. Graph, symbol, source, semantic-vector
+invalidation state, and metadata committed as one revision.
+
+The first Codetrip implementation exceeded 449 seconds and refused to publish
+a partial graph. Fixing dependency invalidation, repository-wide loading,
+symbol hydration, deletion, graph-difference detection, and unstable derived
+IDs reduced the same class of update to 13.92 seconds. CodeGraph is still
+faster here; Codetrip differentiates on seconds-level refresh combined with an
+explicit cross-index consistency contract and clean-full equivalence gates.
+
+## Manually reviewed semantic facts
+
+This small sample reuses human-reviewed v0.3.0 Go and TypeScript facts. It is
+directional evidence, not a complete cross-product Precision/Recall study.
+
+| Fact set | Codetrip | CodeGraph | codebase-memory-mcp |
+|---|---|---|---|
+| runc `setupProcessPipes` callees | Exact | Expected plus one false edge | Missed one expected call; false edges |
+| TS `LanguageExtractor` implementations | Exact `IMPLEMENTS` | All ten exist but default output hides them | Discoverable as `INHERITS` |
+| TS `GoExtractor.extractStructure` callees | Exact four | Graph exact; name-only CLI merges namesakes | Exact when file-qualified |
+| etcd `verifyLockEnabled` callers/callee | Exact three plus one | Callers exact; missed cross-package callee | Exact three plus one |
+| etcd `ServerV3` implementations | Exact three | None | None |
+| Kubernetes `BackoffUntil` callers/callees | Exact reviewed one plus two | Missing and false/duplicate edges | Expected plus false edges |
+| Kubernetes `protocolHandler` implementations | Exact four | Exact four | Exact four |
+
+The scoped conclusion is that Codetrip led semantic precision on these seven
+reviewed facts. It does not establish universal superiority for every language,
+repository shape, or relationship.
+
+## Honest verdict
+
+- **Cold indexing and raw lookup:** codebase-memory-mcp led this run.
+- **Structural refresh speed:** CodeGraph led the Kubernetes case.
+- **Reviewed Go/TypeScript semantic precision:** Codetrip led this sample.
+- **Large-repository context pack:** Codetrip was much faster than CodeGraph;
+ CBM was faster with a narrower multi-call result.
+- **Revision-safe Agent edit loop:** Codetrip combines affected-dependency
+ analysis, atomic cross-index publication, recovery, and clean-full semantic
+ equivalence release gates.
+
+Codetrip's product bet is that false relationships and mixed revisions cost a
+coding Agent more than a few milliseconds. Choose it when correctness,
+explainability, bounded context, and immediate post-edit freshness matter.
+
+## What evidence comes next
+
+Before calling this a stable public leaderboard, repeat full-index cases at
+least three times in rotated order on an idle machine. Expand semantic review
+into a tool-neutral blind dataset with equal query contracts, explicit relation
+scoring, and false-positive counts. Dynamic dispatch, reflection, generated
+code, macros, and runtime loading remain general static-analysis limits.
diff --git a/docs/COMPETITIVE_BENCHMARK_ZH.md b/docs/COMPETITIVE_BENCHMARK_ZH.md
new file mode 100644
index 0000000..924b1b4
--- /dev/null
+++ b/docs/COMPETITIVE_BENCHMARK_ZH.md
@@ -0,0 +1,131 @@
+# 竞品工程对比
+
+> 这是 2026-08-04 完成并于 2026-08-10 增加五轮实时上下文测试的 v0.3.0
+> 发布候选版初步证据,不是独立机构评测,
+> 也不声称一个产品适合所有场景。
+
+## 我们真正比较什么
+
+Codetrip 面向编程 Agent 的完整闭环:
+
+```text
+理解 → 修改 → 刷新 → 查询 → 继续推理
+```
+
+因此,对比将冷启动索引、持久 MCP 延迟、人工复核语义事实、结构性刷新和发布一致性
+分开测量。
+
+- Codetrip:v0.3.0 发布候选工作树
+- [CodeGraph](https://github.com/colbymchenry/codegraph):v1.5.0
+- [codebase-memory-mcp](https://github.com/DeusData/codebase-memory-mcp):v0.9.0,`full` 模式
+- Apple M2 Pro、16 GiB 内存、macOS 13.5
+- runc、etcd、Kubernetes、Understand-Anything、pi 和 openclaw
+- 每个工具使用独立仓库副本和数据目录
+- 三方发布包经过 checksum 校验,没有运行安装器或修改 Agent 配置
+
+全量索引目前只有一轮实测,后台安全进程无法完全排除。大幅差异可作为方向性证据,
+小幅差异不能视为统计稳定结论。各产品建模范围不同,节点和边的数量不直接代表质量。
+
+## 冷启动全量索引
+
+数据为耗时秒数 / 峰值 RSS MiB。
+
+| 仓库 | Codetrip | CodeGraph | codebase-memory-mcp |
+|---|---:|---:|---:|
+| runc | 4.05 / 584 | 2.86 / 432 | **0.53 / 275** |
+| Understand-Anything | 4.73 / 261 | 0.98 / 376 | **0.63 / 213** |
+| etcd | 34.87 / 3,313 | 5.18 / 1,126 | **2.43 / 731** |
+| pi | 26.25 / 478 | 3.56 / 1,112 | **1.81 / 572** |
+| openclaw | 274.78 / 1,279 | 16.83 / 2,486 | **8.95 / 2,218** |
+| Kubernetes | 212.70 / 5,039 | 41.05 / 3,812 | **39.25 / 2,644** |
+
+Codetrip 当前不是冷启动全量索引的领先者。解析与多索引发布流水线仍是明确的优化目标。
+
+## 持久 MCP 查询延迟
+
+测试保持同一条 stdio 连接,并排除进程启动时间。
+
+| 仓库及操作 | P50 | P95 | 返回内容 |
+|---|---:|---:|---|
+| runc:Codetrip `brief` | 36.75 ms | 46.41 ms | 源码、类型化关系、影响、相关测试 |
+| runc:CodeGraph `codegraph_explore` | 16.16 ms | 18.18 ms | 源码、路径、爆炸半径上下文 |
+| runc:CBM snippet + trace | ~0.48 ms | ~0.75 ms | 两次调用,结果更窄 |
+| runc:Codetrip 符号查询(修复后) | 0.22 ms | 0.42 ms | 连续 100 次调用 |
+| Kubernetes:Codetrip `brief` | **52.27 ms** | **61.87 ms** | 确定性上下文包 |
+| Kubernetes:CodeGraph `codegraph_explore` | 883.85 ms | 1,118.12 ms | 更宽的图谱/上下文算法 |
+| Kubernetes:CBM search + trace | ~26.65 ms | ~27.51 ms | 两次调用,结果更窄 |
+
+这些操作返回的证据并不相同。真正的产品问题是,结果能否让 Agent 减少后续搜索和
+源码读取,而不是最窄的查询是否快了几毫秒。
+
+## 代码修改后的实时一致性
+
+零 LLM 测试在 Gin 中重命名一个 Go 函数并更新调用者,文件持久化后立即查询,直到
+源码和调用边同时反映新 revision 才停止计时。每个最终结果都与修改后重新建立的干净
+全量索引进行比较。
+
+| 五轮结果 | Codetrip | CodeGraph v1.5.0 | codebase-memory-mcp v0.9.0 |
+|---|---:|---:|---:|
+| 准确上下文 P50 / P95 | 1.169 秒 / 1.196 秒 | **0.298 秒 / 0.389 秒** | 0.310 秒 / 0.396 秒 |
+| 获得准确上下文所需产品操作数 P50 | **1** | 4 | 3 |
+| 混合 revision 观察数 | **0** | 17 次,其中 13 次带过期警告 | 5 次 |
+| 与干净全量索引等价 | **5/5** | 5/5 | 显式刷新后 5/5 |
+
+CodeGraph 和 CBM 的绝对收敛时间更短,但此前的响应会把磁盘中的新源码与旧图关系
+组合在一起。Codetrip 会阻塞第一次查询,直到一个完整原子 revision 可用。因此该
+场景下 Codetrip **减少 67%–75% 的产品工具往返**,且不把过期状态恢复交给 Agent。
+这里测量的是操作数,不代表等比例的 token 或端到端任务成本节省。协议和结果位于
+[`benchmarks/live_context`](../benchmarks/live_context/README.md)。
+
+## 结构性增量刷新
+
+| 修改 | Codetrip | CodeGraph | codebase-memory-mcp |
+|---|---:|---:|---:|
+| runc:新增函数及调用 | 2.10 秒 | 0.74 秒 | **0.42 秒** |
+| Kubernetes:仅修改函数体 | 3.44 秒 | **2.93 秒** | 23.35 秒 |
+| Kubernetes:新增函数及调用 | 13.92 秒 | **3.47 秒** | 22.36 秒 |
+
+Kubernetes 结构性修改后,Codetrip 发布了 114,092 个节点和 489,174 条边,完整性
+检查为零问题,新增的编译器解析 `CALLS` 边可以立即查询。图谱、符号、源码、语义
+向量失效状态和元数据作为同一个 revision 提交。
+
+Codetrip 第一版实现超过 449 秒,并正确拒绝发布半成品。修复依赖失效范围、全仓库
+加载、符号补全、删除、图差异判断和派生 ID 不稳定后,同类修改降至 13.92 秒。
+CodeGraph 在这个案例中仍然更快;Codetrip 的差异化是“秒级刷新 + 明确的跨索引
+一致性契约 + 干净全量等价门禁”这一组合。
+
+## 人工复核语义事实
+
+这个小样本复用了 v0.3.0 人工核验的 Go 和 TypeScript 事实。它是方向性证据,
+不是覆盖所有产品能力的 Precision/Recall 研究。
+
+| 事实集 | Codetrip | CodeGraph | codebase-memory-mcp |
+|---|---|---|---|
+| runc `setupProcessPipes` 被调用函数 | 准确 | 预期调用外多一条误边 | 漏一条预期调用,并有误边 |
+| TS `LanguageExtractor` 实现类 | 准确的 `IMPLEMENTS` | 10 个均存在,但默认输出未清晰暴露 | 可找到,被分类为 `INHERITS` |
+| TS `GoExtractor.extractStructure` 调用 | 准确的 4 条 | 图中准确;只按名称会混入同名方法 | 使用文件限定后准确 |
+| etcd `verifyLockEnabled` 调用者/被调函数 | 准确的 3+1 | 调用者准确,漏跨包被调函数 | 准确的 3+1 |
+| etcd `ServerV3` 实现 | 准确的 3 个 | 无 | 无 |
+| Kubernetes `BackoffUntil` 调用者/被调函数 | 人工复核的 1+2 准确 | 存在漏边、误边和重复边 | 预期边之外存在误边 |
+| Kubernetes `protocolHandler` 实现 | 准确的 4 个 | 准确的 4 个 | 准确的 4 个 |
+
+有范围的结论是:Codetrip 在这 7 组人工复核事实中语义精度领先。它不能证明
+Codetrip 在所有语言、仓库形态和关系类型上都全面领先。
+
+## 诚实结论
+
+- **冷启动索引和原始查询:** codebase-memory-mcp 在本轮领先。
+- **结构性增量速度:** CodeGraph 在 Kubernetes 案例中领先。
+- **人工复核的 Go/TypeScript 语义精度:** Codetrip 领先本组样本。
+- **大型仓库上下文包:** Codetrip 显著快于 CodeGraph;CBM 用更窄的多次调用更快。
+- **Agent 修改后的 revision 安全闭环:** Codetrip 组合了受影响依赖分析、跨索引原子
+ 发布、恢复机制,以及与干净全量结果语义等价的发布门禁。
+
+Codetrip 的产品判断是:对于编程 Agent,错误关系和混合版本的代价通常高于几毫秒
+延迟。当正确性、可解释性、受限上下文和修改后的即时新鲜度重要时,选择 Codetrip。
+
+## 下一步证据
+
+在将本文升级为稳定排行榜前,应在空闲机器上轮换工具顺序,对全量索引至少重复三次。
+语义评估还应扩展为工具中立的盲测数据集,使用等价查询、明确的关系计分和误报统计。
+动态派发、反射、生成代码、宏和运行时加载仍然是静态分析的共同限制。
diff --git a/docs/QUALITY.md b/docs/QUALITY.md
index 4133984..e62b506 100644
--- a/docs/QUALITY.md
+++ b/docs/QUALITY.md
@@ -5,8 +5,9 @@ maintainer-owned gold corpus, evaluator implementation, or review worksheets.
The results are maintainer-reviewed evaluations, not an independent third-party
benchmark.
-Last evaluated: 2026-07-27
-Codetrip version: 0.2.0
+Last evaluated: 2026-07-30
+
+Codetrip version: 0.3.0 release candidate
## What the metrics mean
@@ -54,14 +55,14 @@ interpreted as 100% accuracy on arbitrary repositories.
| Language | Repositories | Precision evidence | Recall evidence | Current maturity |
|---|---|---|---|---|
| C++ | LevelDB, Crow | 72/72 reviewed relationships correct (36 calls and 36 structural) | 33/33 statically decidable calls found across 40 reviewed call sites | Stable |
+| Go | etcd, Kubernetes, runc | 227/227 reviewed relationships correct after source reclassification and correction pass | 64/64 statically decidable calls found across 120 reviewed call sites; 56 external, dynamic, or generated cases excluded | Stable |
+| TypeScript/TSX | Understand-Anything, openclaw, pi | 328/329 reviewed relationships correct | 78/78 statically decidable calls found across 120 reviewed call sites; 42 external or dynamic cases excluded | Stable |
| Kotlin | Exposed, moko-resources | Review in progress; first-pass defects were fixed and rechecked | 40 source call sites sampled; semantic classification in progress | Beta |
-| Go | Kubernetes selected | Planned | Planned | Fixture validated |
| C | nginx selected | Planned | Planned | Fixture validated |
| Java | RocketMQ selected | Planned | Planned | Fixture validated |
| Python | FastAPI selected | Planned | Planned | Fixture validated |
| Rust | ripgrep selected | Planned | Planned | Fixture validated |
| JavaScript | jQuery selected | Planned | Planned | Fixture validated |
-| TypeScript | Commerce selected | Planned | Planned | Fixture validated |
| C# | Moq selected | Planned | Planned | Fixture validated |
| Swift | swift-numerics selected | Planned | Planned | Fixture validated |
| PHP | XP Framework lang selected | Planned | Planned | Fixture validated |
diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md
index c9f8b4d..6b9f231 100644
--- a/docs/USER_GUIDE.md
+++ b/docs/USER_GUIDE.md
@@ -16,7 +16,7 @@ repos//
trash/ interrupted deletion cleanup
```
-Each logical repository owns an independent database and points to one immutable physical snapshot. Repository databases open lazily, so accessing one project does not lock unrelated projects. `index --replace` builds a complete new graph and all derived indexes, publishes the active pointer only after success, and retires the previous snapshot. Codetrip does not modify an active snapshot incrementally. Data created by the earlier shared-database layout must be reindexed.
+Each logical repository owns an independent database and points to one immutable physical snapshot. Repository databases open lazily, so accessing one project does not lock unrelated projects. Every successful changed update builds a copy-on-write snapshot, publishes the active pointer only after all derived indexes succeed, and then retires the previous snapshot. Codetrip never modifies the active snapshot in place. Data created by the earlier shared-database layout must be reindexed.
## CLI
@@ -26,6 +26,7 @@ All business commands are single words:
|---|---|
| `index` | Parse and persist a repository |
| `delete` | Delete a repository and all persisted data |
+| `brief` | Build a bounded, evidence-backed context pack for one symbol |
| `search` | Search symbols and metadata |
| `source` | Search code, engineering configuration, or documentation contents |
| `embed` | Build semantic data for a repository |
@@ -42,10 +43,11 @@ All business commands are single words:
| `mcp` | Start the stdio MCP server or configure supported agent clients |
| `version` | Print the build version |
-### Index and replace
+### Index and refresh
```bash
codetrip index /src/project --repo project
+codetrip index /src/project --repo project --full
codetrip index /src/project --repo project --replace
```
@@ -54,17 +56,47 @@ Index flags:
| Flag | Meaning |
|---|---|
| `--repo` | Logical repository name; defaults to the source directory name |
-| `--replace` | Atomically publish a complete replacement snapshot |
+| `--full` | Force a complete rebuild and atomically publish it |
+| `--replace` | Compatibility alias for `--full` |
| `--export` | Write deterministic parser-inspection CSV files to a directory |
| `--export-strict` | Fail indexing if parser CSV generation fails |
+The CLI uses automatic update mode by default. It reports one of these modes:
+
+| Mode | Meaning |
+|---|---|
+| `noop` | No eligible source file changed; the active snapshot is reused |
+| `incremental` | Only invalidated files and their semantic dependents are reparsed; graph, symbol, source, and vector deltas are committed to the existing snapshot |
+| `full-fallback` | A safe incremental update was not possible, so a complete snapshot was rebuilt |
+| `full` | A complete build was explicitly requested or no prior repository existed |
+
+Incremental mode uses content, structure, and call-shape fingerprints across supported languages. Call-stable body edits use a file-local delta without materializing the complete graph. Call changes refresh callable relationships, while declaration/import/interface changes expand through semantic dependents. Add, delete, and rename operations update graph and search indexes in one revision. Incompatible analyzer state safely falls back to a full build. Changed node vectors are invalidated; run `embed` to regenerate them. A `noop` keeps existing vectors.
+
Delete a logical repository, including every graph, source, symbol, and vector snapshot:
```bash
codetrip delete project
```
-The library exposes the same operation as `Engine.DeleteRepo`. Destructive repository management is intentionally not exposed through MCP.
+The library exposes the same operation as `Engine.DeleteRepo`. Destructive deletion is intentionally not exposed through MCP.
+
+### Symbol brief
+
+```bash
+codetrip brief ParseConfig --repo project
+codetrip brief ParseConfig --repo project --format text
+```
+
+`brief` is the recommended starting point when an Agent needs to understand
+one symbol. It returns the declaration and source excerpt, direct typed
+relationships with confidence and evidence, incoming impact counts, related
+tests, and explicit budget-truncation state. Output is deterministic and does
+not invoke an LLM. JSON is the default for integrations; `--format text`
+provides a compact terminal view.
+
+The symbol may be an exact name or a stable node ID. When a name is ambiguous,
+Codetrip returns the candidates instead of guessing. Source line coordinates
+are one-based.
### Search
@@ -105,6 +137,7 @@ Use `--quantize-int8` on `embed` when a compact semantic representation is requi
codetrip context ParseConfig --repo project
codetrip context ParseConfig --repo project --relations CALLS,IMPLEMENTS
codetrip impact ParseConfig --repo project --depth 3 --limit 100
+codetrip impact ParseConfig --repo project --change signature
codetrip impact ParseConfig --repo project --format tree
codetrip check --repo project
codetrip check --repo project --checks confidence --confidence 0.7
@@ -117,13 +150,17 @@ codetrip path LoadConfig ParseConfig --repo project
symbol name or a stable node ID. When an exact name matches multiple symbols,
the CLI lists their file locations and node IDs instead of choosing one.
`impact` returns JSON by default; use `--format tree` for a compact terminal
-view with a symbol and file summary.
+view with a symbol and file summary. `--change` accepts `behavior`,
+`signature`, `contract`, or `remove`. When omitted, functions and methods
+default to behavior, interfaces to contract, and types to signature.
`context` returns the symbol, its source excerpt when the original checkout is
available, and direct typed relationships. It excludes structural graph noise
-by default. `impact` follows incoming semantic dependencies and reports
-affected actionable symbols with depth, relationship, and confidence. It
-defaults to depth 3 and supports `--relations` for narrower analysis.
+by default. `impact` separates confirmed dependents, contract impacts, entry
+points, related tests, and review candidates. Every item includes its evidence
+path and why it may be affected; uncertain dispatch remains explicitly marked
+for review and cannot become confirmed later in the path. It defaults to depth
+3 and supports `--relations` for narrower analysis.
`check` runs `integrity` and `cycles` by default. Integrity reports missing
edge endpoints and invalid self-dependencies. Cycle analysis distinguishes
@@ -206,7 +243,7 @@ defer engine.Close()
```go
result, err := engine.IndexRepo(ctx, "/src/project",
codetrip.WithRepoName("project"),
- codetrip.WithReplaceExisting(true),
+ codetrip.WithIndexMode(codetrip.IndexModeAuto),
codetrip.WithIndexTimeout(30*time.Minute),
codetrip.WithCSVExport("./local-review/project"),
codetrip.WithCSVExportStrict(true),
@@ -227,6 +264,10 @@ source, err := engine.SearchSource(ctx, &codetrip.SourceSearchRequest{
Repo: "project", Query: "lang:go ParseConfig", Limit: 20, ContextLines: 2,
})
+brief, err := engine.Brief(ctx, &codetrip.BriefRequest{
+ Repo: "project", Symbol: "ParseConfig",
+})
+
nodes, err := engine.Traverse(ctx, &codetrip.TraverseRequest{
Repo: "project", StartNodeID: nodeID,
Direction: codetrip.TraverseAny, MaxDepth: 3,
@@ -289,7 +330,7 @@ The Go API exposes domain request/result types. Internal storage and index imple
Configure every supported client detected on the current machine:
```bash
-codetrip mcp setup
+codetrip mcp setup --watch
```
Supported targets are `codex`, `claude`, `cursor`, `vscode`, and `copilot`.
@@ -299,10 +340,15 @@ replaced:
```bash
codetrip mcp setup codex cursor
+codetrip mcp setup codex --watch
codetrip mcp setup --dry-run
codetrip mcp setup claude --force
```
+If Codetrip was configured before watch support was enabled, rerun setup with
+`--watch --force` to replace that Codetrip entry; unrelated MCP servers remain
+untouched.
+
The setup command preserves unrelated MCP servers. Cursor's JSON configuration
is merged atomically and backed up before modification. The other clients are
configured through their official command-line interfaces.
@@ -311,9 +357,16 @@ To start the stdio server directly:
```bash
codetrip mcp --dir ~/.codetrip
+codetrip mcp --dir ~/.codetrip --watch
```
-The stdio server exposes `list`, `search`, `source`, `context`, `impact`, `check`, `diff`, `rename`, `traverse`, and `path`, matching the corresponding CLI command names. MCP lives in the CLI adapter and invokes only public `Engine` methods.
+With `--watch`, the MCP process recursively watches every indexed repository source root. Filesystem bursts are debounced for 750 ms by default (`--watch-debounce` changes it), then the watcher invokes automatic refresh through the public Engine API. The language-neutral invalidation pipeline incrementally updates supported languages and safely falls back to a full rebuild only when persisted analyzer state is incompatible. A freshness barrier makes queries wait for every already-observed change to commit; a failed refresh is returned as an explicit error instead of serving stale results. Newly indexed repositories are discovered while the server remains running. Watch diagnostics are written to stderr so they cannot corrupt the stdio protocol.
+
+The stdio server exposes `index`, `list`, `brief`, `search`, `source`, `context`,
+`impact`, `check`, `diff`, `rename`, `traverse`, and `path`, matching the
+corresponding CLI command names. `brief` accepts the same repository, symbol,
+and budget controls as the public API. MCP lives in the CLI adapter and invokes
+only public `Engine` methods. The `index` tool remains useful when watch mode is disabled, for explicit recovery, or when `full` is requested.
The MCP process opens the engine for one tool request and releases it when the
request finishes, so an idle server does not hold the database lock or block CLI
diff --git a/docs/USER_GUIDE_ZH.md b/docs/USER_GUIDE_ZH.md
index 98e41ef..309ae6a 100644
--- a/docs/USER_GUIDE_ZH.md
+++ b/docs/USER_GUIDE_ZH.md
@@ -16,7 +16,7 @@ repos//
trash/ 中断删除的清理目录
```
-每个逻辑仓库拥有独立数据库,并且只指向一个不可变物理快照。仓库数据库按需打开,访问一个项目不会锁住其他项目。`index --replace` 会完整构建新图和全部派生索引,成功后才发布活动指针,并回收旧快照;不会局部修改当前活动快照。旧版共享数据库布局中的数据需要重新索引。
+每个逻辑仓库拥有独立数据库,并且只指向一个不可变物理快照。仓库数据库按需打开,访问一个项目不会锁住其他项目。每次发生变更的成功更新都会以写时复制方式构建新快照,全部派生索引成功后才发布活动指针并回收旧快照;活动快照永远不会被原地修改。旧版共享数据库布局中的数据需要重新索引。
## CLI
@@ -26,6 +26,7 @@ trash/ 中断删除的清理目录
|---|---|
| `index` | 解析并持久化仓库 |
| `delete` | 删除仓库及其全部持久化数据 |
+| `brief` | 为单个符号生成受预算约束、带证据的上下文包 |
| `search` | 搜索符号及元数据 |
| `source` | 搜索代码、工程配置或文档内容 |
| `embed` | 构建仓库语义数据 |
@@ -42,27 +43,54 @@ trash/ 中断删除的清理目录
| `mcp` | 启动 stdio MCP 服务或配置受支持的 Agent 客户端 |
| `version` | 输出构建版本 |
-### 索引与完整替换
+### 索引与刷新
```bash
codetrip index /src/project --repo project
+codetrip index /src/project --repo project --full
codetrip index /src/project --repo project --replace
```
| 参数 | 含义 |
|---|---|
| `--repo` | 逻辑仓库名,默认使用源码目录名 |
-| `--replace` | 原子发布完整替换快照 |
+| `--full` | 强制完整重建并原子发布 |
+| `--replace` | `--full` 的兼容别名 |
| `--export` | 输出确定性的解析检查 CSV |
| `--export-strict` | 解析 CSV 生成失败时让索引任务失败 |
+CLI 默认使用自动更新模式,并报告以下模式之一:
+
+| 模式 | 含义 |
+|---|---|
+| `noop` | 没有符合条件的源码文件发生变化,复用活动快照 |
+| `incremental` | 仅重新解析失效文件及其语义依赖,并将图、符号、源码和向量差量提交到原快照 |
+| `full-fallback` | 无法安全执行增量更新,自动完整重建快照 |
+| `full` | 显式要求完整构建,或仓库此前尚未建立索引 |
+
+增量模式对所有受支持语言使用内容、结构和调用形状指纹。调用不变的函数体编辑使用文件级差量,无需物化完整图;调用变化刷新 callable 关系;声明、import 和接口变化沿语义依赖扩散;新增、删除和重命名在同一 revision 更新图与搜索索引。分析器状态不兼容时安全回退完整构建。发生变化的节点向量会失效,需要执行 `embed` 重新生成;`noop` 保留现有向量。
+
删除逻辑仓库及其所有图、源码、符号和向量快照:
```bash
codetrip delete project
```
-LIB 提供相同的 `Engine.DeleteRepo` 方法。MCP 不暴露这种破坏性仓库管理能力。
+LIB 提供相同的 `Engine.DeleteRepo` 方法。MCP 不暴露破坏性的删除能力。
+
+### 符号简报
+
+```bash
+codetrip brief ParseConfig --repo project
+codetrip brief ParseConfig --repo project --format text
+```
+
+当 Agent 需要理解一个符号时,建议先使用 `brief`。它一次返回声明和源码片段、
+带置信度及证据的直接类型化关系、入向影响计数、相关测试,以及明确的预算截断状态。
+输出完全确定,不调用 LLM。集成场景默认使用 JSON;`--format text` 提供紧凑的终端视图。
+
+目标可以是精确符号名或稳定节点 ID。同名符号存在歧义时,Codetrip 会返回候选项,
+而不是自行猜测。源码行坐标从 1 开始。
### 搜索
@@ -97,6 +125,7 @@ codetrip hybrid "configuration loading" --repo project \
codetrip context ParseConfig --repo project
codetrip context ParseConfig --repo project --relations CALLS,IMPLEMENTS
codetrip impact ParseConfig --repo project --depth 3 --limit 100
+codetrip impact ParseConfig --repo project --change signature
codetrip impact ParseConfig --repo project --format tree
codetrip check --repo project
codetrip check --repo project --checks confidence --confidence 0.7
@@ -108,8 +137,13 @@ codetrip path LoadConfig ParseConfig --repo project
`context`、`impact`、`traverse`、`path` 和 `rename` 都可以接收精确符号名或稳定的节点
ID。如果同名符号有多个,CLI 会列出文件位置和节点 ID,而不会自行猜测。`impact`
默认返回 JSON;使用 `--format tree` 可获得适合终端阅读的树形结果以及符号和文件汇总。
+`--change` 支持 `behavior`、`signature`、`contract` 和 `remove`。省略时,函数和方法
+默认按行为修改分析,接口按契约修改分析,类型按签名修改分析。
-`context` 返回目标符号、原始代码目录仍可访问时的源码片段,以及直接类型化关系;默认排除结构图噪音。`impact` 沿反向语义依赖查找受影响的可行动符号,并返回深度、到达关系和置信度;默认深度为 3,也可用 `--relations` 收窄分析范围。
+`context` 返回目标符号、原始代码目录仍可访问时的源码片段,以及直接类型化关系;
+默认排除结构图噪音。`impact` 将结果分为确定依赖、契约影响、生产入口、相关测试和
+待核验候选;每项都返回完整证据路径和受影响原因。不确定性经过接口分派后会沿路径
+保留,不能重新升级为确定依赖。默认深度为 3,也可用 `--relations` 收窄分析范围。
`check` 默认执行 `integrity` 和 `cycles`。完整性检查报告缺失的边端点和无效自依赖;环检查将继承环标记为错误,将导入环标记为警告。可选的 `confidence` 检查报告低于 `--confidence` 的语义关系;它面向专项评审,默认不会作为正确性告警开启。
@@ -176,7 +210,7 @@ defer engine.Close()
```go
result, err := engine.IndexRepo(ctx, "/src/project",
codetrip.WithRepoName("project"),
- codetrip.WithReplaceExisting(true),
+ codetrip.WithIndexMode(codetrip.IndexModeAuto),
codetrip.WithIndexTimeout(30*time.Minute),
codetrip.WithCSVExport("./local-review/project"),
codetrip.WithCSVExportStrict(true),
@@ -197,6 +231,10 @@ source, err := engine.SearchSource(ctx, &codetrip.SourceSearchRequest{
Repo: "project", Query: "lang:go ParseConfig", Limit: 20, ContextLines: 2,
})
+brief, err := engine.Brief(ctx, &codetrip.BriefRequest{
+ Repo: "project", Symbol: "ParseConfig",
+})
+
nodes, err := engine.Traverse(ctx, &codetrip.TraverseRequest{
Repo: "project", StartNodeID: nodeID,
Direction: codetrip.TraverseAny, MaxDepth: 3,
@@ -259,26 +297,35 @@ Go LIB 只暴露领域请求和结果类型,不公开内部存储及索引实
自动检测并配置当前机器上已安装的受支持客户端:
```bash
-codetrip mcp setup
+codetrip mcp setup --watch
```
支持的目标为 `codex`、`claude`、`cursor`、`vscode` 和 `copilot`。可以显式指定一个或多个目标,使用 `--dry-run` 预览改动;只有需要替换已有 Codetrip 配置时才使用 `--force`:
```bash
codetrip mcp setup codex cursor
+codetrip mcp setup codex --watch
codetrip mcp setup --dry-run
codetrip mcp setup claude --force
```
+如果 Codetrip MCP 配置早于 watch 功能,使用 `--watch --force` 重新生成该 Codetrip 条目;其他 MCP Server 不受影响。
+
setup 命令会保留其他 MCP server。Cursor 的 JSON 配置采用原子合并写入,并在修改前创建备份;其他客户端通过各自的官方命令行接口配置。
直接启动 stdio 服务:
```bash
codetrip mcp --dir ~/.codetrip
+codetrip mcp --dir ~/.codetrip --watch
```
-stdio 服务提供 `list`、`search`、`source`、`context`、`impact`、`check`、`diff`、`rename`、`traverse` 和 `path`,与对应的 CLI 命令同名。MCP 位于 CLI 适配层,只调用 `Engine` 公共方法。
+启用 `--watch` 后,MCP 进程会递归监听每个已索引仓库的源码根目录。文件系统事件默认合并等待 750ms(可通过 `--watch-debounce` 调整),然后只通过公共 Engine API 执行自动刷新。语言无关的失效管线会增量更新所有受支持语言;仅当持久化分析状态不兼容时才安全回退到完整重建。新鲜度屏障会让查询等待所有已经观察到的变化提交;如果刷新失败,则返回明确错误而不是旧结果。MCP 运行期间新索引的仓库也会被自动发现。监听日志写入 stderr,不会破坏 stdio 协议。
+
+stdio 服务提供 `index`、`list`、`brief`、`search`、`source`、`context`、`impact`、
+`check`、`diff`、`rename`、`traverse` 和 `path`,与对应的 CLI 命令同名。
+`brief` 接受与公共 API 相同的仓库、符号和预算参数。`index` 在未启用 watch、显式恢复或要求 `full` 时仍然可用。MCP 位于 CLI 适配层,
+只调用 `Engine` 公共方法。
MCP 进程只在单次工具请求期间打开引擎,请求结束后立即释放,因此空闲 MCP server 不会长期占用数据库锁,也不会阻止 CLI 建立索引。MCP 请求会串行执行;如果 CLI 索引正在占用数据库,此时的 MCP 请求可能返回临时存储繁忙错误,稍后重试即可。
diff --git a/docs/V0.3.0_AGENT_AB.md b/docs/V0.3.0_AGENT_AB.md
new file mode 100644
index 0000000..c8677aa
--- /dev/null
+++ b/docs/V0.3.0_AGENT_AB.md
@@ -0,0 +1,247 @@
+# Codetrip v0.3.0 Codex A/B evaluation
+
+Status: Initial controlled evaluation complete
+Measured: 2026-07-28
+Agent: Codex CLI 0.144.2
+Mode: independent ephemeral sessions, repository source unmodified
+
+## Question
+
+Does `brief` materially reduce the work a Code Agent needs to understand a
+symbol without reducing answer quality?
+
+This evaluation tests the v0.3.0 product claim directly. It is separate from
+the structural, performance, Precision, and Recall worksheets.
+
+## Method
+
+Four real-repository tasks were run twice in independent Codex sessions:
+
+| Language | Repository | Symbol |
+|---|---|---|
+| Go | Kubernetes | `NewMainKubelet` |
+| Go | etcd | `StartEtcd` |
+| TypeScript | pi | `createAgentSession` |
+| TypeScript | Understand-Anything | `GraphBuilder` |
+
+Each task requested:
+
+- responsibility and exact definition;
+- direct repository callers or users;
+- important direct callees, dependencies, or members;
+- directly related tests;
+- concrete modification risks;
+- repository-relative file and line evidence.
+
+The A arm prohibited `brief`. The B arm had to invoke `brief` first and could
+then inspect source only to verify or complete its evidence. Sessions did not
+share prior answers. Metrics below come from Codex JSONL events rather than
+agent self-reporting.
+
+## Results
+
+| Task | Arm | Commands | Failed commands | Input tokens | Output tokens |
+|---|---:|---:|---:|---:|---:|
+| Kubernetes `NewMainKubelet` | A | 4 | 0 | 165,045 | 3,111 |
+| Kubernetes `NewMainKubelet` | B | 3 | 0 | 112,210 | 2,108 |
+| etcd `StartEtcd` | A | 5 | 0 | 149,314 | 2,687 |
+| etcd `StartEtcd` | B | 4 | 0 | 136,823 | 2,560 |
+| pi `createAgentSession` | A | 6 | 0 | 233,516 | 2,889 |
+| pi `createAgentSession` | B | 11 | 3 | 176,684 | 2,814 |
+| Understand `GraphBuilder` | A | 8 | 1 | 115,207 | 2,374 |
+| Understand `GraphBuilder` | B | 6 | 2 | 188,274 | 1,963 |
+| **Total** | **A** | **23** | **1** | **663,082** | **11,061** |
+| **Total** | **B** | **24** | **5** | **613,991** | **9,445** |
+
+Across this small sample, B used 7.4% fewer input tokens and 14.6% fewer output
+tokens. It did not reduce total command count. Three of four tasks used fewer
+input tokens with `brief`:
+
+- Kubernetes: 32.0% fewer;
+- etcd: 8.4% fewer;
+- pi: 24.3% fewer;
+- Understand-Anything: 63.4% more.
+
+Wall-clock time is not reported because remote model scheduling and background
+model/plugin refresh failures were not controlled.
+
+## Answer-quality observations
+
+Both arms correctly identified the requested symbols, their responsibilities,
+major dependencies, tests, and modification risks.
+
+`brief` was most useful for `NewMainKubelet`: it immediately supplied the exact
+symbol, production caller, direct test, high-confidence cross-file calls, and
+bounded source. The B answer retained the important lifecycle, initialization
+ordering, standalone-mode, and security risks while using materially fewer
+tokens.
+
+For `StartEtcd`, both answers were strong. The B arm used one fewer command and
+slightly fewer tokens, but still inspected the constructor body and test call
+sites to explain asynchronous readiness and cleanup behavior.
+
+For `createAgentSession`, the B answer was at least as accurate and more
+compact. It used fewer tokens but more commands. Three failed commands came
+from the Codex command runner switching its working directory to the added
+index directory; the agent recovered by locating the repository again. This is
+test-harness noise, not a Codetrip graph failure, but the failed commands remain
+visible in the table.
+
+For `GraphBuilder`, the name matched multiple symbols. `brief` correctly
+refused to guess, and Codex retried with the exact class node ID. This is the
+desired ambiguity-safety behavior, but it increased input cost. The final B
+answer remained correct and concise, while the A answer found a few additional
+edge cases through broader manual inspection.
+
+## Problems exposed
+
+1. **The context pack can still be large.** The raw `brief` JSON was about
+ 25 KB for `NewMainKubelet` and 31 KB for `createAgentSession`. A bounded
+ result is not automatically a minimal result.
+2. **Agents still re-read broad source ranges.** `brief` narrows the starting
+ point, but the current output does not consistently stop Codex from
+ retrieving the complete implementation to derive behavioral risks.
+3. **Name ambiguity has a round-trip cost.** Refusing to guess is correct, but
+ the error should make candidate selection maximally cheap for an agent.
+4. **Related tests help discovery but not behavioral interpretation.** The
+ agent still opens tests to learn what they assert. This is expected for a
+ deterministic, non-LLM feature, but should be reflected in product claims.
+5. **Read-only store failures are misleading.** In an initial discarded B
+ run, the sandbox could list repositories but could not create the database
+ lock needed by `brief`; the CLI reported that the repository was not
+ indexed. The underlying store-open error should be preserved.
+
+## Initial conclusion (before the expanded evaluation)
+
+The initial evidence supports the core direction: `brief` often gives Codex a
+better starting context and reduced token consumption in three representative
+tasks without observable answer-quality loss.
+
+It does not yet prove the full v0.3.0 acceptance claim. In this sample,
+`brief` did not reduce aggregate command count, did not eliminate broad source
+reads, and increased cost for an ambiguous symbol. Therefore this evaluation
+is a successful product signal, not a completed release gate.
+
+The expanded evaluation therefore had to:
+
+1. make the default context pack more compact without dropping high-value
+ callers, contracts, tests, or evidence;
+2. improve ambiguity responses and store-open error fidelity;
+3. repeat the experiment with at least ten tasks per target language,
+ stratified across functions, methods, classes/structs, and interfaces;
+4. score final-answer correctness with a reviewer who does not know the arm;
+5. report median command, file-read, token, and correctness deltas with
+ failures retained.
+
+## Compact-response iteration
+
+The initial A/B results showed that relation objects repeated edge IDs, source
+IDs, and target IDs even though the selected symbol, direction, and neighbor
+already determine those values. The wire model was flattened to retain only
+the information an Agent needs:
+
+- direction;
+- relationship type;
+- confidence;
+- evidence reason;
+- neighboring symbol.
+
+No source, relationship, test, or ranking budget was reduced. Default CLI JSON
+also uses compact encoding; human-readable output remains available through
+`--format text`. Payload sizes changed as follows:
+
+| Task | Initial payload | Compact payload | Reduction |
+|---|---:|---:|---:|
+| Kubernetes `NewMainKubelet` | 25,138 bytes | 15,254 bytes | 39.3% |
+| etcd `StartEtcd` | 16,100 bytes | 11,259 bytes | 30.1% |
+| pi `createAgentSession` | 31,515 bytes | 18,907 bytes | 40.0% |
+
+Kubernetes and pi B arms were then repeated in fresh sessions:
+
+| Task | Arm | Commands | Failed | Input tokens | Output tokens |
+|---|---:|---:|---:|---:|---:|
+| Kubernetes | A, no `brief` | 4 | 0 | 165,045 | 3,111 |
+| Kubernetes | compact `brief` | 3 | 0 | 100,491 | 1,932 |
+| pi | A, no `brief` | 6 | 0 | 233,516 | 2,889 |
+| pi | compact `brief` | 3 | 0 | 104,835 | 1,996 |
+
+The compact B arms used 48.5% fewer input tokens and 40% fewer commands in
+aggregate than their original A arms, with no failed commands. Their answers
+retained correct symbol identity, callers, major dependencies, tests,
+evidence, and modification risks. This is a stronger positive signal than the
+initial B run, but it remains a two-task follow-up rather than the complete
+twenty-task release gate.
+
+## Twenty-task expansion
+
+The evaluation was expanded to ten Go and ten TypeScript tasks. The 16 new
+tasks covered functions, methods, structs/classes, and interfaces across all
+six frozen repositories. Every arm ran in an independent ephemeral Codex
+session with an eight-command limit. B had to invoke `brief` first; A was
+prohibited from using Codetrip.
+
+### New 16-task operational results
+
+| Arm | Tasks | Commands | Failed commands | Input tokens | Output tokens |
+|---|---:|---:|---:|---:|---:|
+| A, source-only | 16 | 105 | 4 | 3,874,964 | 33,321 |
+| B, `brief` first | 16 | 103 | 7 | 3,764,455 | 32,521 |
+
+For this expansion, B used 1.9% fewer commands, 2.85% fewer input tokens, and
+2.4% fewer output tokens. Ten of 16 B tasks used fewer input tokens than their
+paired A task. All 16 valid B runs successfully invoked `brief` as their first
+command.
+
+Three initial Kubernetes B attempts used abbreviated rather than complete node
+IDs and were rejected by `brief`. They were retained as harness failures,
+excluded from the valid comparison, and repeated with the complete IDs. One
+separate pilot used a read-only sandbox that prohibited creation of the graph
+store lock; it was likewise retained but excluded and repeated with write
+permission limited to the index directory.
+
+### Independent blinded answer review
+
+The 32 new answers were deterministically relabeled X/Y and reviewed by six
+independent ephemeral Codex sessions grouped by repository. Reviewers saw the
+task, answers, and repository source, but not the A/B mapping.
+
+| Arm | Safe passes | Correctness points | Evidence points |
+|---|---:|---:|---:|
+| A | 16 / 16 | 29 / 32 | 32 / 32 |
+| B | 16 / 16 | 30 / 32 | 32 / 32 |
+
+No answer contained a dangerous false claim. Reviewers found three minor A
+errors and two minor B errors. The B errors were an imprecise “sole
+implementation” claim and attribution of an `Agent.abort()` README example to
+`AgentHarness.abort()`; neither originated from a false graph relationship.
+
+### Combined 20-task result
+
+For the original four tasks, the final compact B results are used where they
+were rerun; the other two retain their original B results.
+
+| Arm | Tasks | Commands | Failed commands | Input tokens | Output tokens |
+|---|---:|---:|---:|---:|---:|
+| A | 20 | 128 | 5 | 4,538,046 | 44,382 |
+| B | 20 | 119 | 9 | 4,294,878 | 40,972 |
+
+Across all 20 tasks, `brief` reduced commands by 7.0%, input tokens by 5.4%,
+and output tokens by 7.7%, without reducing observed answer quality. Failures
+were retained; valid B runs had no failed initial `brief` invocation.
+
+### Final interpretation
+
+The complete evaluation proves a repeatable but modest aggregate efficiency
+gain and no answer-quality regression. It does not support a claim that
+`brief` eliminates source reading: agents still opened target implementations,
+caller bodies, and tests to explain behavior and modification risk.
+
+The honest v0.3.0 claim is therefore:
+
+> `brief` gives an Agent a precise, bounded, evidence-backed starting context
+> and reduces aggregate investigation work; it does not replace source
+> inspection for behavioral conclusions.
+
+This satisfies the release requirement for a useful Agent Context Pack, but
+marketing must use the measured 20-task result rather than the stronger claim
+that every task becomes dramatically cheaper.
diff --git a/docs/V0.3.0_BASELINE.md b/docs/V0.3.0_BASELINE.md
new file mode 100644
index 0000000..8c7b0c4
--- /dev/null
+++ b/docs/V0.3.0_BASELINE.md
@@ -0,0 +1,590 @@
+# Codetrip v0.3.0 implementation baseline
+
+Status: Local release-candidate gates passed; GitHub native build matrix pending
+Measured: 2026-07-29
+Scope: `brief`, Go Stable, TypeScript Stable
+
+This document records the implementation and acceptance evidence for v0.3.0.
+Stable is claimed only after every language-quality, product-value,
+performance, and regression gate below has passed.
+
+## Frozen repositories
+
+| Language | Repository | Commit |
+|---|---|---|
+| Go | etcd | `58d7ebfe45bcef11245c1a7b0037af5500f472a6` |
+| Go | Kubernetes | `a9e4f5b7862e84c4152eabe2e960f3f6fb9a4867` |
+| Go | runc | `e2b8e8b836717456b3ddbcfa8b8d89d7331b3f2e` |
+| TypeScript | Understand-Anything | `09ede1917ffd043e6d5bbc8a80b45760814c2d7f` |
+| TypeScript | openclaw | `d8d8dc7421885c984e23a9e794a29972ff9c56d2` |
+| TypeScript | pi | `c820aa26fe0907e053e881a957722693fc094c9c` |
+
+## Initial indexing baseline
+
+Indexes were built from the v0.3.0 working tree with semantic embeddings
+disabled and an empty, isolated data directory.
+
+| Language | Repository | Indexed files | Nodes | Edges | Duration |
+|---|---|---:|---:|---:|---:|
+| Go | etcd | 1,228 | 10,659 | 27,763 | 5.90 s |
+| Go | Kubernetes | 17,389 | 107,386 | 277,588 | 121.23 s |
+| Go | runc | 306 | 1,719 | 7,358 | 1.42 s |
+| TypeScript | Understand-Anything | 328 | 1,322 | 3,031 | 2.70 s |
+| TypeScript | openclaw | 7,779 | 56,484 | 128,983 | 142.82 s |
+| TypeScript | pi | 1,053 | 9,141 | 30,699 | 14.29 s |
+
+These measurements are single runs and are used to detect material regressions,
+not as a comparative benchmark.
+
+After the generalized Go multi-module fix, the Go repositories were rebuilt:
+
+| Repository | Indexed files | Nodes | Edges | Duration |
+|---|---:|---:|---:|---:|
+| etcd | 1,228 | 10,763 | 32,825 | 14.71 s |
+| Kubernetes | 17,389 | 107,547 | 394,457 | 139.10 s |
+| runc | 306 | 1,764 | 4,569 | 3.02–3.64 s |
+
+The increase in etcd and Kubernetes is consistent with newly resolved nested
+module imports and calls. The runc edge-count decrease is not treated as proof
+of either improvement or regression: its semantic nodes and semantic edges
+were identical across two consecutive rebuilds and require source review.
+Community and derived process identifiers varied between those rebuilds,
+while all 1,510 non-derived nodes and 3,116 non-derived edges were identical.
+Derived graph determinism remains a separate issue; it does not affect
+`brief`, which excludes community and process nodes.
+
+The next Go semantic pass loads every discovered module with compiler type
+information and includes test variants. On etcd it produced 10,901 nodes and
+42,139 edges in 28.76 seconds on a warm module cache. In the same deterministic
+40-call candidate set, repository targets increased from 14 to 22. The newly
+resolved cases include receiver methods, nested-module calls, and test callers.
+The remaining 18 candidates are currently all standard-library, third-party,
+runtime, or interface-dispatched calls; that classification still requires
+human sign-off before it can be reported as 22/22 static recall.
+
+Go compiler semantics now use a fixed `linux/amd64` target so identical source
+produces the same semantic graph on macOS, Linux, and Windows. This corrected
+runc's Linux-only `Run`, `Openat`, `prepareCriuRestoreMounts`, and `Close`
+targets while removing a false `errors.New` same-name edge. Repositories with
+at most 5,000 Go files include compiler-loaded test variants; larger
+repositories retain test source and lexical test relationships without paying
+the unbounded type-checking cost. An all-tests Kubernetes experiment was
+terminated after more than six minutes.
+
+For multi-module repositories, a temporary external Go workspace now loads all
+module patterns in one compiler operation without changing repository files.
+Kubernetes completed in 169.29 seconds with 109,302 nodes and 493,561 edges,
+versus 139.10 seconds and 394,457 edges before full staging-module semantics.
+In 39 shared recall candidates, seven previously missing repository calls were
+resolved, including `Insert`, `Resource`, `Forbidden`, `SetHeader`,
+`DeepCopyInto`, and `StartStructuredLogging`. Raw emitted targets increased
+from 18/40 to 25/40; remaining candidates are primarily standard library,
+third-party, declarations, or dynamic/interface dispatch and still require
+human classification.
+
+Across the six deterministic recall candidate sets, none of the 156 emitted
+repository targets depended on the `unique-global` fallback. Because earlier
+reviews found concrete same-name false positives, Go and TypeScript/TSX now
+reject repository-wide uniqueness as call-resolution evidence. Their calls
+must be supported by lexical scope, imports/bindings, receiver types, or
+compiler semantics. Other languages retain their existing behavior until
+separately reviewed.
+
+## Initial `brief` implementation
+
+The first vertical slice is available through:
+
+- Go API: `Engine.Brief`
+- CLI: `codetrip brief`
+- MCP: `brief`
+
+The initial implementation provides:
+
+- exact symbol-name or node-ID selection;
+- explicit ambiguity and missing-symbol errors;
+- bounded source excerpts;
+- ranked incoming, outgoing, contract, implementation, and entrypoint groups;
+- member relationships for classes, interfaces, and structures;
+- separate related-test results so test callers cannot displace production
+ relationships;
+- direct impact counts;
+- edge confidence and resolution evidence;
+- deterministic JSON and terminal text output;
+- observable source, relationship, and related-test truncation.
+
+## First real-repository observations (historical)
+
+This section records the first implementation pass. Its open measurements and
+blockers were subsequently resolved by the final release-candidate validation
+later in this document.
+
+### Confirmed useful behavior
+
+- Kubernetes `NewMainKubelet` returned its production caller, direct test,
+ high-confidence cross-file callees, source excerpt, impact count, and
+ truncation state in one query.
+- pi `createAgentSession` separated direct test callers from production
+ relationships and returned six high-confidence related tests.
+- Understand-Anything `GraphBuilder` returned bounded `HAS_METHOD`
+ relationships and its matching test file instead of a raw structural
+ neighborhood.
+- Independent-process query times observed for the first cases were
+ approximately 0.08–0.16 seconds, including process startup. Formal P95
+ measurements are still required.
+
+### Issues found and corrected
+
+1. Text-only test discovery originally accepted unrelated same-name symbols
+ from other packages. It now requires a matching test-file family in the
+ same directory unless a direct semantic relationship exists.
+2. Type briefs originally omitted `HAS_METHOD` and `HAS_PROPERTY`, leaving
+ classes without their most useful bounded context. These relations are now
+ included.
+3. Test callers originally consumed the main relationship budget and could
+ hide production implementations or callers. Tests are now reported
+ separately.
+4. Persisted member edges without an explicit reason now receive a
+ deterministic `declared-method` or `declared-property` evidence label in
+ the brief result.
+5. Go compiler semantics originally ran only once from the repository root.
+ It now loads each discovered module independently, reconciles object
+ identities for local replacements, and includes test packages.
+6. TypeScript path mappings originally loaded only one root config and resolved
+ `baseUrl` relative to the repository root. Nested monorepo configs are now
+ discovered, ordered by closest source directory, and resolved relative to
+ their own config directory with deterministic longest-alias matching.
+7. NodeNext `.js` source specifiers originally did not bind to their `.ts`/
+ `.tsx` source targets. Exact emitted-to-source extension mapping and
+ implicit-constructor resolution increased Understand-Anything from 3,031
+ to 3,511 edges. Its deterministic recall candidate set improved from 39/40
+ emitted repository targets to 40/40; source review is still required before
+ treating that as a formal recall result.
+8. Local workspace package imports now resolve from `package.json` package
+ names to source entrypoints. Fast-path parsing now preserves named imports,
+ aliases, named re-exports, and wildcard barrel exports. On pi this corrected
+ a false same-name edge for `getDefaultAgentDir` and added precise
+ `Container` constructor and inherited `addChild` calls. On openclaw, the
+ named-binding portion of this work increased edges from 128,983 to 155,092
+ while reducing sampled `receiver-type-ownerless-definition` calls from five
+ to one. The final wildcard-barrel rebuild produced 155,510 edges and changed
+ the deterministic raw recall candidates from 15/40 to 16/40 by resolving
+ `createExecApprovalHandlers → errorShape` through two barrel layers with
+ `import-resolved-default-args` evidence.
+
+### Quality blockers found in the initial pass
+
+1. **Go cross-package call recall (resolved in working tree):** etcd contains statically decidable calls
+ from `server/embed/etcd.go` and
+ `tests/framework/integration/cluster.go` to
+ `server/etcdserver/server.go:NewServer`, but the current graph does not emit
+ those incoming `CALLS` relationships. The root cause was single-module-only
+ Go configuration in a multi-module repository. Multi-module discovery and
+ qualified import resolution now emit the production `StartEtcd → NewServer`
+ call and separate the integration-test caller. A regression fixture covers
+ calls both within and across nested modules.
+2. **Recall sampling classification:** source-driven sampling must keep
+ language declarations, built-ins, dynamic calls, external calls, and
+ statically decidable repository calls in separate cohorts. Early Go and
+ TypeScript worksheets exposed declaration and dynamic-method candidates
+ that require explicit review rather than automatic scoring.
+3. **Stable evidence was incomplete at this stage:** precision, recall,
+ confidence intervals, and the 30-symbol-per-language brief review had not
+ yet been completed. The final sections below record their completion.
+
+The first blocker directly affected `brief` completeness. The final Go
+real-repository recall review and correction pass below closed it before the
+Stable promotion.
+
+## Validation tooling baseline
+
+The maintainer-local validation tools now accept complete persisted graph
+exports (`nodes.csv` and `edges.csv`) in addition to parser-inspection CSVs.
+Source-driven recall sampling has been extended to Go and TypeScript/TSX.
+
+The first complete candidate generation produced:
+
+- 89/120/71 precision candidates for etcd/Kubernetes/runc;
+- 80/138/130 precision candidates for Understand-Anything/openclaw/pi;
+- 40 deterministic source-driven recall candidates for each of the six
+ repositories.
+
+The recall sampler now excludes dependency/vendor trees, generated sources,
+language built-ins, and call sites without a callable graph owner. An emitted
+`yes` or `no` is still not itself a verdict: callbacks, external APIs,
+interface dispatch, runtime library calls, and same-name targets must be
+classified by inspecting source.
+
+At this historical checkpoint the worksheets were unreviewed and therefore
+were not yet quality evidence. Their completed reviews appear below.
+
+## Brief review and performance candidates
+
+A maintainer-local deterministic sampler selected 10 production symbols from
+each frozen repository: 30 Go and 30 TypeScript briefs in total. Selection is
+language-filtered and stratified across functions, methods, structs/classes,
+and interfaces. Every query succeeded.
+
+Automated invariants found zero violations across the 60 results:
+
+- requested and returned node IDs matched;
+- every relationship had a valid direction, type, endpoint, confidence, and
+ evidence reason;
+- no File, Folder, Community, or Process node entered the context pack;
+- the 30-relationship and 40-source-line budgets were respected;
+- related tests were separated from production relationships.
+
+Repeating all 60 queries produced byte-equivalent normalized JSON after
+excluding elapsed-time observations. Independent CLI invocations had:
+
+- median latency: 84.80 ms;
+- P95 latency: 115.86 ms;
+- maximum observed latency: 202.09 ms.
+
+These final measurements used the release-candidate binary and the final
+six-repository index, with warm filesystem caches. They include process
+startup, store open, query, JSON encoding, and store close. The 60 results
+contained 415 evidence-backed relationships and 21 related-test results.
+Eighteen results explicitly reported budget truncation. A second independent
+run produced identical result objects for all 60 cases. The measurements pass
+the warm P95 target of 500 ms. A controlled cold-cache large-repository
+At that checkpoint, a controlled large-repository measurement and human
+semantic review of all 60 briefs remained required. Both were completed in the
+final release-candidate gate recorded below.
+
+After the A/B-driven compact relation model was introduced, the same 60
+requests were regenerated twice. All result objects remained deterministic,
+with the same 415 relationships, 21 related-test results, and 18 explicit
+truncation reports. The flattened evidence model produced zero structural
+invariant violations. Independent CLI latency remained within the gate:
+
+- median latency: 75.82 ms;
+- P95 latency: 123.24 ms;
+- maximum observed latency: 207.45 ms.
+
+The compact representation removes redundant edge identifiers and endpoints;
+it does not reduce the relationship, source-line, or related-test budgets.
+
+## Final release-candidate indexing and worksheets
+
+The final working-tree binary was rebuilt after the Go compiler-semantic,
+TypeScript binding, and stable-language precision changes. Each frozen
+repository was indexed into one isolated shared store and exported:
+
+| Language | Repository | Indexed files | Nodes | Edges | Duration |
+|---|---|---:|---:|---:|---:|
+| Go | etcd | 1,228 | 10,593 | 55,215 | 6.96 s |
+| Go | Kubernetes | 17,389 | 109,212 | 486,819 | 158.66 s |
+| Go | runc | 306 | 1,756 | 4,932 | 2.86 s |
+| TypeScript | Understand-Anything | 328 | 1,327 | 3,503 | 2.71 s |
+| TypeScript | openclaw | 7,779 | 56,470 | 153,998 | 144.15 s |
+| TypeScript | pi | 1,053 | 9,128 | 32,649 | 14.37 s |
+
+The final deterministic source-driven candidate sets produced the following
+raw graph-target matches:
+
+| Language | Repository | Emitted repository target candidates |
+|---|---|---:|
+| Go | etcd | 11 / 40 |
+| Go | Kubernetes | 25 / 40 |
+| Go | runc | 28 / 40 |
+| TypeScript | Understand-Anything | 40 / 40 |
+| TypeScript | openclaw | 16 / 40 |
+| TypeScript | pi | 25 / 40 |
+
+These fractions are diagnostic candidate matches, not published recall:
+unmatched cases include external, standard-library, declaration, callback,
+dynamic-dispatch, and potentially missing repository targets. A reviewer must
+classify their denominators before computing Precision/Recall.
+
+Language-filtered precision worksheets contain 233 Go and 329 TypeScript/TSX
+relationship candidates. None of their `CALLS` edges uses the rejected
+`unique-global` fallback. The validation sampler was corrected to filter both
+relationship endpoints by the requested language; without that check,
+multilingual repositories incorrectly mixed C, Swift, and Kotlin edges into
+Go or TypeScript review sheets.
+
+## Human-reviewed Precision and Recall
+
+All six final precision and recall worksheets were reviewed against source by
+`mengshi3` on 2026-07-29. Every row has a final verdict; no blank or provisional
+verdict remains.
+
+### Precision
+
+| Language | Repository | TP | FP | Precision |
+|---|---|---:|---:|---:|
+| Go | etcd | 64 | 3 | 95.52% |
+| Go | Kubernetes | 97 | 3 | 97.00% |
+| Go | runc | 65 | 1 | 98.48% |
+| **Go total** | **3 repositories** | **226** | **7** | **97.00%** |
+| TypeScript | Understand-Anything | 100 | 0 | 100.00% |
+| TypeScript | openclaw | 109 | 0 | 100.00% |
+| TypeScript | pi | 119 | 1 | 99.17% |
+| **TypeScript total** | **3 repositories** | **328** | **1** | **99.70%** |
+
+The 95% Wilson intervals are 93.93–98.54% for Go and 98.30–99.95% for
+TypeScript. Go relation-group precision is 95.00% for `CALLS`, 97.87% for
+`IMPLEMENTS`, and 100% for both `EXTENDS` and `HAS_METHOD`. TypeScript groups
+range from 98.33% to 100%.
+
+### Recall
+
+| Language | Repository | TP | FN | Excluded | Static recall |
+|---|---|---:|---:|---:|---:|
+| Go | etcd | 8 | 8 | 24 | 50.00% |
+| Go | Kubernetes | 22 | 0 | 18 | 100.00% |
+| Go | runc | 27 | 0 | 13 | 100.00% |
+| **Go total** | **3 repositories** | **57** | **8** | **55** | **87.69%** |
+| TypeScript | Understand-Anything | 39 | 0 | 1 | 100.00% |
+| TypeScript | openclaw | 16 | 0 | 24 | 100.00% |
+| TypeScript | pi | 22 | 1 | 17 | 95.65% |
+| **TypeScript total** | **3 repositories** | **77** | **1** | **42** | **98.72%** |
+
+The 95% Wilson intervals are 77.55–93.63% for Go and 93.09–99.77% for
+TypeScript. Excluded rows are standard-library, external, dynamic, generated,
+or otherwise outside the statically decidable repository-call denominator.
+
+### Gate result
+
+- The initially reviewed TypeScript sample passes the v0.3.0 static Precision
+ and Recall thresholds.
+- The initially reviewed Go sample met the Precision threshold but missed the
+ 93% Recall threshold. This result triggered a blocking semantic correction
+ pass rather than a Stable claim.
+
+### Post-review correction pass
+
+The human verdicts exposed both engine defects and two worksheet
+classification defects. Corrections were made without changing the frozen
+repositories or replacing the reviewed rows:
+
+- etcd's seven real false negatives now resolve through compiler semantics.
+ The eighth row, `types.ID(101).String()`, sampled the `types.ID(101)` type
+ conversion rather than a repository call and is correctly excluded.
+- TypeScript's `getDefaultAgentDir` alias now resolves to the exported
+ `config.ts:getAgentDir` target. The graph already contained the exact edge;
+ the recall sampler was corrected to understand named-import aliases.
+- Three etcd false-positive edges were removed when compiler-loaded packages
+ began replacing superseded lexical semantic relationships.
+- runc's `testEnosysStub → Hook.Run` edge was removed. `filter.Run()` has a
+ local variable receiver and must not be resolved by guessing among imported
+ packages.
+- Kubernetes no longer maps `types.UID(...)` to a repository function or
+ `pointer.String(...)` to `rand.String`.
+- The reviewed Kubernetes
+ `AddHandlers → resource/v1alpha2.SwaggerDoc` row is reclassified from FP to
+ TP: the frozen source directly invokes that method at lines 625, 633, and
+ 643. A fresh compiler-semantic rebuild emits the exact type target.
+
+The corrected reviewed denominators are:
+
+| Language | Metric | TP | FP/FN | Excluded | Result | 95% Wilson interval |
+|---|---|---:|---:|---:|---:|---:|
+| Go | Precision | 227 | 0 FP | — | 100.00% | 98.34–100% |
+| Go | Static recall | 64 | 0 FN | 56 | 100.00% | 94.34–100% |
+| TypeScript | Precision | 328 | 1 FP | — | 99.70% | 98.30–99.95% |
+| TypeScript | Static recall | 78 | 0 FN | 42 | 100.00% | 95.31–100% |
+
+Fresh real-repository rebuilds confirmed the corrected Go behavior:
+
+| Repository | Indexed files | Nodes | Edges | Duration |
+|---|---:|---:|---:|---:|
+| etcd | 1,228 | 10,893 | 42,857 | 25.18 s |
+| Kubernetes | 17,389 | 109,461 | 474,298 | 175.66 s |
+| runc | 306 | 1,757 | 4,976 | 2.94 s |
+
+Go and TypeScript therefore pass the v0.3.0 Precision/Recall gate. This quality
+gate alone does not complete the Stable or v0.3.0 release decision; the
+remaining product and regression gates still apply.
+
+## Next gates
+
+1. Run the GitHub native release matrix after the release-candidate changes
+ are committed and pushed.
+2. Set `VERSION` to `0.3.0` and complete public README, quality report, and
+ release-note updates.
+
+## Human-reviewed Brief gate
+
+`mengshi3` reviewed 60 deterministic Brief Context Packs on 2026-07-29:
+10 symbols from each of the six frozen repositories, totaling 30 Go and 30
+TypeScript/TSX symbols.
+
+The initial review found:
+
+- 60/60 correct symbol identities and definition files;
+- 60/60 complete relationship evidence and valid budgets;
+- 59/60 relevant context packs;
+- one blocking Go failure: etcd's `maintenanceServer` omitted its generated
+ `etcdserverpb.MaintenanceServer` interface contract;
+- a systematic zero-based public line-number defect in the Go results.
+
+Both defects were corrected before accepting the gate:
+
+- public graph and API locations now use one-based editor/CLI line numbers,
+ and source extraction plus diff-range matching use the same convention;
+- compiler-known interfaces in generated local sources are retained as
+ semantic contract nodes even when their generated implementation source is
+ excluded from the syntax index;
+- identical declarations loaded through separate Go module operations are
+ reconciled by exact package-qualified method signatures;
+- embedded interface methods retain their declaring owner, while the embedding
+ itself is represented explicitly with `EXTENDS`.
+
+Fresh real-repository verification confirmed:
+
+- `maintenanceServer IMPLEMENTS etcdserverpb.MaintenanceServer`;
+- `ServerV3 EXTENDS Server` and `ServerV3 EXTENDS RaftStatusGetter`, without
+ redeclaring their promoted methods under the wrong owner;
+- Kubernetes `roundTrip` calls
+ `runtime.Object.DeepCopyObject`, not an unrelated same-name interface method.
+
+After the correction pass, all 60 reviewed cases satisfy the Brief identity,
+location, relevance, evidence, ambiguity, budget, and related-test acceptance
+criteria. The Brief human-review gate passes.
+
+## Final performance and regression gates
+
+The release-candidate native `darwin/arm64` binary was measured with 20
+independent CLI processes per repository. The process was cold for every
+query; the macOS filesystem cache was not forcibly purged, so these results
+must not be described as physical cold-cache measurements.
+
+| Repository scale | Repository | First | Median | P95 | Maximum |
+|---|---|---:|---:|---:|---:|
+| Small | runc | 121.05 ms | 75.12 ms | 85.11 ms | 121.05 ms |
+| Medium | etcd | 83.38 ms | 75.60 ms | 82.33 ms | 83.38 ms |
+| Large | Kubernetes | 191.83 ms | 90.53 ms | 102.02 ms | 191.83 ms |
+
+The measurements include process startup, repository/store open, query,
+encoding, and close. They pass the warm P95 target of 500 ms and remain well
+below the 2-second large-repository target.
+
+The strict production quality runner passed every maintained corpus case after
+the maintainer-local gold selectors were migrated from zero-based to one-based
+source coordinates:
+
+- all 12 advertised language fixtures passed;
+- all five C++ Stable fixtures passed with 100% Precision and Recall;
+- `go test ./...` and `go vet ./...` passed;
+- the native `darwin/arm64` release build passed.
+
+The C++ and general language regression gate therefore passes. The remaining
+cross-platform gate is the GitHub native build matrix, which must run from the
+committed release-candidate branch.
+
+The expanded 20-task Agent A/B result is recorded in
+[V0.3.0_AGENT_AB.md](V0.3.0_AGENT_AB.md). B reduced aggregate commands by
+7.0%, input tokens by 5.4%, and output tokens by 7.7%, while the independently
+blinded 16-task expansion retained a 16/16 safe-pass rate and slightly higher
+correctness. The result supports a bounded-context starting-point claim, not a
+claim that Agents no longer need to read source.
+
+## Automatic refresh regression (2026-08-03)
+
+The final automatic-refresh implementation was rebuilt and exercised against
+the three frozen Go repositories and three frozen TypeScript repositories.
+Every repository completed a full atomic build. A second unchanged invocation
+returned `noop`; representative cold Engine checks were 2.01 seconds for
+openclaw (7,777 files) and 2.51 seconds for Kubernetes (17,385 files). The
+`noop` fast path reads fingerprints and snapshot state without opening the
+large graph database.
+
+Real-repository temporary copies then received one body-only edit:
+
+| Repository | Language | Update | Files | Nodes | Edges | Duration |
+|---|---|---|---:|---:|---:|---:|
+| runc | Go | `full-fallback` | 305 | 1,770 | 5,023 | 2.73 s |
+| Understand-Anything | TypeScript | `incremental` | 328 | 1,327 | 3,503 | 0.65 s |
+
+The TypeScript result retained the full-build node and edge counts (the
+preceding full build took 2.81 seconds). An attempted Go incremental refresh
+revealed non-canonical compiler method-owner reconciliation across package and
+test variants. It was rejected rather than promoted: v0.3.0 deliberately
+routes every Go change to a clean full snapshot and records the reason in
+`fallbackReason`. This preserves correctness while still giving Go Agents a
+fast `noop` and a closed edit → refresh → query workflow.
+
+The same run found and fixed repository-directory symbolic links being treated
+as regular files by fingerprinting. Symlinks and other non-regular filesystem
+entries are now excluded consistently and covered by an automated regression
+test.
+
+## Final incremental indexing gate (2026-08-03)
+
+The earlier TypeScript-only/full-fallback result above was superseded by the
+final language-neutral implementation. Persisted content, structure, and call
+shape fingerprints now select file-local, callable, structural-dependency, or
+safe full-rebuild paths. Graph, symbol, source, vector invalidation, metadata,
+and the authoritative graph revision are committed through one recoverable
+protocol. Once MCP watch observes a change, its freshness barrier blocks stale
+reads until that revision is published.
+
+Real-repository body-only measurements used temporary repository copies:
+
+| Repository | Language | Files | Nodes | Edges | Incremental duration |
+|---|---|---:|---:|---:|---:|
+| runc | Go | 305 | 1,770 | 5,023 | 1.59 s |
+| etcd | Go | 1,217 | 11,539 | 45,569 | 2.81 s |
+| Kubernetes | Go | 17,385 | 114,075 | 489,164 | 2.12 s |
+| Understand-Anything | TypeScript | 328 | 1,331 | 3,525 | 0.21 s |
+| openclaw | TypeScript | 7,777 | 56,485 | 154,009 | 1.41 s |
+| pi | TypeScript | 1,053 | 9,128 | 32,649 | 0.29 s |
+
+Additional gates passed:
+
+- Understand-Anything CALLS change: 0.34 seconds, incremental core nodes and
+ edges exactly matched a clean full build.
+- pi import change: 12.13 seconds, incremental core nodes and edges exactly
+ matched a clean full build.
+- openclaw add/delete/rename: 16.18 seconds; added and renamed source was
+ immediately searchable and deleted source returned no matches.
+- Kubernetes file-local publication preserved 114,075 nodes and 489,164 edges,
+ and source search immediately returned the updated line and context.
+- two independent runc clean full builds produced byte-identical node and edge
+ CSV exports after deterministic Community and Process ordering fixes.
+
+The automated v0.3.0 structural-change gate adds 16 isolated cases across Go
+and TypeScript. Each language covers function add, delete, rename and move;
+signature and interface changes; a new cross-file call; and an import change.
+Every case starts from a persisted full index, applies the edit through the
+automatic incremental path, builds a second clean full index of the final
+checkout, and compares the complete graph plus symbol and source queries.
+Removed symbols and source must also disappear, and the published revision must
+advance exactly once. Run it directly with `make incremental-gate`; it is also
+part of `make release-gate` and the GitHub release workflow.
+
+This gate exposed and fixed a Go regression where a body-level call change in
+a file containing an interface could remove compiler-generated interface
+method nodes and their `HAS_METHOD`, `METHOD_IMPLEMENTS`, and `DISPATCHES_TO`
+relationships. Body-only rebuilds now preserve those compiler facts when the
+declaration fingerprint is unchanged.
+
+### Real-repository structural smoke test (2026-08-04)
+
+The same compound structural edit was applied to temporary copies of runc and
+Understand-Anything: rename a function, change its signature, add an interface
+method and implementation, move a call target to a new file, add a function,
+and introduce cross-file calls. The source repositories were not modified.
+
+| Repository | Language | Changed files | Incremental duration | Final nodes | Final edges |
+|---|---|---:|---:|---:|---:|
+| runc (`e2b8e8b8`) | Go | 2 | 1.53 s | 1,780 | 5,050 |
+| Understand-Anything (`09ede19`) | TypeScript | 2 | 0.33 s | 1,344 | 3,559 |
+
+Both refreshes used `mode=incremental`, advanced revision 1 to revision 2,
+made the new symbols and source immediately searchable, and removed the old
+source from search. `brief` returned the new signature and both direct callers;
+`impact --change signature` returned two direct dependents plus the transitive
+entry function. Go relations had compiler-resolved confidence 1.0, while the
+TypeScript cross-file imports resolved at confidence 0.9.
+
+For both repositories, exported `nodes.csv` and `edges.csv` from the
+incrementally refreshed index were byte-for-byte identical to exports from a
+separate clean full index of the final checkout. This includes `CALLS`,
+`IMPLEMENTS`, `HAS_METHOD`, `METHOD_IMPLEMENTS`, and `DISPATCHES_TO` facts.
+
+Community/Process materialization remains a derived layer. Core graph equality
+is the release correctness gate; a small number of alternative but valid
+`STEP_IN_PROCESS` intermediate-path selections is tracked as a later
+determinism improvement and does not alter CALLS/IMPORTS/IMPLEMENTS semantics.
diff --git a/docs/V0.3.0_IMPACT_AGENT_AB.md b/docs/V0.3.0_IMPACT_AGENT_AB.md
new file mode 100644
index 0000000..d725d7d
--- /dev/null
+++ b/docs/V0.3.0_IMPACT_AGENT_AB.md
@@ -0,0 +1,175 @@
+# Codetrip `impact` Agent A/B evaluation
+
+Status: completed; promotional gate not passed
+Measured: 2026-07-30
+Agent: Codex CLI 0.144.2
+
+## Question and method
+
+The evaluation asked whether starting with `impact` reduces the work required
+to assess a symbol change without losing dependency coverage or answer quality.
+
+Twenty real-repository tasks covered ten Go and ten TypeScript symbols across
+etcd, Kubernetes, runc, openclaw, pi, and Understand-Anything. Symbols included
+functions, methods, structs/classes, and interfaces with 2–18 persisted
+two-level impact candidates.
+
+Each task ran in independent sessions:
+
+- A used repository source and Git tools only.
+- B had to run `codetrip impact` first with depth 2 and limit 50, then verify
+ the result against source.
+
+Answers had to identify direct and important second-level dependents,
+production entry paths, relevant tests, and concrete modification risks.
+Twenty B sessions all invoked `impact` first. Metrics came from Codex JSONL
+events. Six independent reviewers scored blinded X/Y answers against source.
+
+## Operational results
+
+| Metric | A: native tools | B: `impact` first | Change |
+|---|---:|---:|---:|
+| Agent commands | 134 | 138 | **+3.0%** |
+| Native search commands | 89 | 71 | −20.2% |
+| File-reading commands | 114 | 97 | −14.9% |
+| Input tokens | 4,801,710 | 4,611,928 | −4.0% |
+| Output tokens | 41,554 | 40,938 | −1.5% |
+| Median commands per task | 7 | 7 | no change |
+
+B used fewer commands in only 6/20 tasks and fewer input tokens in 11/20.
+The initial graph query displaced some native search, but Agents still
+performed extensive source verification.
+
+## Blinded answer quality
+
+| Arm | Safe passes | Correctness | Evidence |
+|---|---:|---:|---:|
+| A | 19/20 | 35/40 | 40/40 |
+| B | 19/20 | 29/40 | 37/40 |
+
+The B arm did not preserve answer quality. Repeated failure modes were:
+
+- treating interface implementers or contracts as confirmed runtime callers;
+- describing alternative interface implementations as dependents of one
+ concrete method;
+- presenting structurally reachable candidates with too much certainty;
+- missing production composition paths not connected by the persisted graph;
+- continuing to read broad source because the flat result did not separate
+ confirmed dependencies from review candidates.
+
+## Engine latency
+
+The 20 frozen queries were executed five times through independent CLI
+processes:
+
+| Median | P95 | Maximum | Failures |
+|---:|---:|---:|---:|
+| 68.8 ms | 78.2 ms | 185.0 ms | 0/100 |
+
+The engine is fast enough; latency is not the blocker.
+
+## Conclusion
+
+`impact` is currently useful as a fast, typed candidate generator, but this
+evaluation does not support a claim that it reduces complete Agent work or
+improves answer quality. It must not be marketed with an efficiency claim yet.
+
+The next iteration should separate:
+
+1. confirmed call/import dependents;
+2. contract implementers and overrides;
+3. alternative implementations;
+4. inferred entry paths;
+5. uncertain review candidates.
+
+It should also explain why each candidate is considered affected and expose
+coverage limitations. A second controlled A/B evaluation is required before
+promotion.
+
+## Remediation implemented after the baseline
+
+The implementation was subsequently changed without altering this baseline:
+
+- `changeKind` now distinguishes behavior, signature, contract, and removal
+ analysis, with symbol-aware defaults;
+- results are separated into confirmed dependents, contract impacts, entry
+ points, related tests, and review candidates;
+- every result carries an evidence path, an explicit explanation, confidence,
+ and review state;
+- uncertainty is preserved after a dispatch bridge instead of being promoted
+ back to a confirmed dependency;
+- sibling implementations reached after an uncertain dispatch are excluded;
+- review-only candidates are no longer promoted into aggregated `diff` impact;
+- the legacy `impacted` and `edges` fields remain for API compatibility.
+
+Unit, integration, CLI, and real-repository structural regressions pass. The
+failed numbers above remain the honest pre-remediation baseline. A second
+Agent A/B run is still required before the README can claim measured impact
+efficiency.
+
+## Post-remediation controlled rerun (invalidated)
+
+The frozen second run appeared to complete on 2026-08-03, but subsequent
+validation found that five resumed B sessions invoked stale temporary stores.
+Their required `impact` command failed before the Agent continued with source
+tools alone. The original validator checked command presence but not its exit
+status or JSON payload. The following operational totals mix 15 valid B
+sessions with five invalid source-only sessions and are retained only as an
+audit record; they are not release evidence.
+
+| Metric | A: native tools | B: classified `impact` first | Change |
+|---|---:|---:|---:|
+| Agent commands | 147 | 134 | **−8.8%** |
+| Native search commands | 94 | 63 | **−33.0%** |
+| File-reading commands | 124 | 93 | **−25.0%** |
+| Input tokens | 5,210,166 | 4,717,225 | **−9.5%** |
+| Output tokens | 44,386 | 39,424 | **−11.2%** |
+| Median commands per task | 7 | 7 | no change |
+
+The remediation therefore reduced repository exploration and total model
+work. B used fewer input tokens in 14/20 paired tasks. It did not reduce the
+median number of commands.
+
+### Low-token blind review experiment
+
+The initial six-reviewer design was stopped because allowing every reviewer
+to explore a large repository repeated millions of input tokens. The final
+review retained blinded X/Y labels but used a locally generated evidence pack:
+
+- all 40 complete answers;
+- 108 exact source windows extracted from answer citations;
+- anonymized known failure modes from the first independent review;
+- no repository tools or additional source exploration;
+- one low-reasoning structured review call.
+
+The evidence pack was 146,037 bytes. Review usage was 51,719 input tokens and
+2,966 output tokens, including 722 reasoning tokens.
+
+| Arm | Safe passes | Correctness | Evidence |
+|---|---:|---:|---:|
+| A | 20/20 | 37/40 | 27/40 |
+| B | 20/20 | 31/40 | 27/40 |
+
+These scores are also invalid as a complete `impact` comparison because five
+B answers did not receive an `impact` result. In addition, the first compact
+evidence pack shared a citation-window budget between X and Y, which could
+unevenly support the two answers. Neither 31/40 nor its difference from A may
+be used as Codetrip accuracy evidence.
+
+This compact review is suitable for repeatable regression gating, but it is
+not equivalent to a fresh full-repository audit: it verifies cited evidence
+and known omission classes rather than independently rediscovering the entire
+dependency graph.
+
+## Current promotion decision
+
+The post-remediation run has no publishable comparative conclusion. It neither
+proves an accuracy regression nor proves equal accuracy. A valid rerun requires
+persistent, preflight-checked stores; successful JSON from every mandatory
+`impact` command; equal evidence budgets; and Golden Facts independent of the
+answers being scored.
+
+The frozen A results can be reused. Future evaluation should first score raw
+`impact` JSON against manually reviewed Golden Facts, then rerun only B. Engine
+changes must follow measured raw-output failures rather than the invalidated
+31/40 Agent score.
diff --git a/docs/V0.3.0_SOURCE_AGENT_AB.md b/docs/V0.3.0_SOURCE_AGENT_AB.md
new file mode 100644
index 0000000..31a78dd
--- /dev/null
+++ b/docs/V0.3.0_SOURCE_AGENT_AB.md
@@ -0,0 +1,105 @@
+# Codetrip `source` Agent A/B evaluation
+
+Status: complete
+Measured: 2026-07-30
+Agent: Codex CLI 0.144.2
+Mode: independent ephemeral sessions, repository source unmodified
+
+## Question
+
+Does `source` reduce the search/read work a Code Agent needs when it does not
+already know the target file, without reducing answer quality?
+
+The test isolates source location and local-context questions. It does not
+exercise graph traversal, impact analysis, or semantic call resolution.
+
+## Method
+
+Twenty real-repository tasks were split evenly between Go and TypeScript:
+
+| Language | Repositories | Tasks |
+|---|---|---:|
+| Go | etcd, Kubernetes, runc | 10 |
+| TypeScript | openclaw, pi, Understand-Anything | 10 |
+
+Tasks started from an error string, configuration name, interface name, class,
+or function, without giving the Agent a file path. Each answer had to report
+the exact repository-relative file, one-based lines, and a local behavioral
+fact established by surrounding source.
+
+Every task ran twice in a fresh Codex session:
+
+- A prohibited Codetrip and used repository-native search and file reads.
+- B had to run `codetrip source` first with `scope=code`, six context lines,
+ and a ten-result limit. It could read source afterward when the result did
+ not establish the requested fact.
+
+Sessions were limited to five shell commands. Metrics come from Codex JSONL
+events, not Agent self-reporting. All 20 B sessions successfully invoked
+`source` as their first command.
+
+One initial etcd question ambiguously referred to both a retry warning and a
+terminal timeout warning. That pair was rejected before aggregation and
+replaced with an unambiguous production CLI-flag task. Its discarded logs were
+retained locally.
+
+## Operational results
+
+| Metric | A: native tools | B: `source` first | Change |
+|---|---:|---:|---:|
+| Agent command executions | 61 | 41 | **−32.8%** |
+| Native search commands | 27 | 3 | **−88.9%** |
+| File-reading commands | 38 | 19 | **−50.0%** |
+| Input tokens | 1,794,010 | 1,263,631 | **−29.6%** |
+| Output tokens | 11,155 | 9,091 | **−18.5%** |
+| Median commands per task | 3 | 2 | **−33.3%** |
+| Median input tokens per task | 84,651 | 55,418 | **−34.5%** |
+
+B used fewer commands in 12/20 paired tasks and fewer input tokens in 15/20.
+Both arms had one recoverable command-output error signal; neither session
+failed.
+
+“File-reading command” means a completed shell command containing a bounded
+reader such as `sed`, `nl`, `awk`, `head`, or `tail`. It is an operational
+proxy, not a count of semantic files understood.
+
+## Independent blinded review
+
+The 40 final answers were deterministically relabeled X/Y and reviewed in six
+independent Codex sessions grouped by repository. Reviewers saw the task,
+answers, and repository source but not the A/B mapping.
+
+| Arm | Safe passes | Correctness | Evidence |
+|---|---:|---:|---:|
+| A | 20/20 | 37/40 | 40/40 |
+| B | 20/20 | 39/40 | 40/40 |
+
+There was no dangerous false claim and no answer-quality regression. Minor
+deductions concerned edge-case wording, not incorrect source locations.
+
+## Query latency
+
+The frozen 20-query set was executed five times through independent CLI
+processes, for 100 warm-filesystem observations including process startup,
+store open, query, JSON encoding, and close:
+
+| Median | P95 | Maximum | Failures |
+|---:|---:|---:|---:|
+| 75.1 ms | 93.8 ms | 285.8 ms | 0/100 |
+
+This is not a physical cold-cache benchmark and does not measure remote Agent
+model scheduling.
+
+## Honest conclusion
+
+For bounded source-location questions, `source` materially reduced Agent
+search, file-reading commands, and token consumption while preserving answer
+quality:
+
+> `source` replaces the usual path discovery, text search, and bounded file
+> read loop with one query that returns exact matches plus surrounding context.
+
+The result does not prove the same reduction for broad debugging or
+architecture tasks. Agents still need file reads when six context lines do not
+contain the complete behavioral evidence. Marketing should use the measured
+20-task result and keep that boundary explicit.
diff --git a/docs/assets/brief-agent-workflow.gif b/docs/assets/brief-agent-workflow.gif
new file mode 100644
index 0000000..e712b81
Binary files /dev/null and b/docs/assets/brief-agent-workflow.gif differ
diff --git a/docs/assets/generate_brief_agent_workflow.swift b/docs/assets/generate_brief_agent_workflow.swift
new file mode 100644
index 0000000..450f269
--- /dev/null
+++ b/docs/assets/generate_brief_agent_workflow.swift
@@ -0,0 +1,148 @@
+import AppKit
+import ImageIO
+import UniformTypeIdentifiers
+
+let output = CommandLine.arguments.count > 1
+ ? CommandLine.arguments[1]
+ : "brief-agent-workflow.gif"
+let width = 1200
+let height = 680
+
+let background = NSColor(calibratedRed: 0.035, green: 0.055, blue: 0.075, alpha: 1)
+let panel = NSColor(calibratedRed: 0.065, green: 0.095, blue: 0.125, alpha: 1)
+let border = NSColor(calibratedRed: 0.16, green: 0.23, blue: 0.29, alpha: 1)
+let primary = NSColor(calibratedWhite: 0.94, alpha: 1)
+let muted = NSColor(calibratedRed: 0.56, green: 0.64, blue: 0.70, alpha: 1)
+let red = NSColor(calibratedRed: 1.0, green: 0.43, blue: 0.43, alpha: 1)
+let green = NSColor(calibratedRed: 0.35, green: 0.88, blue: 0.62, alpha: 1)
+let cyan = NSColor(calibratedRed: 0.32, green: 0.76, blue: 0.96, alpha: 1)
+let yellow = NSColor(calibratedRed: 1.0, green: 0.78, blue: 0.31, alpha: 1)
+
+func font(_ size: CGFloat, bold: Bool = false) -> NSFont {
+ if bold {
+ return NSFont.monospacedSystemFont(ofSize: size, weight: .semibold)
+ }
+ return NSFont.monospacedSystemFont(ofSize: size, weight: .regular)
+}
+
+func drawText(_ value: String, x: CGFloat, y: CGFloat, size: CGFloat = 22,
+ color: NSColor = primary, bold: Bool = false) {
+ let attributes: [NSAttributedString.Key: Any] = [
+ .font: font(size, bold: bold),
+ .foregroundColor: color
+ ]
+ value.draw(at: NSPoint(x: x, y: CGFloat(height) - y - size - 4),
+ withAttributes: attributes)
+}
+
+func drawPanel(x: CGFloat, y: CGFloat, w: CGFloat, h: CGFloat) {
+ let rect = NSRect(x: x, y: CGFloat(height) - y - h, width: w, height: h)
+ let path = NSBezierPath(roundedRect: rect, xRadius: 14, yRadius: 14)
+ panel.setFill()
+ path.fill()
+ border.setStroke()
+ path.lineWidth = 2
+ path.stroke()
+}
+
+func makeFrame(stage: Int) -> CGImage {
+ let image = NSImage(size: NSSize(width: width, height: height))
+ image.lockFocus()
+ background.setFill()
+ NSRect(x: 0, y: 0, width: width, height: height).fill()
+
+ drawText("Understand one symbol", x: 46, y: 31, size: 31, bold: true)
+ drawText("Compare the Agent workflow", x: 46, y: 75, size: 18, color: muted)
+
+ drawPanel(x: 42, y: 125, w: 535, h: 480)
+ drawPanel(x: 623, y: 125, w: 535, h: 480)
+ drawText("WITHOUT CODETRIP", x: 72, y: 151, size: 18, color: red, bold: true)
+ drawText("WITH CODETRIP", x: 653, y: 151, size: 18, color: green, bold: true)
+
+ let oldSteps = [
+ "1 grep_search(symbol)",
+ "2 read_file(definition)",
+ "3 find_references(symbol)",
+ "4 read_file(callers)",
+ "5 find implementations",
+ "6 search + read tests"
+ ]
+ let visibleOld = min(oldSteps.count, max(1, stage + 1))
+ for index in 0..= 2 {
+ drawText("6+ Agent tool calls", x: 72, y: 526, size: 20, color: red, bold: true)
+ }
+
+ if stage >= 3 {
+ drawText("$ codetrip brief", x: 653, y: 205, size: 22, color: cyan, bold: true)
+ drawText(" maintenanceServer", x: 653, y: 240, size: 20, color: primary)
+ }
+ if stage >= 4 {
+ let results = [
+ "✓ source excerpt",
+ "✓ typed callers + callees",
+ "✓ contracts + implementations",
+ "✓ related tests",
+ "✓ confidence + evidence"
+ ]
+ for (index, line) in results.enumerated() {
+ drawText(line, x: 653, y: CGFloat(296 + index * 40),
+ size: 18, color: green)
+ }
+ drawText("1 bounded query", x: 653, y: 526, size: 20, color: green, bold: true)
+ }
+
+ if stage >= 5 {
+ let rect = NSRect(x: 623, y: 26, width: 535, height: 60)
+ let path = NSBezierPath(roundedRect: rect, xRadius: 12, yRadius: 12)
+ NSColor(calibratedRed: 0.06, green: 0.20, blue: 0.15, alpha: 1).setFill()
+ path.fill()
+ drawText("Measured: −7.0% commands · −5.4% input tokens",
+ x: 646, y: 609, size: 17, color: green, bold: true)
+ }
+
+ image.unlockFocus()
+ var proposed = NSRect(x: 0, y: 0, width: width, height: height)
+ return image.cgImage(forProposedRect: &proposed, context: nil, hints: nil)!
+}
+
+let url = URL(fileURLWithPath: output) as CFURL
+guard let destination = CGImageDestinationCreateWithURL(
+ url, UTType.gif.identifier as CFString, 6, nil
+) else {
+ fatalError("cannot create GIF destination")
+}
+
+let global: [CFString: Any] = [
+ kCGImagePropertyGIFLoopCount: 0
+]
+CGImageDestinationSetProperties(destination, [
+ kCGImagePropertyGIFDictionary: global
+] as CFDictionary)
+
+for stage in 0..<6 {
+ let delay = stage == 5 ? 3.2 : 0.85
+ let frame: [CFString: Any] = [
+ kCGImagePropertyGIFDelayTime: delay,
+ kCGImagePropertyGIFUnclampedDelayTime: delay
+ ]
+ CGImageDestinationAddImage(destination, makeFrame(stage: stage), [
+ kCGImagePropertyGIFDictionary: frame
+ ] as CFDictionary)
+}
+
+guard CGImageDestinationFinalize(destination) else {
+ fatalError("cannot finalize GIF")
+}
+
+guard let source = CGImageSourceCreateWithURL(url, nil) else {
+ fatalError("cannot reopen generated GIF")
+}
+let frameCount = CGImageSourceGetCount(source)
+guard frameCount == 6 else {
+ fatalError("expected 6 GIF frames, got \(frameCount)")
+}
+print("generated \(output) with \(frameCount) frames")
diff --git a/engine.go b/engine.go
index d58adca..476bd72 100644
--- a/engine.go
+++ b/engine.go
@@ -22,6 +22,7 @@ import (
"github.com/cockroachdb/pebble/v2"
"github.com/mengshi02/codetrip/internal/export/csv"
"github.com/mengshi02/codetrip/internal/graph"
+ inc "github.com/mengshi02/codetrip/internal/incremental"
"github.com/mengshi02/codetrip/internal/ingest"
"github.com/mengshi02/codetrip/internal/search"
"github.com/mengshi02/codetrip/internal/search/semantic"
@@ -53,12 +54,20 @@ type Engine struct {
// IndexResult summarizes one completed repository snapshot.
type IndexResult struct {
- Repo string `json:"repo"`
- Files int `json:"files"`
- Nodes int `json:"nodes"`
- Edges int `json:"edges"`
- Duration float64 `json:"duration"`
- CSVPath string `json:"csvPath,omitempty"`
+ Repo string `json:"repo"`
+ Mode string `json:"mode"`
+ Files int `json:"files"`
+ Added int `json:"addedFiles,omitempty"`
+ Modified int `json:"modifiedFiles,omitempty"`
+ Deleted int `json:"deletedFiles,omitempty"`
+ Renamed int `json:"renamedFiles,omitempty"`
+ BaseRevision uint64 `json:"baseRevision,omitempty"`
+ Revision uint64 `json:"revision,omitempty"`
+ FallbackReason string `json:"fallbackReason,omitempty"`
+ Nodes int `json:"nodes"`
+ Edges int `json:"edges"`
+ Duration float64 `json:"duration"`
+ CSVPath string `json:"csvPath,omitempty"`
}
// RepoInfo describes a repository available in the opened Engine directory.
@@ -66,10 +75,19 @@ type RepoInfo struct {
Name string `json:"name"`
}
+// RepositorySource identifies the local source directory backing a logical
+// repository. It supports embedded hosts that need to coordinate refreshes;
+// MCP's public list tool intentionally returns RepoInfo without this path.
+type RepositorySource struct {
+ Name string `json:"name"`
+ Path string `json:"path"`
+}
+
type repositoryManifest struct {
- SchemaVersion int `json:"schemaVersion"`
- Name string `json:"name"`
- SourcePath string `json:"sourcePath,omitempty"`
+ SchemaVersion int `json:"schemaVersion"`
+ Name string `json:"name"`
+ SourcePath string `json:"sourcePath,omitempty"`
+ ActiveSnapshot string `json:"activeSnapshot,omitempty"`
}
const repositorySchemaVersion = 1
@@ -192,8 +210,46 @@ func (e *Engine) ensureRepositoryOpen(repo string) error {
_ = db.Close()
return err
}
+ graphStore := e.newGraphStore(db, physical)
+ journal := inc.NewStoreJournal(db)
+ pending, err := journal.Load(context.Background())
+ if err != nil {
+ _ = db.Close()
+ return fmt.Errorf("load repository recovery journal: %w", err)
+ }
+ var recoveredLexical *symbol.LexicalIndex
+ var recoveredSource *source.Index
+ if pending != nil {
+ recoveredLexical, err = symbol.NewLexicalIndexWithDir(root, physical, db)
+ if err != nil {
+ _ = db.Close()
+ return fmt.Errorf("open symbol index for recovery: %w", err)
+ }
+ recoveredSource = source.New(root, physical)
+ if err = recoveredSource.Open(); err != nil {
+ _ = recoveredLexical.Close()
+ _ = db.Close()
+ return fmt.Errorf("open source index for recovery: %w", err)
+ }
+ vector := semantic.NewVectorSearchWithDir(nil, db, graphStore, root)
+ committer := newRepositoryCommitter(db, graphStore, recoveredLexical, recoveredSource, vector, root)
+ if err = committer.Recover(context.Background()); err != nil {
+ vector.Close()
+ _ = recoveredLexical.Close()
+ recoveredSource.Close()
+ _ = db.Close()
+ return fmt.Errorf("recover repository revision: %w", err)
+ }
+ vector.Close()
+ }
e.stores[repo] = db
- e.graphs[repo] = e.newGraphStore(db, physical)
+ e.graphs[repo] = graphStore
+ if recoveredLexical != nil {
+ e.lexical[repo] = recoveredLexical
+ }
+ if recoveredSource != nil {
+ e.sources[repo] = recoveredSource
+ }
return nil
}
@@ -338,15 +394,16 @@ func (e *Engine) IndexRepo(ctx context.Context, repoPath string, opts ...IndexOp
return nil, ErrInvalidRequest
}
operation := e.repositoryOperationLock(repo)
- operation.RLock()
- defer operation.RUnlock()
+ operation.Lock()
+ defer operation.Unlock()
e.mu.Lock()
_, exists := e.repoDirs[repo]
_, busy := e.indexing[repo]
db := e.stores[repo]
repoRoot := e.repoDirs[repo]
- if busy || (exists && !configuration.replace) {
+ allowExisting := configuration.replace || configuration.mode == IndexModeAuto || configuration.mode == IndexModeFull
+ if busy || (exists && !allowExisting) {
e.mu.Unlock()
e.metrics.IndexRepoFail.Add(1)
if busy {
@@ -357,6 +414,39 @@ func (e *Engine) IndexRepo(ctx context.Context, repoPath string, opts ...IndexOp
e.indexing[repo] = struct{}{}
e.mu.Unlock()
defer func() { e.mu.Lock(); delete(e.indexing, repo); e.mu.Unlock() }()
+ currentState, err := captureRepositoryIndexState(repoPath)
+ if err != nil {
+ return nil, fmt.Errorf("fingerprint repository: %w", err)
+ }
+ // A fresh Engine (notably one opened for a single MCP request) should not
+ // need to open a potentially large graph database merely to prove that the
+ // working tree is unchanged. New manifests carry the active snapshot for
+ // this fast path; older manifests transparently use the store-backed path.
+ if exists && configuration.mode == IndexModeAuto {
+ manifestBytes, manifestErr := os.ReadFile(filepath.Join(repoRoot, "manifest.json"))
+ var manifest repositoryManifest
+ if manifestErr == nil {
+ manifestErr = json.Unmarshal(manifestBytes, &manifest)
+ }
+ if manifestErr == nil && manifest.ActiveSnapshot != "" {
+ previousState, stateErr := readIndexState(repoRoot, manifest.ActiveSnapshot)
+ if stateErr == nil && previousState.SourcePath == currentState.SourcePath {
+ if stateErr = hydrateRepositoryStructure(repoPath, currentState, previousState); stateErr != nil {
+ return nil, stateErr
+ }
+ fastChanges := compareIndexStates(previousState, currentState)
+ if len(fastChanges.Added)+len(fastChanges.Modified)+len(fastChanges.Deleted)+len(fastChanges.Renamed) == 0 {
+ e.metrics.IndexRepoSuccess.Add(1)
+ return &IndexResult{
+ Repo: repo, Mode: "noop", Files: len(currentState.Files),
+ Nodes: previousState.NodeCount, Edges: previousState.EdgeCount,
+ BaseRevision: uint64(previousState.Revision), Revision: uint64(previousState.Revision),
+ Duration: time.Since(started).Seconds(),
+ }, nil
+ }
+ }
+ }
+ }
if exists {
if err := e.ensureRepositoryOpen(repo); err != nil {
return nil, err
@@ -365,6 +455,56 @@ func (e *Engine) IndexRepo(ctx context.Context, repoPath string, opts ...IndexOp
db, repoRoot = e.stores[repo], e.repoDirs[repo]
e.mu.RUnlock()
}
+ baseRevision := uint64(0)
+ if exists {
+ if active := e.graphStore(repo); active != nil {
+ if state, stateErr := readIndexState(repoRoot, active.Repo()); stateErr == nil {
+ baseRevision = uint64(state.Revision)
+ }
+ }
+ }
+ changeSet := diffIndexStates(nil, currentState)
+ changes := compareIndexStates(nil, currentState)
+ resultMode := "full"
+ fallbackReason := ""
+ var incrementalBase *graph.GraphStore
+ canAttemptIncremental := false
+ if exists && configuration.mode == IndexModeAuto {
+ incrementalBase = e.graphStore(repo)
+ oldPhysical := incrementalBase.Repo()
+ previousState, stateErr := readIndexState(repoRoot, oldPhysical)
+ if stateErr == nil && previousState.SourcePath == currentState.SourcePath {
+ if stateErr = hydrateRepositoryStructure(repoPath, currentState, previousState); stateErr != nil {
+ return nil, stateErr
+ }
+ baseRevision = uint64(previousState.Revision)
+ currentState.NodeCount = previousState.NodeCount
+ currentState.EdgeCount = previousState.EdgeCount
+ if currentState.NodeCount == 0 && currentState.EdgeCount == 0 {
+ currentState.NodeCount, currentState.EdgeCount, stateErr = persistedGraphCounts(incrementalBase)
+ if stateErr != nil {
+ return nil, stateErr
+ }
+ }
+ changeSet = diffIndexStates(previousState, currentState)
+ changes = compareIndexStates(previousState, currentState)
+ if len(changes.Added)+len(changes.Modified)+len(changes.Deleted)+len(changes.Renamed) == 0 {
+ e.metrics.IndexRepoSuccess.Add(1)
+ return &IndexResult{
+ Repo: repo, Mode: "noop", Files: len(currentState.Files),
+ Nodes: previousState.NodeCount, Edges: previousState.EdgeCount,
+ BaseRevision: baseRevision, Revision: baseRevision,
+ Duration: time.Since(started).Seconds(),
+ }, nil
+ }
+ canAttemptIncremental = true
+ } else if stateErr != nil {
+ fallbackReason = "active snapshot has no compatible incremental state"
+ } else {
+ fallbackReason = "repository source path changed"
+ }
+ resultMode = "full-fallback"
+ }
if e.indexSem != nil {
select {
@@ -380,7 +520,58 @@ func (e *Engine) IndexRepo(ctx context.Context, repoPath string, opts ...IndexOp
default:
}
- pipelineResult, err := ingest.NewPipeline(repoPath, "", false).Run()
+ if canAttemptIncremental && configuration.exportCSVPath == "" && isLocalBodyOnlyChange(changeSet) {
+ delta, supported, deltaErr := buildLocalBodyDelta(incrementalBase, changeSet, repoPath)
+ if deltaErr != nil {
+ return nil, fmt.Errorf("build local body delta: %w", deltaErr)
+ }
+ if supported {
+ currentState.Revision = inc.Revision(baseRevision + 1)
+ encodedState, encodeErr := encodeIndexState(currentState)
+ if encodeErr != nil {
+ return nil, encodeErr
+ }
+ delta.Metadata = map[string][]byte{indexStateMetadata: encodedState}
+ if err = e.commitIncrementalDelta(ctx, repo, repoRoot, db, incrementalBase, delta, baseRevision); err != nil {
+ return nil, err
+ }
+ e.metrics.IndexRepoSuccess.Add(1)
+ return &IndexResult{
+ Repo: repo, Mode: "incremental", Files: len(currentState.Files), Modified: len(changes.Modified),
+ BaseRevision: baseRevision, Revision: uint64(currentState.Revision),
+ Nodes: currentState.NodeCount, Edges: currentState.EdgeCount, Duration: time.Since(started).Seconds(),
+ }, nil
+ }
+ }
+
+ var pipelineResult *ingest.PipelineResult
+ if canAttemptIncremental {
+ stageStarted := time.Now()
+ base, exportErr := incrementalBase.ExportKnowledgeGraph()
+ if exportErr != nil {
+ return nil, fmt.Errorf("materialize active snapshot: %w", exportErr)
+ }
+ slog.Info("incremental stage", "repo", repo, "stage", "materialize", "duration", time.Since(stageStarted))
+ stageStarted = time.Now()
+ plan := inc.Plan(changeSet, ingest.NewGraphDependencies(base))
+ slog.Info("incremental stage", "repo", repo, "stage", "plan", "units", len(plan.Units), "duration", time.Since(stageStarted))
+ stageStarted = time.Now()
+ incremental, incrementalErr := ingest.RunIncrementalPlan(repoPath, base, plan)
+ if incrementalErr != nil {
+ return nil, fmt.Errorf("incremental ingest: %w", incrementalErr)
+ }
+ slog.Info("incremental stage", "repo", repo, "stage", "analyze", "duration", time.Since(stageStarted))
+ if incremental.Supported {
+ pipelineResult = incremental.Pipeline
+ resultMode = "incremental"
+ } else {
+ fallbackReason = incremental.Reason
+ slog.Info("incremental index selected safe full fallback", "repo", repo, "reason", incremental.Reason)
+ }
+ }
+ if pipelineResult == nil {
+ pipelineResult, err = ingest.NewPipeline(repoPath, "", false).Run()
+ }
if err != nil {
e.metrics.IndexRepoFail.Add(1)
e.metrics.Errors.Add(1)
@@ -399,6 +590,35 @@ func (e *Engine) IndexRepo(ctx context.Context, repoPath string, opts ...IndexOp
csvPath = configuration.exportCSVPath
}
}
+ if resultMode == "incremental" {
+ currentState.NodeCount = pipelineResult.Graph.NodeCount()
+ currentState.EdgeCount = pipelineResult.Graph.RelationshipCount()
+ currentState.Revision = inc.Revision(baseRevision + 1)
+ encodedState, encodeErr := encodeIndexState(currentState)
+ if encodeErr != nil {
+ return nil, encodeErr
+ }
+ stageStarted := time.Now()
+ delta, deltaErr := buildIncrementalDelta(incrementalBase, pipelineResult.Graph, changeSet, repoPath)
+ if deltaErr != nil {
+ return nil, fmt.Errorf("build incremental delta: %w", deltaErr)
+ }
+ slog.Info("incremental stage", "repo", repo, "stage", "delta", "putNodes", len(delta.PutNodes), "deleteNodes", len(delta.DeleteNodes), "putEdges", len(delta.PutEdges), "deleteEdges", len(delta.DeleteEdges), "duration", time.Since(stageStarted))
+ delta.Metadata = map[string][]byte{indexStateMetadata: encodedState}
+ stageStarted = time.Now()
+ if err = e.commitIncrementalDelta(ctx, repo, repoRoot, db, incrementalBase, delta, baseRevision); err != nil {
+ return nil, err
+ }
+ slog.Info("incremental stage", "repo", repo, "stage", "commit", "duration", time.Since(stageStarted))
+ e.metrics.IndexRepoSuccess.Add(1)
+ return &IndexResult{
+ Repo: repo, Mode: resultMode, Files: pipelineResult.WalkResult.TotalFiles,
+ Added: len(changes.Added), Modified: len(changes.Modified), Deleted: len(changes.Deleted), Renamed: len(changes.Renamed),
+ BaseRevision: baseRevision, Revision: uint64(currentState.Revision),
+ Nodes: currentState.NodeCount, Edges: currentState.EdgeCount,
+ Duration: time.Since(started).Seconds(), CSVPath: csvPath,
+ }, nil
+ }
newRepository := db == nil
if newRepository {
repoRoot = e.repositoryRoot(repo)
@@ -433,6 +653,11 @@ func (e *Engine) IndexRepo(ctx context.Context, repoPath string, opts ...IndexOp
_ = e.deleteGraphNamespace(db, physicalRepo)
return nil, fmt.Errorf("flush graph: %w", err)
}
+ desiredRevision := uint64(baseRevision + 1)
+ if err := graphStore.InitializeRevision(desiredRevision); err != nil {
+ _ = e.deleteGraphNamespace(db, physicalRepo)
+ return nil, fmt.Errorf("initialize graph revision: %w", err)
+ }
// Use the same physical snapshot name for graph and text indexes. The
// logical active pointer therefore publishes both as one version.
index, err := symbol.NewLexicalIndexWithDir(repoRoot, physicalRepo, db)
@@ -472,6 +697,23 @@ func (e *Engine) IndexRepo(ctx context.Context, repoPath string, opts ...IndexOp
_ = e.deleteGraphNamespace(db, physicalRepo)
return nil, fmt.Errorf("build source index: %w", err)
}
+ currentState.NodeCount = pipelineResult.Graph.NodeCount()
+ currentState.EdgeCount = pipelineResult.Graph.RelationshipCount()
+ currentState.Revision = inc.Revision(desiredRevision)
+ if err := hydrateRepositoryStructure(repoPath, currentState, nil); err != nil {
+ index.Close()
+ contentIndex.Close()
+ _ = e.deleteSnapshotArtifacts(repoRoot, physicalRepo)
+ _ = e.deleteGraphNamespace(db, physicalRepo)
+ return nil, err
+ }
+ if err := writeIndexState(repoRoot, physicalRepo, currentState); err != nil {
+ index.Close()
+ contentIndex.Close()
+ _ = e.deleteSnapshotArtifacts(repoRoot, physicalRepo)
+ _ = e.deleteGraphNamespace(db, physicalRepo)
+ return nil, fmt.Errorf("write index state: %w", err)
+ }
e.mu.RLock()
oldGraph, oldLexical, oldVector, oldSource := e.graphs[repo], e.lexical[repo], e.vectors[repo], e.sources[repo]
e.mu.RUnlock()
@@ -494,7 +736,7 @@ func (e *Engine) IndexRepo(ctx context.Context, repoPath string, opts ...IndexOp
_ = e.deleteGraphNamespace(db, physicalRepo)
return nil, fmt.Errorf("publish snapshot: %w", err)
}
- if err := writeRepositoryManifest(repoRoot, repositoryManifest{SchemaVersion: repositorySchemaVersion, Name: repo, SourcePath: repoPath}); err != nil {
+ if err := writeRepositoryManifest(repoRoot, repositoryManifest{SchemaVersion: repositorySchemaVersion, Name: repo, SourcePath: currentState.SourcePath, ActiveSnapshot: physicalRepo}); err != nil {
index.Close()
contentIndex.Close()
_ = db.Batch(func(batch *pebble.Batch) error {
@@ -537,12 +779,82 @@ func (e *Engine) IndexRepo(ctx context.Context, repoPath string, opts ...IndexOp
e.metrics.IndexRepoSuccess.Add(1)
return &IndexResult{
- Repo: repo, Files: pipelineResult.WalkResult.TotalFiles,
- Nodes: pipelineResult.Graph.NodeCount(), Edges: pipelineResult.Graph.RelationshipCount(),
+ Repo: repo, Mode: resultMode, Files: pipelineResult.WalkResult.TotalFiles,
+ Added: len(changes.Added), Modified: len(changes.Modified), Deleted: len(changes.Deleted), Renamed: len(changes.Renamed),
+ BaseRevision: baseRevision, Revision: uint64(currentState.Revision),
+ FallbackReason: fallbackReason,
+ Nodes: pipelineResult.Graph.NodeCount(), Edges: pipelineResult.Graph.RelationshipCount(),
Duration: time.Since(started).Seconds(), CSVPath: csvPath,
}, nil
}
+func persistedGraphCounts(store *graph.GraphStore) (int, int, error) {
+ nodes, edges := 0, 0
+ if err := store.ForEachNode(func(*graph.Node) error { nodes++; return nil }); err != nil {
+ return 0, 0, err
+ }
+ if err := store.ForEachEdge(func(*graph.Edge) error { edges++; return nil }); err != nil {
+ return 0, 0, err
+ }
+ return nodes, edges, nil
+}
+
+func (e *Engine) commitIncrementalDelta(
+ ctx context.Context,
+ repo, repoRoot string,
+ db *store.Store,
+ graphStore *graph.GraphStore,
+ delta *inc.Delta,
+ baseRevision uint64,
+) error {
+ activeRevision, err := graphStore.Revision()
+ if err != nil {
+ return err
+ }
+ if activeRevision == 0 {
+ if err = graphStore.InitializeRevision(baseRevision); err != nil {
+ return err
+ }
+ }
+ e.mu.RLock()
+ lexicalIndex, contentIndex, vectorIndex := e.lexical[repo], e.sources[repo], e.vectors[repo]
+ e.mu.RUnlock()
+ if lexicalIndex == nil {
+ lexicalIndex, err = symbol.NewLexicalIndexWithDir(repoRoot, graphStore.Repo(), db)
+ if err != nil {
+ return err
+ }
+ e.mu.Lock()
+ e.lexical[repo] = lexicalIndex
+ e.mu.Unlock()
+ }
+ if contentIndex == nil {
+ contentIndex = source.New(repoRoot, graphStore.Repo())
+ if err = contentIndex.Open(); err != nil {
+ return err
+ }
+ e.mu.Lock()
+ e.sources[repo] = contentIndex
+ e.mu.Unlock()
+ }
+ transientVector := false
+ if vectorIndex == nil {
+ vectorIndex = semantic.NewVectorSearchWithDir(nil, db, graphStore, repoRoot)
+ transientVector = true
+ }
+ committer := newRepositoryCommitter(db, graphStore, lexicalIndex, contentIndex, vectorIndex, repoRoot)
+ if err = inc.NewCoordinator(committer).Apply(ctx, delta); err != nil {
+ if transientVector {
+ vectorIndex.Close()
+ }
+ return fmt.Errorf("commit incremental revision: %w", err)
+ }
+ if transientVector {
+ vectorIndex.Close()
+ }
+ return nil
+}
+
type SearchRequest struct {
Repo string `json:"repo"`
Query string `json:"query"`
@@ -580,17 +892,24 @@ func (e *Engine) Search(_ context.Context, request *SearchRequest) (*SearchResul
if graphStore == nil {
return nil, fmt.Errorf("repository %q is not indexed", request.Repo)
}
- var err error
+ // Repository recovery may have opened and registered the index while
+ // graphStore ensured the repository was ready.
e.mu.RLock()
- db, repoRoot := e.stores[request.Repo], e.repoDirs[request.Repo]
+ index = e.lexical[request.Repo]
e.mu.RUnlock()
- index, err = symbol.NewLexicalIndexWithDir(repoRoot, graphStore.Repo(), db)
- if err != nil {
- return nil, err
+ if index == nil {
+ var err error
+ e.mu.RLock()
+ db, repoRoot := e.stores[request.Repo], e.repoDirs[request.Repo]
+ e.mu.RUnlock()
+ index, err = symbol.NewLexicalIndexWithDir(repoRoot, graphStore.Repo(), db)
+ if err != nil {
+ return nil, err
+ }
+ e.mu.Lock()
+ e.lexical[request.Repo] = index
+ e.mu.Unlock()
}
- e.mu.Lock()
- e.lexical[request.Repo] = index
- e.mu.Unlock()
}
limit := request.Limit
if limit <= 0 {
@@ -630,6 +949,7 @@ func (e *Engine) deleteSnapshotArtifacts(repoRoot, repo string) error {
filepath.Join(repoRoot, "content", repo+".build"),
filepath.Join(repoRoot, "vectors", repo),
filepath.Join(repoRoot, "vectors", repo+".bin"),
+ indexStatePath(repoRoot, repo),
} {
if err := os.RemoveAll(path); err != nil && firstErr == nil {
firstErr = err
@@ -710,6 +1030,29 @@ func (e *Engine) ListRepos() ([]RepoInfo, error) {
return result, nil
}
+// ListRepositorySources returns the local source roots used for automatic
+// refresh coordination. It does not open repository graph databases.
+func (e *Engine) ListRepositorySources() ([]RepositorySource, error) {
+ e.mu.RLock()
+ defer e.mu.RUnlock()
+ result := make([]RepositorySource, 0, len(e.repoDirs))
+ for repo, root := range e.repoDirs {
+ encoded, err := os.ReadFile(filepath.Join(root, "manifest.json"))
+ if err != nil {
+ return nil, err
+ }
+ var manifest repositoryManifest
+ if err := json.Unmarshal(encoded, &manifest); err != nil {
+ return nil, err
+ }
+ if manifest.SourcePath != "" {
+ result = append(result, RepositorySource{Name: repo, Path: manifest.SourcePath})
+ }
+ }
+ sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name })
+ return result, nil
+}
+
// DeleteRepo removes one logical repository and all of its graph, lexical,
// content, and vector snapshots. The repository directory is first atomically
// moved out of the active namespace so partial filesystem cleanup cannot leave
@@ -935,25 +1278,76 @@ type ContextResult struct {
type ImpactRequest struct {
Repo string `json:"repo"`
NodeID string `json:"nodeId"`
+ ChangeKind string `json:"changeKind,omitempty" jsonschema:"change intent: behavior, signature, contract, remove; inferred from the symbol when omitted"`
MaxDepth int `json:"maxDepth,omitempty"`
RelationTypes []string `json:"relationTypes,omitempty" jsonschema:"optional relationship filter; defaults to incoming calls, imports, inheritance, implementations, overrides, dispatch, routes, tools, and event bindings"`
Limit int `json:"limit,omitempty"`
}
+const (
+ ImpactChangeBehavior = "behavior"
+ ImpactChangeSignature = "signature"
+ ImpactChangeContract = "contract"
+ ImpactChangeRemove = "remove"
+)
+
+const (
+ ImpactCategoryConfirmed = "confirmed-dependent"
+ ImpactCategoryContract = "contract-impact"
+ ImpactCategoryEntry = "entry-point"
+ ImpactCategoryTest = "related-test"
+ ImpactCategoryReview = "review-candidate"
+)
+
+// ImpactStep is one evidence-backed relationship in an impact path.
+type ImpactStep struct {
+ From GraphNode `json:"from"`
+ To GraphNode `json:"to"`
+ Relation string `json:"relation"`
+ Confidence float64 `json:"confidence"`
+ Reason string `json:"reason,omitempty"`
+}
+
// ImpactNode records one affected symbol and the edge that first reached it.
type ImpactNode struct {
- Node GraphNode `json:"node"`
- Depth int `json:"depth"`
- Via GraphEdge `json:"via"`
- Confidence float64 `json:"confidence"`
+ Node GraphNode `json:"node"`
+ Depth int `json:"depth"`
+ Via GraphEdge `json:"via"`
+ Confidence float64 `json:"confidence"`
+ Category string `json:"category,omitempty"`
+ Risk string `json:"risk,omitempty"`
+ WhyAffected string `json:"whyAffected,omitempty"`
+ Path []ImpactStep `json:"path,omitempty"`
+ RequiresReview bool `json:"requiresReview,omitempty"`
+}
+
+// ImpactCoverage explains the analysis assumptions and its observable bounds.
+type ImpactCoverage struct {
+ ChangeKind string `json:"changeKind"`
+ Assumption string `json:"assumption"`
+ MaxDepth int `json:"maxDepth"`
+ Limit int `json:"limit"`
+ Confirmed int `json:"confirmed"`
+ Contract int `json:"contract"`
+ EntryPoints int `json:"entryPoints"`
+ RelatedTests int `json:"relatedTests"`
+ ReviewCandidates int `json:"reviewCandidates"`
+ Notes []string `json:"notes,omitempty"`
}
// ImpactResult contains the bounded reverse dependency tree for a symbol.
type ImpactResult struct {
- Origin GraphNode `json:"origin"`
- Impacted []ImpactNode `json:"impacted"`
- Edges []GraphEdge `json:"edges"`
- Truncated bool `json:"truncated,omitempty"`
+ Origin GraphNode `json:"origin"`
+ ChangeKind string `json:"changeKind"`
+ ConfirmedDependents []ImpactNode `json:"confirmedDependents"`
+ ContractImpacts []ImpactNode `json:"contractImpacts"`
+ EntryPoints []ImpactNode `json:"entryPoints"`
+ RelatedTests []ImpactNode `json:"relatedTests"`
+ ReviewCandidates []ImpactNode `json:"reviewCandidates"`
+ Coverage ImpactCoverage `json:"coverage"`
+ Impacted []ImpactNode `json:"impacted"`
+ Edges []GraphEdge `json:"edges"`
+ Truncated bool `json:"truncated,omitempty"`
}
// CheckRequest selects repository-wide structural checks.
@@ -1268,10 +1662,8 @@ func (e *Engine) readSymbolContent(repo string, symbol *graph.Node) string {
if _, ok := symbol.Props.GetProp("startLine"); !ok {
return ""
}
- // Parser positions are stored as zero-based rows while source excerpts are
- // read from the one-based line stream.
- startLine := symbol.GetPropInt("startLine") + 1
- endLine := symbol.GetPropInt("endLine") + 1
+ startLine := symbol.GetPropInt("startLine")
+ endLine := symbol.GetPropInt("endLine")
if startLine < 1 {
return ""
}
@@ -1343,15 +1735,34 @@ func (e *Engine) Impact(ctx context.Context, request *ImpactRequest) (*ImpactRes
if err != nil {
return nil, fmt.Errorf("%w: %s", ErrSymbolNotFound, request.NodeID)
}
+ changeKind, assumption, err := normalizedImpactChangeKind(request.ChangeKind, origin)
+ if err != nil {
+ return nil, err
+ }
allowed := normalizedRelationSet(request.RelationTypes, defaultImpactRelations)
result := &ImpactResult{
- Origin: publicGraphNode(origin),
- Impacted: make([]ImpactNode, 0, limit),
- Edges: make([]GraphEdge, 0, limit),
+ Origin: publicGraphNode(origin),
+ ChangeKind: changeKind,
+ ConfirmedDependents: make([]ImpactNode, 0),
+ ContractImpacts: make([]ImpactNode, 0),
+ EntryPoints: make([]ImpactNode, 0),
+ RelatedTests: make([]ImpactNode, 0),
+ ReviewCandidates: make([]ImpactNode, 0),
+ Impacted: make([]ImpactNode, 0, limit),
+ Edges: make([]GraphEdge, 0, limit),
+ Coverage: ImpactCoverage{
+ ChangeKind: changeKind, Assumption: assumption, MaxDepth: maxDepth, Limit: limit,
+ Notes: []string{
+ "dynamic dispatch, reflection, generated code, and runtime registration may require source verification",
+ "review candidates are not confirmed runtime dependents",
+ },
+ },
}
type impactQueueEntry struct {
nodeID string
depth int
+ path []ImpactStep
+ review bool
}
queue := []impactQueueEntry{{nodeID: request.NodeID, depth: 0}}
visited := map[string]struct{}{request.NodeID: {}}
@@ -1365,6 +1776,10 @@ func (e *Engine) Impact(ctx context.Context, request *ImpactRequest) (*ImpactRes
if current.depth >= maxDepth {
continue
}
+ currentNode, err := graphStore.GetNode(current.nodeID)
+ if err != nil {
+ return nil, err
+ }
edges, err := graphStore.GetAllInEdges(current.nodeID)
if err != nil {
return nil, err
@@ -1382,6 +1797,11 @@ func (e *Engine) Impact(ctx context.Context, request *ImpactRequest) (*ImpactRes
if _, ok := allowed[strings.ToUpper(string(edge.Type))]; !ok {
continue
}
+ // Once a path crosses an uncertain dispatch bridge, sibling
+ // implementations are alternatives rather than dependents.
+ if current.review && isContractImpactRelation(string(edge.Type)) {
+ continue
+ }
if _, ok := visited[edge.Source]; ok {
continue
}
@@ -1392,20 +1812,47 @@ func (e *Engine) Impact(ctx context.Context, request *ImpactRequest) (*ImpactRes
visited[edge.Source] = struct{}{}
depth := current.depth + 1
publicEdge := publicGraphEdge(edge)
- result.Impacted = append(result.Impacted, ImpactNode{
+ publicNode := publicGraphNode(node)
+ category, requiresReview, expand := classifyImpactRelationship(changeKind, publicEdge.Type, publicNode)
+ if current.review {
+ category = ImpactCategoryReview
+ requiresReview = true
+ expand = impactRelationCanContinue(publicEdge.Type)
+ }
+ step := ImpactStep{
+ From: publicNode, To: publicGraphNode(currentNode),
+ Relation: publicEdge.Type, Confidence: publicEdge.Confidence, Reason: publicEdge.Reason,
+ }
+ path := append(append([]ImpactStep(nil), current.path...), step)
+ affected := ImpactNode{
Node: publicGraphNode(node),
Depth: depth,
Via: publicEdge,
Confidence: edge.Confidence(),
- })
+ Category: category, Risk: changeKind, Path: path,
+ WhyAffected: impactReason(category, changeKind, publicEdge.Type, publicNode.Name),
+ RequiresReview: requiresReview,
+ }
+ result.Impacted = append(result.Impacted, affected)
result.Edges = append(result.Edges, publicEdge)
- queue = append(queue, impactQueueEntry{nodeID: edge.Source, depth: depth})
+ addClassifiedImpact(result, affected)
+ if expand {
+ queue = append(queue, impactQueueEntry{
+ nodeID: edge.Source, depth: depth, path: path,
+ review: current.review || requiresReview,
+ })
+ }
if len(result.Impacted) >= limit {
result.Truncated = len(queue) > 0 || len(edges) > 0
return result, nil
}
}
}
+ result.Coverage.Confirmed = len(result.ConfirmedDependents)
+ result.Coverage.Contract = len(result.ContractImpacts)
+ result.Coverage.EntryPoints = len(result.EntryPoints)
+ result.Coverage.RelatedTests = len(result.RelatedTests)
+ result.Coverage.ReviewCandidates = len(result.ReviewCandidates)
return result, nil
}
@@ -1702,7 +2149,16 @@ func (e *Engine) Diff(ctx context.Context, request *DiffRequest) (*DiffResult, e
if impact.Truncated {
result.Truncated = true
}
- for _, affected := range impact.Impacted {
+ // Diff reports actionable impact only. Review candidates remain
+ // available from Impact but are not promoted to confirmed diff impact.
+ actionable := make([]ImpactNode, 0,
+ len(impact.ConfirmedDependents)+len(impact.ContractImpacts)+
+ len(impact.EntryPoints)+len(impact.RelatedTests))
+ actionable = append(actionable, impact.ConfirmedDependents...)
+ actionable = append(actionable, impact.ContractImpacts...)
+ actionable = append(actionable, impact.EntryPoints...)
+ actionable = append(actionable, impact.RelatedTests...)
+ for _, affected := range actionable {
if _, changedDirectly := changedIDs[affected.Node.ID]; changedDirectly {
continue
}
@@ -1940,8 +2396,8 @@ func lineWithinNode(line int, node *graph.Node) bool {
if _, ok := node.Props.GetProp("startLine"); !ok {
return false
}
- start := node.GetPropInt("startLine") + 1
- end := node.GetPropInt("endLine") + 1
+ start := node.GetPropInt("startLine")
+ end := node.GetPropInt("endLine")
if end < start {
end = start
}
@@ -2044,8 +2500,8 @@ func nodeIntersectsDiff(node *graph.Node, file DiffFile) bool {
if _, ok := node.Props.GetProp("startLine"); !ok {
return false
}
- nodeStart := node.GetPropInt("startLine") + 1
- nodeEnd := node.GetPropInt("endLine") + 1
+ nodeStart := node.GetPropInt("startLine")
+ nodeEnd := node.GetPropInt("endLine")
if nodeEnd < nodeStart {
nodeEnd = nodeStart
}
@@ -2238,6 +2694,116 @@ func isImpactNode(origin, candidate *graph.Node) bool {
return origin != nil && origin.Label == graph.LabelFile && candidate.Label == graph.LabelFile
}
+func normalizedImpactChangeKind(requested string, origin *graph.Node) (string, string, error) {
+ kind := strings.ToLower(strings.TrimSpace(requested))
+ if kind == "" {
+ switch origin.Label {
+ case graph.LabelInterface:
+ kind = ImpactChangeContract
+ case graph.LabelClass, graph.LabelStruct, graph.LabelEnum:
+ kind = ImpactChangeSignature
+ default:
+ kind = ImpactChangeBehavior
+ }
+ return kind, fmt.Sprintf("changeKind inferred as %s from %s symbol", kind, origin.Label), nil
+ }
+ switch kind {
+ case ImpactChangeBehavior, ImpactChangeSignature, ImpactChangeContract, ImpactChangeRemove:
+ return kind, "changeKind explicitly supplied by the caller", nil
+ default:
+ return "", "", fmt.Errorf("%w: unsupported impact changeKind %q; use behavior, signature, contract, or remove", ErrInvalidRequest, requested)
+ }
+}
+
+func classifyImpactRelationship(changeKind, relation string, node GraphNode) (string, bool, bool) {
+ if isTestFile(node.FilePath) {
+ return ImpactCategoryTest, false, false
+ }
+ relation = strings.ToUpper(relation)
+ switch relation {
+ case "HANDLES_ROUTE", "HANDLES_TOOL", "BINDS_EVENT_HANDLER":
+ return ImpactCategoryEntry, false, false
+ case "CALLS", "IMPORTS", "WRAPS", "EMITS_EVENT":
+ return ImpactCategoryConfirmed, false, true
+ case "EXTENDS", "IMPLEMENTS", "INHERITS", "OVERRIDES", "METHOD_OVERRIDES", "METHOD_IMPLEMENTS":
+ if changeKind == ImpactChangeBehavior {
+ return ImpactCategoryReview, true, false
+ }
+ return ImpactCategoryContract, false, true
+ case "DISPATCHES_TO":
+ if changeKind == ImpactChangeBehavior {
+ return ImpactCategoryReview, true, true
+ }
+ return ImpactCategoryContract, true, false
+ default:
+ return ImpactCategoryReview, true, false
+ }
+}
+
+func impactRelationCanContinue(relation string) bool {
+ switch strings.ToUpper(relation) {
+ case "CALLS", "IMPORTS", "WRAPS", "EMITS_EVENT", "HANDLES_ROUTE",
+ "HANDLES_TOOL", "BINDS_EVENT_HANDLER", "DISPATCHES_TO":
+ return true
+ default:
+ return false
+ }
+}
+
+func isContractImpactRelation(relation string) bool {
+ switch strings.ToUpper(relation) {
+ case "EXTENDS", "IMPLEMENTS", "INHERITS", "OVERRIDES",
+ "METHOD_OVERRIDES", "METHOD_IMPLEMENTS":
+ return true
+ default:
+ return false
+ }
+}
+
+func addClassifiedImpact(result *ImpactResult, impacted ImpactNode) {
+ switch impacted.Category {
+ case ImpactCategoryConfirmed:
+ result.ConfirmedDependents = append(result.ConfirmedDependents, impacted)
+ result.Coverage.Confirmed++
+ case ImpactCategoryContract:
+ result.ContractImpacts = append(result.ContractImpacts, impacted)
+ result.Coverage.Contract++
+ case ImpactCategoryEntry:
+ result.EntryPoints = append(result.EntryPoints, impacted)
+ result.Coverage.EntryPoints++
+ case ImpactCategoryTest:
+ result.RelatedTests = append(result.RelatedTests, impacted)
+ result.Coverage.RelatedTests++
+ default:
+ result.ReviewCandidates = append(result.ReviewCandidates, impacted)
+ result.Coverage.ReviewCandidates++
+ }
+}
+
+func impactReason(category, changeKind, relation, name string) string {
+ switch category {
+ case ImpactCategoryConfirmed:
+ return fmt.Sprintf("%s reaches the changed symbol through a confirmed %s dependency", name, relation)
+ case ImpactCategoryContract:
+ return fmt.Sprintf("%s participates in a %s contract that may need updating for a %s change", name, relation, changeKind)
+ case ImpactCategoryEntry:
+ return fmt.Sprintf("%s is a bound production entry point on the impact path through %s", name, relation)
+ case ImpactCategoryTest:
+ return fmt.Sprintf("%s is a test that reaches the changed symbol through %s", name, relation)
+ default:
+ return fmt.Sprintf("%s is related through %s but is not a confirmed runtime dependent for a %s change", name, relation, changeKind)
+ }
+}
+
+func isTestFile(path string) bool {
+ lower := strings.ToLower(filepath.ToSlash(path))
+ return strings.HasSuffix(lower, "_test.go") ||
+ strings.Contains(lower, ".test.") ||
+ strings.Contains(lower, ".spec.") ||
+ strings.Contains(lower, "/test/") ||
+ strings.Contains(lower, "/tests/")
+}
+
func normalizedRelationSet(requested, defaults []string) map[string]struct{} {
if len(requested) == 0 {
requested = defaults
diff --git a/engine_integration_test.go b/engine_integration_test.go
index d86e934..78e3046 100644
--- a/engine_integration_test.go
+++ b/engine_integration_test.go
@@ -2,14 +2,19 @@ package codetrip
import (
"context"
+ "encoding/json"
"errors"
"hash/fnv"
"os"
"path/filepath"
+ "sort"
"strings"
"testing"
"github.com/mengshi02/codetrip/internal/graph"
+ inc "github.com/mengshi02/codetrip/internal/incremental"
+ "github.com/mengshi02/codetrip/internal/ingest"
+ "github.com/mengshi02/codetrip/internal/model"
)
func TestIndexRepoPersistsValidatedGraphAndExportsCSV(t *testing.T) {
@@ -99,6 +104,12 @@ func TestIndexRepoPersistsValidatedGraphAndExportsCSV(t *testing.T) {
impact.Impacted[0].Depth != 1 || impact.Impacted[0].Via.Type != "CALLS" {
t.Fatalf("impact=%#v", impact)
}
+ if impact.ChangeKind != ImpactChangeBehavior || len(impact.ConfirmedDependents) != 1 ||
+ impact.ConfirmedDependents[0].Category != ImpactCategoryConfirmed ||
+ len(impact.ConfirmedDependents[0].Path) != 1 ||
+ impact.Coverage.Confirmed != 1 || impact.Coverage.ReviewCandidates != 0 {
+ t.Fatalf("classified impact=%#v", impact)
+ }
rename, err := engine.Rename(context.Background(), &RenameRequest{
Repo: "fixture", NodeID: traversal.Nodes[0].ID, NewName: "Execute",
})
@@ -198,6 +209,10 @@ func TestIndexRepoPersistsValidatedGraphAndExportsCSV(t *testing.T) {
if err != nil || len(repositories) != 1 || repositories[0].Name != "fixture" {
t.Fatalf("reopened repositories: %#v, error: %v", repositories, err)
}
+ sources, err := reopened.ListRepositorySources()
+ if err != nil || len(sources) != 1 || sources[0].Name != "fixture" || sources[0].Path != repository {
+ t.Fatalf("repository sources: %#v, error: %v", sources, err)
+ }
if reopened.graphStore("fixture") == nil {
t.Fatal("reopened graph store missing")
}
@@ -416,3 +431,458 @@ func TestReplaceRepositoryPublishesNewSnapshotAndCollectsOldOne(t *testing.T) {
}
}
}
+
+func TestAutomaticIndexNoopAndChangedIncremental(t *testing.T) {
+ repository := t.TempDir()
+ dataDir := filepath.Join(t.TempDir(), "data")
+ path := filepath.Join(repository, "main.go")
+ if err := os.WriteFile(path, []byte("package p\nfunc OldName() {}\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ engine, err := Open(dataDir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer engine.Close()
+ first, err := engine.IndexRepo(context.Background(), repository, WithRepoName("fixture"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if first.Mode != "full" || first.Added != 1 {
+ t.Fatalf("first=%#v", first)
+ }
+ physical := engine.graphStore("fixture").Repo()
+ second, err := engine.IndexRepo(context.Background(), repository, WithRepoName("fixture"), WithIndexMode(IndexModeAuto))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if second.Mode != "noop" || second.Added != 0 || second.Modified != 0 || second.Deleted != 0 {
+ t.Fatalf("second=%#v", second)
+ }
+ if engine.graphStore("fixture").Repo() != physical {
+ t.Fatal("noop changed active snapshot")
+ }
+ if err := os.WriteFile(path, []byte("package p\nfunc NewName() {}\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ third, err := engine.IndexRepo(context.Background(), repository, WithRepoName("fixture"), WithIndexMode(IndexModeAuto))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if third.Mode != "incremental" || third.Modified != 1 || third.Added != 0 || third.Deleted != 0 {
+ t.Fatalf("third=%#v", third)
+ }
+ if engine.graphStore("fixture").Repo() != physical {
+ t.Fatal("incremental update replaced the full snapshot namespace")
+ }
+}
+
+func TestAutomaticIndexNoopAfterReopenDoesNotOpenRepositoryStore(t *testing.T) {
+ repository := t.TempDir()
+ dataDir := filepath.Join(t.TempDir(), "data")
+ if err := os.WriteFile(filepath.Join(repository, "main.go"), []byte("package p\nfunc Ready() {}\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ engine, err := Open(dataDir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := engine.IndexRepo(context.Background(), repository, WithRepoName("fixture")); err != nil {
+ t.Fatal(err)
+ }
+ if err := engine.Close(); err != nil {
+ t.Fatal(err)
+ }
+
+ reopened, err := Open(dataDir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer reopened.Close()
+ result, err := reopened.IndexRepo(context.Background(), repository, WithRepoName("fixture"), WithIndexMode(IndexModeAuto))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result.Mode != "noop" {
+ t.Fatalf("result=%#v", result)
+ }
+ if reopened.stores["fixture"] != nil || reopened.graphs["fixture"] != nil {
+ t.Fatal("noop refresh opened the repository store")
+ }
+}
+
+func TestIndexRevisionsAdvanceAndRenameIsNeverNoop(t *testing.T) {
+ repository := t.TempDir()
+ dataDir := filepath.Join(t.TempDir(), "data")
+ oldPath := filepath.Join(repository, "old.ts")
+ if err := os.WriteFile(oldPath, []byte("export function ready() {}\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ engine, err := Open(dataDir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer engine.Close()
+ first, err := engine.IndexRepo(context.Background(), repository, WithRepoName("fixture"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if first.BaseRevision != 0 || first.Revision != 1 {
+ t.Fatalf("first revision=%#v", first)
+ }
+ newPath := filepath.Join(repository, "new.ts")
+ if err := os.Rename(oldPath, newPath); err != nil {
+ t.Fatal(err)
+ }
+ renamed, err := engine.IndexRepo(context.Background(), repository, WithRepoName("fixture"), WithIndexMode(IndexModeAuto))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if renamed.Mode != "incremental" || renamed.Renamed != 1 || renamed.BaseRevision != 1 || renamed.Revision != 2 {
+ t.Fatalf("rename result=%#v", renamed)
+ }
+ forced, err := engine.IndexRepo(context.Background(), repository, WithRepoName("fixture"), WithIndexMode(IndexModeFull))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if forced.Mode != "full" || forced.BaseRevision != 2 || forced.Revision != 3 {
+ t.Fatalf("forced result=%#v", forced)
+ }
+}
+
+func TestAutomaticIndexGoBodyChangeIsFullEquivalent(t *testing.T) {
+ repository := t.TempDir()
+ dataDir := filepath.Join(t.TempDir(), "incremental")
+ path := filepath.Join(repository, "main.go")
+ before := "package sample\nfunc A() {}\nfunc B() {}\nfunc Run() { A() }\n"
+ after := "package sample\nfunc A() {}\nfunc B() {}\nfunc Run() { B() }\n"
+ if err := os.WriteFile(path, []byte(before), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(repository, "caller.go"), []byte("package sample\nfunc Entry() { Run() }\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(repository, "go.mod"), []byte("module example.com/sample\n\ngo 1.22\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ engine, err := Open(dataDir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer engine.Close()
+ if _, err := engine.IndexRepo(context.Background(), repository, WithRepoName("fixture")); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte(after), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ updated, err := engine.IndexRepo(context.Background(), repository, WithRepoName("fixture"), WithIndexMode(IndexModeAuto))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if updated.Mode != "incremental" || updated.Modified != 1 || updated.FallbackReason != "" {
+ t.Fatalf("updated=%#v", updated)
+ }
+ callers, err := engine.graphStore("fixture").GetOutEdges("Function:main.go:Run", "CALLS")
+ if err != nil || len(callers) != 1 || callers[0].Target != "Function:main.go:B" {
+ t.Fatalf("CALLS=%#v error=%v", callers, err)
+ }
+ incoming, err := engine.graphStore("fixture").GetInEdges("Function:main.go:Run", "CALLS")
+ if err != nil || len(incoming) != 1 || incoming[0].Source != "Function:caller.go:Entry" {
+ t.Fatalf("incoming CALLS=%#v error=%v", incoming, err)
+ }
+ sourceResult, err := engine.SearchSource(context.Background(), &SourceSearchRequest{Repo: "fixture", Query: "B()", Limit: 10})
+ if err != nil || len(sourceResult.Results) == 0 {
+ t.Fatalf("source=%#v error=%v", sourceResult, err)
+ }
+
+ fullEngine, err := Open(filepath.Join(t.TempDir(), "full"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer fullEngine.Close()
+ if _, err := fullEngine.IndexRepo(context.Background(), repository, WithRepoName("fixture")); err != nil {
+ t.Fatal(err)
+ }
+ updatedGraph, err := engine.graphStore("fixture").ExportKnowledgeGraph()
+ if err != nil {
+ t.Fatal(err)
+ }
+ fullGraph, err := fullEngine.graphStore("fixture").ExportKnowledgeGraph()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if updatedGraph.NodeCount() != fullGraph.NodeCount() || updatedGraph.RelationshipCount() != fullGraph.RelationshipCount() {
+ t.Fatalf(
+ "updated nodes=%d edges=%d full nodes=%d edges=%d",
+ updatedGraph.NodeCount(), updatedGraph.RelationshipCount(), fullGraph.NodeCount(), fullGraph.RelationshipCount(),
+ )
+ }
+ if got, want := canonicalKnowledgeGraph(t, updatedGraph), canonicalKnowledgeGraph(t, fullGraph); got != want {
+ t.Fatal("automatic incremental graph differs from a clean full index")
+ }
+}
+
+func TestAutomaticIndexTypeScriptBodyChangeIsFullEquivalent(t *testing.T) {
+ repository := t.TempDir()
+ mainPath := filepath.Join(repository, "main.ts")
+ if err := os.WriteFile(filepath.Join(repository, "service.ts"), []byte("export function a() {}\nexport function b() {}\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(mainPath, []byte("import { a, b } from './service';\nexport function run() { a(); }\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ engine, err := Open(filepath.Join(t.TempDir(), "incremental"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer engine.Close()
+ if _, err := engine.IndexRepo(context.Background(), repository, WithRepoName("fixture")); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(mainPath, []byte("import { a, b } from './service';\nexport function run() { b(); }\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ result, err := engine.IndexRepo(context.Background(), repository, WithRepoName("fixture"), WithIndexMode(IndexModeAuto))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result.Mode != "incremental" {
+ t.Fatalf("result=%#v", result)
+ }
+ fullEngine, err := Open(filepath.Join(t.TempDir(), "full"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer fullEngine.Close()
+ if _, err := fullEngine.IndexRepo(context.Background(), repository, WithRepoName("fixture")); err != nil {
+ t.Fatal(err)
+ }
+ incrementalGraph, err := engine.graphStore("fixture").ExportKnowledgeGraph()
+ if err != nil {
+ t.Fatal(err)
+ }
+ fullGraph, err := fullEngine.graphStore("fixture").ExportKnowledgeGraph()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got, want := canonicalKnowledgeGraph(t, incrementalGraph), canonicalKnowledgeGraph(t, fullGraph); got != want {
+ t.Fatal("TypeScript incremental graph differs from a clean full index")
+ }
+}
+
+func TestRepositoryOpenRollsPendingIncrementalRevisionForwardBeforeQuery(t *testing.T) {
+ repository := t.TempDir()
+ dataDir := filepath.Join(t.TempDir(), "data")
+ path := filepath.Join(repository, "main.ts")
+ if err := os.WriteFile(path, []byte("export function OldSymbol() {}\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ engine, err := Open(dataDir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := engine.IndexRepo(context.Background(), repository, WithRepoName("fixture")); err != nil {
+ t.Fatal(err)
+ }
+ graphStore := engine.graphStore("fixture")
+ physical := graphStore.Repo()
+ previous, err := readIndexState(engine.repoDirs["fixture"], physical)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte("export function RecoveredSymbol() {}\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ current, err := captureRepositoryIndexState(repository)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := hydrateRepositoryStructure(repository, current, previous); err != nil {
+ t.Fatal(err)
+ }
+ changes := diffIndexStates(previous, current)
+ base, err := graphStore.ExportKnowledgeGraph()
+ if err != nil {
+ t.Fatal(err)
+ }
+ result, err := ingest.RunIncrementalPlan(repository, base, inc.Plan(changes, ingest.NewGraphDependencies(base)))
+ if err != nil || !result.Supported {
+ t.Fatalf("result=%#v error=%v", result, err)
+ }
+ delta, err := buildIncrementalDelta(graphStore, result.Pipeline.Graph, changes, repository)
+ if err != nil {
+ t.Fatal(err)
+ }
+ current.Revision = previous.Revision + 1
+ current.NodeCount = result.Pipeline.Graph.NodeCount()
+ current.EdgeCount = result.Pipeline.Graph.RelationshipCount()
+ encoded, err := encodeIndexState(current)
+ if err != nil {
+ t.Fatal(err)
+ }
+ delta.Metadata = map[string][]byte{indexStateMetadata: encoded}
+ if err := inc.NewStoreJournal(engine.stores["fixture"]).Save(context.Background(), delta); err != nil {
+ t.Fatal(err)
+ }
+ if err := engine.Close(); err != nil {
+ t.Fatal(err)
+ }
+
+ reopened, err := Open(dataDir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer reopened.Close()
+ searchResult, err := reopened.Search(context.Background(), &SearchRequest{Repo: "fixture", Query: "RecoveredSymbol", Limit: 10})
+ if err != nil || len(searchResult.Results) != 1 {
+ t.Fatalf("search=%#v error=%v", searchResult, err)
+ }
+ if revision, err := reopened.graphStore("fixture").Revision(); err != nil || revision != uint64(current.Revision) {
+ t.Fatalf("revision=%d error=%v", revision, err)
+ }
+ if pending, err := inc.NewStoreJournal(reopened.stores["fixture"]).Load(context.Background()); err != nil || pending != nil {
+ t.Fatalf("pending=%#v error=%v", pending, err)
+ }
+}
+
+func TestAutomaticIncrementalAddDeleteRenameAndStructureMatchesCleanFull(t *testing.T) {
+ repository := t.TempDir()
+ write := func(name, content string) {
+ t.Helper()
+ if err := os.WriteFile(filepath.Join(repository, name), []byte(content), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ write("lib.ts", "export function target(): string { return 'ok' }\n")
+ write("app.ts", "import { target } from './lib'\nexport function run(): string { return target() }\n")
+ write("removed.ts", "export function removed() {}\n")
+ incrementalEngine, err := Open(filepath.Join(t.TempDir(), "incremental"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer incrementalEngine.Close()
+ if _, err := incrementalEngine.IndexRepo(context.Background(), repository, WithRepoName("fixture")); err != nil {
+ t.Fatal(err)
+ }
+ physical := incrementalEngine.graphStore("fixture").Repo()
+ if err := os.Rename(filepath.Join(repository, "lib.ts"), filepath.Join(repository, "core.ts")); err != nil {
+ t.Fatal(err)
+ }
+ write("app.ts", "import { target } from './core'\nexport function run(): string { return target() }\n")
+ if err := os.Remove(filepath.Join(repository, "removed.ts")); err != nil {
+ t.Fatal(err)
+ }
+ write("added.ts", "export function added(): number { return 1 }\n")
+ result, err := incrementalEngine.IndexRepo(context.Background(), repository, WithRepoName("fixture"), WithIndexMode(IndexModeAuto))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result.Mode != "incremental" || result.Added != 1 || result.Deleted != 1 || result.Renamed != 1 || result.Modified != 1 {
+ t.Fatalf("result=%#v", result)
+ }
+ if incrementalEngine.graphStore("fixture").Repo() != physical {
+ t.Fatal("incremental update replaced physical full snapshot")
+ }
+ fullEngine, err := Open(filepath.Join(t.TempDir(), "full"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer fullEngine.Close()
+ if _, err := fullEngine.IndexRepo(context.Background(), repository, WithRepoName("fixture")); err != nil {
+ t.Fatal(err)
+ }
+ incrementalGraph, err := incrementalEngine.graphStore("fixture").ExportKnowledgeGraph()
+ if err != nil {
+ t.Fatal(err)
+ }
+ fullGraph, err := fullEngine.graphStore("fixture").ExportKnowledgeGraph()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got, want := canonicalKnowledgeGraph(t, incrementalGraph), canonicalKnowledgeGraph(t, fullGraph); got != want {
+ t.Fatal("incremental add/delete/rename graph differs from clean full graph")
+ }
+ if matches, err := incrementalEngine.Search(context.Background(), &SearchRequest{Repo: "fixture", Query: "added", Limit: 10}); err != nil || len(matches.Results) == 0 {
+ t.Fatalf("added symbol=%#v error=%v", matches, err)
+ }
+ if matches, err := incrementalEngine.SearchSource(context.Background(), &SourceSearchRequest{Repo: "fixture", Query: "removed", Scope: SourceScopeCode, Limit: 10}); err != nil || len(matches.Results) != 0 {
+ t.Fatalf("removed source=%#v error=%v", matches, err)
+ }
+}
+
+func TestGoIncrementalPackageSemanticsMatchCleanFull(t *testing.T) {
+ repository := t.TempDir()
+ write := func(name, content string) {
+ t.Helper()
+ path := filepath.Join(repository, filepath.FromSlash(name))
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ write("go.mod", "module example.com/fixture\n\ngo 1.22\n")
+ write("contract/contract.go", "package contract\ntype Runner interface { Run() string }\n")
+ write("worker/worker.go", "package worker\nimport \"example.com/fixture/contract\"\ntype Worker struct{}\nfunc (Worker) Run() string { return \"old\" }\nvar _ contract.Runner = Worker{}\n")
+
+ incrementalEngine, err := Open(filepath.Join(t.TempDir(), "incremental"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer incrementalEngine.Close()
+ if _, err := incrementalEngine.IndexRepo(context.Background(), repository, WithRepoName("fixture")); err != nil {
+ t.Fatal(err)
+ }
+ write("worker/worker.go", "package worker\nimport \"example.com/fixture/contract\"\ntype Worker struct{}\nfunc (Worker) Run() string { return \"new\" }\nvar _ contract.Runner = Worker{}\n")
+ result, err := incrementalEngine.IndexRepo(context.Background(), repository, WithRepoName("fixture"), WithIndexMode(IndexModeAuto))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result.Mode != "incremental" {
+ t.Fatalf("result=%#v", result)
+ }
+
+ fullEngine, err := Open(filepath.Join(t.TempDir(), "full"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer fullEngine.Close()
+ if _, err := fullEngine.IndexRepo(context.Background(), repository, WithRepoName("fixture")); err != nil {
+ t.Fatal(err)
+ }
+ incrementalGraph, err := incrementalEngine.graphStore("fixture").ExportKnowledgeGraph()
+ if err != nil {
+ t.Fatal(err)
+ }
+ fullGraph, err := fullEngine.graphStore("fixture").ExportKnowledgeGraph()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got, want := canonicalKnowledgeGraph(t, incrementalGraph), canonicalKnowledgeGraph(t, fullGraph); got != want {
+ gotLines, wantLines := strings.Split(got, "\n"), strings.Split(want, "\n")
+ for index := 0; index < len(gotLines) && index < len(wantLines); index++ {
+ if gotLines[index] != wantLines[index] {
+ t.Fatalf("Go package-scoped incremental graph differs at line %d:\nincremental: %s\nfull: %s", index+1, gotLines[index], wantLines[index])
+ }
+ }
+ t.Fatalf("Go package-scoped incremental graph length differs: incremental=%d full=%d", len(gotLines), len(wantLines))
+ }
+}
+
+func canonicalKnowledgeGraph(t *testing.T, knowledgeGraph *model.KnowledgeGraph) string {
+ t.Helper()
+ nodes := knowledgeGraph.Nodes()
+ relationships := knowledgeGraph.Relationships()
+ sort.Slice(nodes, func(i, j int) bool { return nodes[i].ID < nodes[j].ID })
+ sort.Slice(relationships, func(i, j int) bool { return relationships[i].ID < relationships[j].ID })
+ encoded, err := json.Marshal(struct {
+ Nodes []*model.GraphNode `json:"nodes"`
+ Relationships []*model.GraphRelationship `json:"relationships"`
+ }{Nodes: nodes, Relationships: relationships})
+ if err != nil {
+ t.Fatal(err)
+ }
+ return string(encoded)
+}
diff --git a/engine_intelligence_test.go b/engine_intelligence_test.go
index 99ca2ec..8900508 100644
--- a/engine_intelligence_test.go
+++ b/engine_intelligence_test.go
@@ -74,6 +74,52 @@ func TestImpactNodeFilteringKeepsFilesOnlyForFileAnalysis(t *testing.T) {
}
}
+func TestImpactRelationshipClassification(t *testing.T) {
+ production := GraphNode{Name: "Caller", FilePath: "service.go"}
+ testNode := GraphNode{Name: "TestCaller", FilePath: "service_test.go"}
+
+ category, review, expand := classifyImpactRelationship(ImpactChangeBehavior, "CALLS", production)
+ if category != ImpactCategoryConfirmed || review || !expand {
+ t.Fatalf("CALLS classification=%q review=%v expand=%v", category, review, expand)
+ }
+ category, review, expand = classifyImpactRelationship(ImpactChangeBehavior, "IMPLEMENTS", production)
+ if category != ImpactCategoryReview || !review || expand {
+ t.Fatalf("behavior IMPLEMENTS classification=%q review=%v expand=%v", category, review, expand)
+ }
+ category, review, expand = classifyImpactRelationship(ImpactChangeContract, "IMPLEMENTS", production)
+ if category != ImpactCategoryContract || review || !expand {
+ t.Fatalf("contract IMPLEMENTS classification=%q review=%v expand=%v", category, review, expand)
+ }
+ category, review, expand = classifyImpactRelationship(ImpactChangeBehavior, "CALLS", testNode)
+ if category != ImpactCategoryTest || review || expand {
+ t.Fatalf("test classification=%q review=%v expand=%v", category, review, expand)
+ }
+ category, review, expand = classifyImpactRelationship(ImpactChangeBehavior, "DISPATCHES_TO", production)
+ if category != ImpactCategoryReview || !review || !expand {
+ t.Fatalf("behavior dispatch classification=%q review=%v expand=%v", category, review, expand)
+ }
+}
+
+func TestImpactChangeKindInference(t *testing.T) {
+ tests := []struct {
+ label graph.Label
+ want string
+ }{
+ {graph.LabelFunction, ImpactChangeBehavior},
+ {graph.LabelInterface, ImpactChangeContract},
+ {graph.LabelClass, ImpactChangeSignature},
+ }
+ for _, test := range tests {
+ kind, assumption, err := normalizedImpactChangeKind("", graph.NewNode("repo", test.label, "Symbol"))
+ if err != nil || kind != test.want || assumption == "" {
+ t.Fatalf("label=%s kind=%q assumption=%q err=%v", test.label, kind, assumption, err)
+ }
+ }
+ if _, _, err := normalizedImpactChangeKind("unknown", graph.NewNode("repo", graph.LabelFunction, "Run")); !errors.Is(err, ErrInvalidRequest) {
+ t.Fatalf("invalid change kind error=%v", err)
+ }
+}
+
func TestDirectedCycleComponents(t *testing.T) {
edges := []*graph.Edge{
graph.NewEdge(graph.RelImports, "a", "b"),
diff --git a/go.mod b/go.mod
index d5b455c..9703398 100644
--- a/go.mod
+++ b/go.mod
@@ -7,12 +7,14 @@ require (
github.com/blugelabs/bluge v0.2.2
github.com/cockroachdb/pebble/v2 v2.1.6
github.com/coder/hnsw v0.6.1
+ github.com/fsnotify/fsnotify v1.9.0
github.com/go-enry/go-enry/v2 v2.9.6
github.com/hashicorp/golang-lru/v2 v2.0.7
github.com/modelcontextprotocol/go-sdk v1.6.1
github.com/sourcegraph/zoekt v0.0.0-20260716100610-b2738987fa42
github.com/spf13/cobra v1.10.2
github.com/vmihailenco/msgpack/v5 v5.4.1
+ golang.org/x/sync v0.21.0
golang.org/x/tools v0.43.0
)
@@ -64,7 +66,6 @@ require (
golang.org/x/mod v0.34.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
- golang.org/x/sync v0.21.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/grpc v1.80.0 // indirect
)
diff --git a/go.sum b/go.sum
index b4c05f2..f7a4d5f 100644
--- a/go.sum
+++ b/go.sum
@@ -97,6 +97,8 @@ github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/
github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
github.com/frankban/quicktest v1.14.3/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
+github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
+github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fwcd/tree-sitter-kotlin v0.0.0-20260602151103-c8ac3d262724 h1:2kH1FLKavrQd7PtrvtVrn0IbMTARIBP//ahOnhbj7cY=
github.com/fwcd/tree-sitter-kotlin v0.0.0-20260602151103-c8ac3d262724/go.mod h1:VDp2dbLmXdPwjWnz7xVmjLKP6U2ZJyaQrGNxbEflMPc=
github.com/getsentry/sentry-go v0.31.1 h1:ELVc0h7gwyhnXHDouXkhqTFSO5oslsRDk0++eyE0KJ4=
diff --git a/incremental_commit.go b/incremental_commit.go
new file mode 100644
index 0000000..1bed930
--- /dev/null
+++ b/incremental_commit.go
@@ -0,0 +1,43 @@
+package codetrip
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/mengshi02/codetrip/internal/graph"
+ inc "github.com/mengshi02/codetrip/internal/incremental"
+ "github.com/mengshi02/codetrip/internal/search/semantic"
+ "github.com/mengshi02/codetrip/internal/search/source"
+ "github.com/mengshi02/codetrip/internal/search/symbol"
+ "github.com/mengshi02/codetrip/internal/store"
+)
+
+const indexStateMetadata = "index-state"
+
+func newRepositoryCommitter(
+ database *store.Store,
+ graphStore *graph.GraphStore,
+ lexical *symbol.LexicalIndex,
+ content *source.Index,
+ vector *semantic.VectorSearch,
+ repoRoot string,
+) *inc.RecoverableCommitter {
+ state := inc.ComponentFunc{ComponentName: "index-state", ApplyDelta: func(_ context.Context, delta *inc.Delta) error {
+ encoded := delta.Metadata[indexStateMetadata]
+ if len(encoded) == 0 {
+ return fmt.Errorf("incremental delta has no index state")
+ }
+ return writeIndexStateBytes(repoRoot, graphStore.Repo(), encoded)
+ }}
+ components := []inc.Component{
+ inc.NewSymbolComponent(lexical),
+ inc.NewSourceComponent(content),
+ }
+ if vector != nil {
+ components = append(components, inc.NewVectorComponent(vector))
+ }
+ components = append(components, state)
+ return inc.NewRecoverableCommitter(
+ inc.NewStoreJournal(database), inc.NewGraphAuthority(graphStore), components...,
+ )
+}
diff --git a/incremental_delta.go b/incremental_delta.go
new file mode 100644
index 0000000..f3de799
--- /dev/null
+++ b/incremental_delta.go
@@ -0,0 +1,164 @@
+package codetrip
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+
+ "github.com/mengshi02/codetrip/internal/graph"
+ inc "github.com/mengshi02/codetrip/internal/incremental"
+ model "github.com/mengshi02/codetrip/internal/model"
+)
+
+// buildIncrementalDelta compares stable graph IDs and reads only changed
+// source files. The full snapshot remains untouched until the recoverable
+// committer publishes the resulting revision.
+func buildIncrementalDelta(
+ graphStore *graph.GraphStore,
+ updated *model.KnowledgeGraph,
+ changes inc.ChangeSet,
+ repoPath string,
+) (*inc.Delta, error) {
+ delta := &inc.Delta{
+ BaseRevision: changes.BaseRevision, Revision: changes.BaseRevision + 1,
+ PutSymbols: make(map[string][]byte), PutSources: make(map[string][]byte),
+ }
+ oldNodes := make(map[string]*graph.Node)
+ if err := graphStore.ForEachNode(func(node *graph.Node) error {
+ oldNodes[node.ID] = node
+ return nil
+ }); err != nil {
+ return nil, err
+ }
+ newNodes := make(map[string]*graph.Node)
+ for _, source := range updated.Nodes() {
+ node := graph.NodeFromModel(graphStore.Repo(), source)
+ newNodes[node.ID] = node
+ payload, err := graph.Encode(node)
+ if err != nil {
+ return nil, err
+ }
+ if old, exists := oldNodes[node.ID]; exists {
+ equal, compareErr := canonicalGraphEqual(old, node)
+ if compareErr != nil {
+ return nil, compareErr
+ }
+ if equal {
+ continue
+ }
+ }
+ delta.PutNodes = append(delta.PutNodes, inc.NodeDelta{ID: node.ID, Payload: payload})
+ if node.Label.IsSymbol() {
+ delta.PutSymbols[node.ID] = payload
+ invalidateNodeVectors(delta, node.ID)
+ }
+ }
+ for id, node := range oldNodes {
+ if _, exists := newNodes[id]; exists {
+ continue
+ }
+ delta.DeleteNodes = append(delta.DeleteNodes, id)
+ if node.Label.IsSymbol() {
+ delta.DeleteSymbols = append(delta.DeleteSymbols, id)
+ invalidateNodeVectors(delta, id)
+ }
+ }
+
+ oldEdges := make(map[string]*graph.Edge)
+ if err := graphStore.ForEachEdge(func(edge *graph.Edge) error {
+ oldEdges[edge.ID] = edge
+ return nil
+ }); err != nil {
+ return nil, err
+ }
+ newEdges := make(map[string]*graph.Edge)
+ for _, source := range updated.Relationships() {
+ edge := graph.EdgeFromModel(graphStore.Repo(), source)
+ newEdges[edge.ID] = edge
+ payload, err := graph.Encode(edge)
+ if err != nil {
+ return nil, err
+ }
+ if old, exists := oldEdges[edge.ID]; exists {
+ if equivalentGraphEdge(old, edge) {
+ continue
+ }
+ }
+ delta.PutEdges = append(delta.PutEdges, inc.EdgeDelta{ID: edge.ID, Payload: payload})
+ }
+ for id := range oldEdges {
+ if _, exists := newEdges[id]; !exists {
+ delta.DeleteEdges = append(delta.DeleteEdges, id)
+ }
+ }
+
+ putPaths := make(map[string]bool)
+ for _, file := range changes.Added {
+ putPaths[file.Path] = true
+ }
+ for _, modification := range changes.Modified {
+ putPaths[modification.After.Path] = true
+ }
+ for _, rename := range changes.Renamed {
+ delta.DeleteSources = append(delta.DeleteSources, rename.Before.Path)
+ putPaths[rename.After.Path] = true
+ }
+ for _, file := range changes.Deleted {
+ delta.DeleteSources = append(delta.DeleteSources, file.Path)
+ }
+ for path := range putPaths {
+ content, err := os.ReadFile(filepath.Join(repoPath, filepath.FromSlash(path)))
+ if err != nil {
+ return nil, fmt.Errorf("read changed source %s: %w", path, err)
+ }
+ delta.PutSources[filepath.ToSlash(path)] = content
+ }
+ sortIncrementalDelta(delta)
+ return delta, nil
+}
+
+func equivalentGraphEdge(left, right *graph.Edge) bool {
+ return left != nil && right != nil &&
+ left.ID == right.ID && left.Repo == right.Repo && left.Type == right.Type &&
+ left.Source == right.Source && left.Target == right.Target &&
+ left.Confidence() == right.Confidence() &&
+ left.GetPropString("reason") == right.GetPropString("reason") &&
+ left.GetPropInt("step") == right.GetPropInt("step")
+}
+
+func canonicalGraphEqual(left, right any) (bool, error) {
+ leftJSON, err := json.Marshal(left)
+ if err != nil {
+ return false, err
+ }
+ rightJSON, err := json.Marshal(right)
+ if err != nil {
+ return false, err
+ }
+ return bytes.Equal(leftJSON, rightJSON), nil
+}
+
+func invalidateNodeVectors(delta *inc.Delta, nodeID string) {
+ delta.DeleteVectors = append(delta.DeleteVectors,
+ inc.VectorRef{NodeID: nodeID, Modality: "desc", Chunk: -1},
+ inc.VectorRef{NodeID: nodeID, Modality: "code", Chunk: -2},
+ )
+}
+
+func sortIncrementalDelta(delta *inc.Delta) {
+ sort.Slice(delta.PutNodes, func(i, j int) bool { return delta.PutNodes[i].ID < delta.PutNodes[j].ID })
+ sort.Strings(delta.DeleteNodes)
+ sort.Slice(delta.PutEdges, func(i, j int) bool { return delta.PutEdges[i].ID < delta.PutEdges[j].ID })
+ sort.Strings(delta.DeleteEdges)
+ sort.Strings(delta.DeleteSymbols)
+ sort.Strings(delta.DeleteSources)
+ sort.Slice(delta.DeleteVectors, func(i, j int) bool {
+ if delta.DeleteVectors[i].NodeID != delta.DeleteVectors[j].NodeID {
+ return delta.DeleteVectors[i].NodeID < delta.DeleteVectors[j].NodeID
+ }
+ return delta.DeleteVectors[i].Modality < delta.DeleteVectors[j].Modality
+ })
+}
diff --git a/incremental_delta_test.go b/incremental_delta_test.go
new file mode 100644
index 0000000..51ac0be
--- /dev/null
+++ b/incremental_delta_test.go
@@ -0,0 +1,61 @@
+package codetrip
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/mengshi02/codetrip/internal/graph"
+ inc "github.com/mengshi02/codetrip/internal/incremental"
+ model "github.com/mengshi02/codetrip/internal/model"
+ "github.com/mengshi02/codetrip/internal/store"
+)
+
+func TestBuildIncrementalDeltaUsesStableGraphIDsAndChangedSources(t *testing.T) {
+ database, err := store.Open(store.DefaultConfig(t.TempDir()))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer database.Close()
+ graphStore := graph.NewGraphStore(database, "snapshot")
+ base := model.NewKnowledgeGraph()
+ base.AddNode(modelNode("one", "OldName", "main.go"))
+ base.AddNode(modelNode("two", "Removed", "removed.go"))
+ base.AddRelationship(&model.GraphRelationship{ID: "old-edge", SourceID: "one", TargetID: "two", Type: model.RelCALLS, Confidence: 0.9})
+ if err := graphStore.ImportKnowledgeGraph(base); err != nil {
+ t.Fatal(err)
+ }
+ updated := model.NewKnowledgeGraph()
+ updated.AddNode(modelNode("one", "NewName", "main.go"))
+ updated.AddNode(modelNode("three", "Added", "added.go"))
+ updated.AddRelationship(&model.GraphRelationship{ID: "new-edge", SourceID: "one", TargetID: "three", Type: model.RelCALLS, Confidence: 0.95})
+ repository := t.TempDir()
+ if err := os.WriteFile(filepath.Join(repository, "main.go"), []byte("package main\nfunc NewName() {}\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ changes := inc.ChangeSet{
+ BaseRevision: 2,
+ Modified: []inc.Modification{{Before: inc.FileState{Path: "main.go"}, After: inc.FileState{Path: "main.go"}}},
+ Deleted: []inc.FileState{{Path: "removed.go"}},
+ }
+ delta, err := buildIncrementalDelta(graphStore, updated, changes, repository)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if delta.BaseRevision != 2 || delta.Revision != 3 || len(delta.PutNodes) != 2 || len(delta.DeleteNodes) != 1 || delta.DeleteNodes[0] != "two" {
+ t.Fatalf("node delta=%#v", delta)
+ }
+ if len(delta.PutEdges) != 1 || delta.PutEdges[0].ID != "new-edge" || len(delta.DeleteEdges) != 1 || delta.DeleteEdges[0] != "old-edge" {
+ t.Fatalf("edge delta=%#v", delta)
+ }
+ if string(delta.PutSources["main.go"]) == "" || len(delta.DeleteSources) != 1 || delta.DeleteSources[0] != "removed.go" {
+ t.Fatalf("source delta=%#v", delta)
+ }
+ if len(delta.PutSymbols) != 2 || len(delta.DeleteSymbols) != 1 || len(delta.DeleteVectors) != 6 {
+ t.Fatalf("derived delta=%#v", delta)
+ }
+}
+
+func modelNode(id, name, path string) *model.GraphNode {
+ return &model.GraphNode{ID: id, Label: model.LabelFunction, Properties: model.NodeProperties{Name: name, FilePath: path, Language: "go"}}
+}
diff --git a/incremental_release_gate_test.go b/incremental_release_gate_test.go
new file mode 100644
index 0000000..79ac6d8
--- /dev/null
+++ b/incremental_release_gate_test.go
@@ -0,0 +1,254 @@
+package codetrip
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "testing"
+)
+
+// TestV030StructuralIncrementalReleaseGate is the v0.3.0 correctness gate for
+// structural edits in the two Stable languages. Every case proves that the
+// automatically refreshed index exposes the same graph, symbols, and source
+// as a clean full index of the final checkout.
+func TestV030StructuralIncrementalReleaseGate(t *testing.T) {
+ for _, language := range structuralGateLanguages() {
+ language := language
+ for _, scenario := range language.scenarios {
+ scenario := scenario
+ t.Run(language.name+"/"+scenario.name, func(t *testing.T) {
+ runStructuralIncrementalGate(t, language, scenario)
+ })
+ }
+ }
+}
+
+type structuralGateLanguage struct {
+ name string
+ baseFiles map[string]string
+ scenarios []structuralGateScenario
+}
+
+type structuralGateScenario struct {
+ name string
+ files map[string]string
+ queries []string
+ absent []string
+}
+
+func runStructuralIncrementalGate(t *testing.T, language structuralGateLanguage, scenario structuralGateScenario) {
+ t.Helper()
+ repository := t.TempDir()
+ writeGateFiles(t, repository, language.baseFiles)
+
+ incrementalEngine, err := Open(filepath.Join(t.TempDir(), "incremental"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer incrementalEngine.Close()
+ if _, err = incrementalEngine.IndexRepo(context.Background(), repository, WithRepoName("fixture")); err != nil {
+ t.Fatalf("initial full index: %v", err)
+ }
+
+ replaceGateFiles(t, repository, language.baseFiles, scenario.files)
+ result, err := incrementalEngine.IndexRepo(context.Background(), repository,
+ WithRepoName("fixture"), WithIndexMode(IndexModeAuto))
+ if err != nil {
+ t.Fatalf("automatic refresh: %v", err)
+ }
+ if result.Mode != "incremental" {
+ t.Fatalf("structural change did not use incremental publication: %#v", result)
+ }
+ if result.Revision != result.BaseRevision+1 {
+ t.Fatalf("revision did not advance atomically: %#v", result)
+ }
+
+ fullEngine, err := Open(filepath.Join(t.TempDir(), "full"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer fullEngine.Close()
+ if _, err = fullEngine.IndexRepo(context.Background(), repository, WithRepoName("fixture")); err != nil {
+ t.Fatalf("clean full index: %v", err)
+ }
+
+ incrementalGraph, err := incrementalEngine.graphStore("fixture").ExportKnowledgeGraph()
+ if err != nil {
+ t.Fatal(err)
+ }
+ fullGraph, err := fullEngine.graphStore("fixture").ExportKnowledgeGraph()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got, want := canonicalKnowledgeGraph(t, incrementalGraph), canonicalKnowledgeGraph(t, fullGraph); got != want {
+ t.Fatalf("incremental graph differs from clean full index\n%s", firstStringDifference(got, want))
+ }
+
+ for _, query := range scenario.queries {
+ assertGateQueryEquivalent(t, incrementalEngine, fullEngine, query)
+ }
+ for _, query := range scenario.absent {
+ assertGateQueryAbsent(t, incrementalEngine, query)
+ }
+}
+
+func assertGateQueryEquivalent(t *testing.T, incrementalEngine, fullEngine *Engine, query string) {
+ t.Helper()
+ ctx := context.Background()
+ incrementalSymbols, err := incrementalEngine.Search(ctx, &SearchRequest{Repo: "fixture", Query: query, Limit: 50})
+ if err != nil {
+ t.Fatalf("incremental symbol query %q: %v", query, err)
+ }
+ fullSymbols, err := fullEngine.Search(ctx, &SearchRequest{Repo: "fixture", Query: query, Limit: 50})
+ if err != nil {
+ t.Fatalf("full symbol query %q: %v", query, err)
+ }
+ if got, want := canonicalSymbolResults(incrementalSymbols), canonicalSymbolResults(fullSymbols); got != want {
+ t.Fatalf("symbol query %q differs\nincremental: %s\nfull: %s", query, got, want)
+ }
+ if len(fullSymbols.Results) == 0 {
+ t.Fatalf("gate query %q does not identify a final symbol", query)
+ }
+
+ incrementalSource, err := incrementalEngine.SearchSource(ctx, &SourceSearchRequest{Repo: "fixture", Query: query, Scope: SourceScopeCode, Limit: 50})
+ if err != nil {
+ t.Fatalf("incremental source query %q: %v", query, err)
+ }
+ fullSource, err := fullEngine.SearchSource(ctx, &SourceSearchRequest{Repo: "fixture", Query: query, Scope: SourceScopeCode, Limit: 50})
+ if err != nil {
+ t.Fatalf("full source query %q: %v", query, err)
+ }
+ if got, want := canonicalSourceResults(incrementalSource), canonicalSourceResults(fullSource); got != want {
+ t.Fatalf("source query %q differs\nincremental: %s\nfull: %s", query, got, want)
+ }
+ if len(fullSource.Results) == 0 {
+ t.Fatalf("gate query %q does not identify final source", query)
+ }
+}
+
+func assertGateQueryAbsent(t *testing.T, engine *Engine, query string) {
+ t.Helper()
+ ctx := context.Background()
+ symbols, err := engine.Search(ctx, &SearchRequest{Repo: "fixture", Query: query, Limit: 50})
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, result := range symbols.Results {
+ if result.Name == query {
+ t.Fatalf("removed symbol %q remains searchable: %#v", query, result)
+ }
+ }
+ sources, err := engine.SearchSource(ctx, &SourceSearchRequest{Repo: "fixture", Query: query, Scope: SourceScopeCode, Limit: 50})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(sources.Results) != 0 {
+ t.Fatalf("removed source %q remains searchable: %#v", query, sources.Results)
+ }
+}
+
+func canonicalSymbolResults(result *SearchResult) string {
+ values := make([]string, 0, len(result.Results))
+ for _, item := range result.Results {
+ values = append(values, fmt.Sprintf("%s|%s|%s|%s", item.NodeID, item.Name, item.Label, item.FilePath))
+ }
+ sort.Strings(values)
+ return strings.Join(values, "\n")
+}
+
+func canonicalSourceResults(result *SourceSearchResult) string {
+ values := make([]string, 0, len(result.Results))
+ for _, item := range result.Results {
+ // Backends may include or omit the trailing record separator in Content;
+ // it does not change the source match visible to callers.
+ values = append(values, fmt.Sprintf("%s|%d|%s", item.FilePath, item.Line, strings.TrimSpace(item.Content)))
+ }
+ sort.Strings(values)
+ return strings.Join(values, "\n")
+}
+
+func firstStringDifference(got, want string) string {
+ gotLines, wantLines := strings.Split(got, "\n"), strings.Split(want, "\n")
+ for index := 0; index < len(gotLines) && index < len(wantLines); index++ {
+ if gotLines[index] != wantLines[index] {
+ return fmt.Sprintf("first difference at line %d\nincremental: %s\nfull: %s", index+1, gotLines[index], wantLines[index])
+ }
+ }
+ return fmt.Sprintf("different lengths: incremental=%d full=%d", len(gotLines), len(wantLines))
+}
+
+func writeGateFiles(t *testing.T, root string, files map[string]string) {
+ t.Helper()
+ for name, content := range files {
+ path := filepath.Join(root, filepath.FromSlash(name))
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+}
+
+func replaceGateFiles(t *testing.T, root string, before, after map[string]string) {
+ t.Helper()
+ for name := range before {
+ if _, retained := after[name]; !retained {
+ if err := os.Remove(filepath.Join(root, filepath.FromSlash(name))); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+ writeGateFiles(t, root, after)
+}
+
+func structuralGateLanguages() []structuralGateLanguage {
+ return []structuralGateLanguage{goStructuralGate(), typeScriptStructuralGate()}
+}
+
+func goStructuralGate() structuralGateLanguage {
+ base := map[string]string{
+ "go.mod": "module example.com/gate\n\ngo 1.22\n",
+ "main.go": "package gate\n\ntype Runner interface { Run(string) string }\ntype Worker struct{}\nfunc (Worker) Run(v string) string { return v }\nfunc Alpha() string { return \"alpha\" }\nfunc Beta(v string) string { return v }\nfunc Entry() string { return Alpha() }\n",
+ "util.go": "package gate\n\nfunc Helper() string { return \"helper\" }\n",
+ }
+ return structuralGateLanguage{name: "go", baseFiles: base, scenarios: []structuralGateScenario{
+ {name: "add-function", files: gateCopy(base, "extra.go", "package gate\n\nfunc AddedFunction() string { return Alpha() }\n"), queries: []string{"AddedFunction"}},
+ {name: "delete-function", files: map[string]string{"go.mod": base["go.mod"], "main.go": "package gate\n\ntype Runner interface { Run(string) string }\ntype Worker struct{}\nfunc (Worker) Run(v string) string { return v }\nfunc Beta(v string) string { return v }\nfunc Entry() string { return Beta(\"entry\") }\n", "util.go": base["util.go"]}, queries: []string{"Entry"}, absent: []string{"Alpha"}},
+ {name: "rename-function", files: map[string]string{"go.mod": base["go.mod"], "main.go": strings.ReplaceAll(base["main.go"], "Alpha", "Gamma"), "util.go": base["util.go"]}, queries: []string{"Gamma"}, absent: []string{"Alpha"}},
+ {name: "move-function", files: map[string]string{"go.mod": base["go.mod"], "main.go": strings.Replace(base["main.go"], "func Alpha() string { return \"alpha\" }\n", "", 1), "util.go": base["util.go"] + "func Alpha() string { return \"alpha\" }\n"}, queries: []string{"Alpha"}},
+ {name: "change-signature", files: map[string]string{"go.mod": base["go.mod"], "main.go": strings.Replace(strings.Replace(base["main.go"], "func Alpha() string", "func Alpha(prefix string) string", 1), "return Alpha()", "return Alpha(\"gate\")", 1), "util.go": base["util.go"]}, queries: []string{"Alpha"}},
+ {name: "change-interface", files: map[string]string{"go.mod": base["go.mod"], "main.go": strings.Replace(strings.Replace(base["main.go"], "interface { Run(string) string }", "interface { Run(string) string; Stop() error }", 1), "func (Worker) Run(v string) string { return v }", "func (Worker) Run(v string) string { return v }\nfunc (Worker) Stop() error { return nil }", 1), "util.go": base["util.go"]}, queries: []string{"Stop"}},
+ {name: "add-cross-file-call", files: map[string]string{"go.mod": base["go.mod"], "main.go": strings.Replace(base["main.go"], "return Alpha()", "return Helper()", 1), "util.go": base["util.go"]}, queries: []string{"Helper", "Entry"}},
+ {name: "change-import", files: map[string]string{"go.mod": base["go.mod"], "main.go": "package gate\n\nimport \"strings\"\n\ntype Runner interface { Run(string) string }\ntype Worker struct{}\nfunc (Worker) Run(v string) string { return v }\nfunc Alpha() string { return strings.ToUpper(\"alpha\") }\nfunc Beta(v string) string { return v }\nfunc Entry() string { return Alpha() }\n", "util.go": base["util.go"]}, queries: []string{"Alpha"}},
+ }}
+}
+
+func typeScriptStructuralGate() structuralGateLanguage {
+ base := map[string]string{
+ "main.ts": "import { helper } from './util'\nexport interface Runner { run(value: string): string }\nexport class Worker implements Runner { run(value: string): string { return value } }\nexport function alpha(): string { return 'alpha' }\nexport function beta(value: string): string { return value }\nexport function entry(): string { return alpha() + helper() }\n",
+ "util.ts": "export function helper(): string { return 'helper' }\n",
+ }
+ return structuralGateLanguage{name: "typescript", baseFiles: base, scenarios: []structuralGateScenario{
+ {name: "add-function", files: gateCopy(base, "extra.ts", "import { alpha } from './main'\nexport function addedFunction(): string { return alpha() }\n"), queries: []string{"addedFunction"}},
+ {name: "delete-function", files: map[string]string{"main.ts": strings.Replace(strings.Replace(base["main.ts"], "export function alpha(): string { return 'alpha' }\n", "", 1), "return alpha() + helper()", "return beta('entry') + helper()", 1), "util.ts": base["util.ts"]}, queries: []string{"entry"}, absent: []string{"alpha"}},
+ {name: "rename-function", files: map[string]string{"main.ts": strings.ReplaceAll(base["main.ts"], "alpha", "gamma"), "util.ts": base["util.ts"]}, queries: []string{"gamma"}, absent: []string{"alpha"}},
+ {name: "move-function", files: map[string]string{"main.ts": strings.Replace(strings.Replace(base["main.ts"], "import { helper } from './util'", "import { helper, alpha } from './util'", 1), "export function alpha(): string { return 'alpha' }\n", "", 1), "util.ts": base["util.ts"] + "export function alpha(): string { return 'alpha' }\n"}, queries: []string{"alpha"}},
+ {name: "change-signature", files: map[string]string{"main.ts": strings.Replace(strings.Replace(base["main.ts"], "function alpha(): string", "function alpha(prefix: string): string", 1), "alpha() + helper()", "alpha('gate') + helper()", 1), "util.ts": base["util.ts"]}, queries: []string{"alpha"}},
+ {name: "change-interface", files: map[string]string{"main.ts": strings.Replace(strings.Replace(base["main.ts"], "run(value: string): string }", "run(value: string): string; stop(): void }", 1), "run(value: string): string { return value } }", "run(value: string): string { return value } stop(): void {} }", 1), "util.ts": base["util.ts"]}, queries: []string{"stop"}},
+ {name: "add-cross-file-call", files: map[string]string{"main.ts": strings.Replace(base["main.ts"], "return alpha() + helper()", "return helper()", 1), "util.ts": base["util.ts"]}, queries: []string{"helper", "entry"}},
+ {name: "change-import", files: map[string]string{"main.ts": strings.Replace(strings.Replace(base["main.ts"], "import { helper } from './util'", "import { replacement } from './replacement'", 1), "helper()", "replacement()", 1), "util.ts": base["util.ts"], "replacement.ts": "export function replacement(): string { return 'replacement' }\n"}, queries: []string{"replacement"}},
+ }}
+}
+
+func gateCopy(base map[string]string, name, content string) map[string]string {
+ result := make(map[string]string, len(base)+1)
+ for path, source := range base {
+ result[path] = source
+ }
+ result[name] = content
+ return result
+}
diff --git a/index_state.go b/index_state.go
new file mode 100644
index 0000000..1706f09
--- /dev/null
+++ b/index_state.go
@@ -0,0 +1,215 @@
+package codetrip
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "runtime"
+ "sort"
+ "sync"
+
+ inc "github.com/mengshi02/codetrip/internal/incremental"
+ "github.com/mengshi02/codetrip/internal/ingest"
+ "golang.org/x/sync/errgroup"
+)
+
+const indexStateSchemaVersion = 2
+const analyzerStateVersion = "v0.3.0-incremental-5"
+
+type indexedFileState struct {
+ SHA256 string `json:"sha256"`
+ StructureSHA string `json:"structureSha256,omitempty"`
+ CallSHA string `json:"callSha256,omitempty"`
+ Size int64 `json:"size"`
+ Language string `json:"language,omitempty"`
+ Unit string `json:"unit,omitempty"`
+}
+
+type repositoryIndexState struct {
+ SchemaVersion int `json:"schemaVersion"`
+ AnalyzerVersion string `json:"analyzerVersion"`
+ SourcePath string `json:"sourcePath"`
+ Revision inc.Revision `json:"revision"`
+ Files map[string]indexedFileState `json:"files"`
+ NodeCount int `json:"nodeCount"`
+ EdgeCount int `json:"edgeCount"`
+}
+
+type indexChanges struct {
+ Added []string
+ Modified []string
+ Deleted []string
+ Renamed []inc.Rename
+}
+
+func captureRepositoryIndexState(repoPath string) (*repositoryIndexState, error) {
+ walk, err := ingest.WalkRepository(repoPath)
+ if err != nil {
+ return nil, err
+ }
+ absolute, err := filepath.Abs(repoPath)
+ if err != nil {
+ return nil, err
+ }
+ state := &repositoryIndexState{
+ SchemaVersion: indexStateSchemaVersion, AnalyzerVersion: analyzerStateVersion,
+ SourcePath: absolute, Files: make(map[string]indexedFileState, len(walk.Files)),
+ }
+ var mu sync.Mutex
+ group := new(errgroup.Group)
+ workers := runtime.GOMAXPROCS(0)
+ if workers < 4 {
+ workers = 4
+ }
+ if workers > 16 {
+ workers = 16
+ }
+ group.SetLimit(workers)
+ for _, file := range walk.Files {
+ file := file
+ group.Go(func() error {
+ content, readErr := os.ReadFile(file.AbsolutePath)
+ if readErr != nil {
+ return fmt.Errorf("fingerprint %s: %w", file.RelativePath, readErr)
+ }
+ sum := sha256.Sum256(content)
+ path := filepath.ToSlash(file.RelativePath)
+ entry := indexedFileState{
+ SHA256: hex.EncodeToString(sum[:]), Size: file.Size,
+ Language: file.LanguageID, Unit: path,
+ }
+ mu.Lock()
+ state.Files[path] = entry
+ mu.Unlock()
+ return nil
+ })
+ }
+ if err := group.Wait(); err != nil {
+ return nil, err
+ }
+ return state, nil
+}
+
+// hydrateRepositoryStructure reuses fingerprints for byte-identical files and
+// parses only new or changed files. This keeps no-op detection hash-only even
+// for very large repositories.
+func hydrateRepositoryStructure(repoPath string, current, previous *repositoryIndexState) error {
+ for path, file := range current.Files {
+ if file.StructureSHA != "" {
+ continue
+ }
+ if previous != nil {
+ if old, exists := previous.Files[path]; exists && old.SHA256 == file.SHA256 && old.StructureSHA != "" && old.CallSHA != "" {
+ file.StructureSHA = old.StructureSHA
+ file.CallSHA = old.CallSHA
+ current.Files[path] = file
+ continue
+ }
+ }
+ content, err := os.ReadFile(filepath.Join(repoPath, filepath.FromSlash(path)))
+ if err != nil {
+ return fmt.Errorf("read structural fingerprint %s: %w", path, err)
+ }
+ fingerprint, calls, err := ingest.AnalysisFingerprints(path, content)
+ if err != nil {
+ return fmt.Errorf("fingerprint structure %s: %w", path, err)
+ }
+ file.StructureSHA = fingerprint
+ file.CallSHA = calls
+ current.Files[path] = file
+ }
+ return nil
+}
+
+func compareIndexStates(previous, current *repositoryIndexState) indexChanges {
+ diff := diffIndexStates(previous, current)
+ changes := indexChanges{Renamed: diff.Renamed}
+ for _, file := range diff.Added {
+ changes.Added = append(changes.Added, file.Path)
+ }
+ for _, file := range diff.Deleted {
+ changes.Deleted = append(changes.Deleted, file.Path)
+ }
+ for _, modification := range diff.Modified {
+ changes.Modified = append(changes.Modified, modification.After.Path)
+ }
+ sort.Strings(changes.Added)
+ sort.Strings(changes.Modified)
+ sort.Strings(changes.Deleted)
+ return changes
+}
+
+func diffIndexStates(previous, current *repositoryIndexState) inc.ChangeSet {
+ before := inc.SnapshotState{Files: map[string]inc.FileState{}}
+ if previous != nil {
+ before.Revision = previous.Revision
+ before.Files = incrementalFileStates(previous)
+ }
+ after := inc.SnapshotState{Files: incrementalFileStates(current)}
+ return inc.Diff(before, after)
+}
+
+func incrementalFileStates(state *repositoryIndexState) map[string]inc.FileState {
+ result := make(map[string]inc.FileState)
+ if state == nil {
+ return result
+ }
+ for path, file := range state.Files {
+ result[path] = inc.FileState{
+ Path: path, ContentHash: file.SHA256, StructureHash: file.StructureSHA,
+ CallHash: file.CallSHA,
+ Size: file.Size, Language: file.Language, Unit: file.Unit,
+ }
+ }
+ return result
+}
+
+func indexStatePath(repoRoot, snapshot string) string {
+ return filepath.Join(repoRoot, "state", snapshot+".json")
+}
+
+func writeIndexState(repoRoot, snapshot string, state *repositoryIndexState) error {
+ encoded, err := encodeIndexState(state)
+ if err != nil {
+ return err
+ }
+ return writeIndexStateBytes(repoRoot, snapshot, encoded)
+}
+
+func encodeIndexState(state *repositoryIndexState) ([]byte, error) {
+ encoded, err := json.MarshalIndent(state, "", " ")
+ if err != nil {
+ return nil, err
+ }
+ return append(encoded, '\n'), nil
+}
+
+func writeIndexStateBytes(repoRoot, snapshot string, encoded []byte) error {
+ directory := filepath.Join(repoRoot, "state")
+ if err := os.MkdirAll(directory, 0o755); err != nil {
+ return err
+ }
+ temporary := indexStatePath(repoRoot, snapshot) + ".tmp"
+ if err := os.WriteFile(temporary, encoded, 0o644); err != nil {
+ return err
+ }
+ return os.Rename(temporary, indexStatePath(repoRoot, snapshot))
+}
+
+func readIndexState(repoRoot, snapshot string) (*repositoryIndexState, error) {
+ encoded, err := os.ReadFile(indexStatePath(repoRoot, snapshot))
+ if err != nil {
+ return nil, err
+ }
+ var state repositoryIndexState
+ if err := json.Unmarshal(encoded, &state); err != nil {
+ return nil, err
+ }
+ if state.SchemaVersion != indexStateSchemaVersion || state.AnalyzerVersion != analyzerStateVersion {
+ return nil, fmt.Errorf("index state is incompatible")
+ }
+ return &state, nil
+}
diff --git a/index_state_test.go b/index_state_test.go
new file mode 100644
index 0000000..acdcd4a
--- /dev/null
+++ b/index_state_test.go
@@ -0,0 +1,75 @@
+package codetrip
+
+import (
+ "os"
+ "path/filepath"
+ "reflect"
+ "testing"
+)
+
+func TestCompareIndexStates(t *testing.T) {
+ previous := &repositoryIndexState{Files: map[string]indexedFileState{
+ "deleted.go": {SHA256: "a", Size: 1},
+ "same.go": {SHA256: "b", Size: 2},
+ "changed.go": {SHA256: "c", Size: 3},
+ }}
+ current := &repositoryIndexState{Files: map[string]indexedFileState{
+ "added.go": {SHA256: "d", Size: 4},
+ "same.go": {SHA256: "b", Size: 2},
+ "changed.go": {SHA256: "e", Size: 3},
+ }}
+ changes := compareIndexStates(previous, current)
+ if !reflect.DeepEqual(changes.Added, []string{"added.go"}) ||
+ !reflect.DeepEqual(changes.Modified, []string{"changed.go"}) ||
+ !reflect.DeepEqual(changes.Deleted, []string{"deleted.go"}) {
+ t.Fatalf("changes=%#v", changes)
+ }
+}
+
+func TestIndexStateRoundTrip(t *testing.T) {
+ root := t.TempDir()
+ state := &repositoryIndexState{
+ SchemaVersion: indexStateSchemaVersion, AnalyzerVersion: analyzerStateVersion,
+ SourcePath: "/repo", Files: map[string]indexedFileState{"main.go": {SHA256: "abc", Size: 3}},
+ }
+ if err := writeIndexState(root, "snapshot@test", state); err != nil {
+ t.Fatal(err)
+ }
+ loaded, err := readIndexState(root, "snapshot@test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(loaded, state) {
+ t.Fatalf("loaded=%#v want=%#v", loaded, state)
+ }
+ if _, err := os.Stat(filepath.Join(root, "state", "snapshot@test.json")); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestCaptureRepositoryIndexStateSkipsSymlinks(t *testing.T) {
+ root := t.TempDir()
+ if err := os.WriteFile(filepath.Join(root, "main.go"), []byte("package main\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Mkdir(filepath.Join(root, "target"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink("target", filepath.Join(root, "linked-directory")); err != nil {
+ t.Skipf("symlinks are unavailable: %v", err)
+ }
+ if err := os.Symlink("main.go", filepath.Join(root, "linked.go")); err != nil {
+ t.Skipf("symlinks are unavailable: %v", err)
+ }
+
+ state, err := captureRepositoryIndexState(root)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(state.Files) != 1 {
+ t.Fatalf("files=%v, want only the regular source file", state.Files)
+ }
+ if _, ok := state.Files["main.go"]; !ok {
+ t.Fatalf("files=%v, want main.go", state.Files)
+ }
+}
diff --git a/internal/graph/export.go b/internal/graph/export.go
index 7d7f733..5f958fe 100644
--- a/internal/graph/export.go
+++ b/internal/graph/export.go
@@ -1,5 +1,7 @@
package graph
+import ingestgraph "github.com/mengshi02/codetrip/internal/model"
+
// ForEachNode visits every persisted node in deterministic Pebble key order.
func (s *GraphStore) ForEachNode(fn func(*Node) error) error {
iterator := s.IterNodes(s.repo)
@@ -22,3 +24,91 @@ func (s *GraphStore) ForEachEdge(fn func(*Edge) error) error {
return fn(&edge)
})
}
+
+// ExportKnowledgeGraph materializes one immutable persisted snapshot for a
+// copy-on-write incremental build. The active store is never modified.
+func (s *GraphStore) ExportKnowledgeGraph() (*ingestgraph.KnowledgeGraph, error) {
+ result := ingestgraph.NewKnowledgeGraph()
+ if err := s.ForEachNode(func(node *Node) error {
+ result.AddNode(persistedNodeToModel(node))
+ return nil
+ }); err != nil {
+ return nil, err
+ }
+ if err := s.ForEachEdge(func(edge *Edge) error {
+ confidence := edge.Confidence()
+ relationship := &ingestgraph.GraphRelationship{
+ ID: edge.ID, SourceID: edge.Source, TargetID: edge.Target,
+ Type: ingestgraph.RelationshipType(edge.Type), Confidence: confidence,
+ Reason: edge.GetPropString("reason"),
+ }
+ if step := edge.GetPropInt("step"); step != 0 {
+ relationship.Step = &step
+ }
+ result.AddRelationship(relationship)
+ return nil
+ }); err != nil {
+ return nil, err
+ }
+ return result, nil
+}
+
+func persistedNodeToModel(node *Node) *ingestgraph.GraphNode {
+ properties := ingestgraph.NodeProperties{
+ Name: node.Name, FilePath: node.FilePath,
+ Content: node.GetPropString("content"),
+ Language: node.GetPropString("language"), Description: node.GetPropString("description"),
+ ReturnType: node.GetPropString("returnType"), Visibility: node.GetPropString("visibility"),
+ Modifiers: node.GetPropString("modifiers"), HeuristicLabel: node.GetPropString("heuristicLabel"),
+ EnrichedBy: node.GetPropString("enrichedBy"), ProcessType: node.GetPropString("processType"),
+ EntryPointID: node.GetPropString("entryPointID"), TerminalID: node.GetPropString("terminalID"),
+ LanguageID: node.GetPropString("languageID"), PackageName: node.GetPropString("packageName"),
+ Version: node.GetPropString("version"),
+ }
+ copyInt := func(key string) *int {
+ if value, ok := node.Props.GetProp(key); ok {
+ converted := toInt(value)
+ return &converted
+ }
+ return nil
+ }
+ copyBool := func(key string) *bool {
+ if value, ok := node.Props.GetProp(key); ok {
+ if converted, valid := value.(bool); valid {
+ return &converted
+ }
+ }
+ return nil
+ }
+ copyFloat := func(key string) *float64 {
+ if value, ok := node.Props.GetProp(key); ok {
+ converted := toFloat64(value)
+ return &converted
+ }
+ return nil
+ }
+ copyStrings := func(key string) []string {
+ if value, ok := node.Props.GetProp(key); ok {
+ return toStringSlice(value)
+ }
+ return nil
+ }
+ properties.StartLine = copyInt("startLine")
+ properties.EndLine = copyInt("endLine")
+ properties.ParameterCount = copyInt("parameterCount")
+ properties.IsExported = copyBool("isExported")
+ properties.IsAbstract = copyBool("isAbstract")
+ properties.IsStatic = copyBool("isStatic")
+ properties.IsAsync = copyBool("isAsync")
+ properties.IsTest = copyBool("isTest")
+ properties.IsBinary = copyBool("isBinary")
+ properties.Cohesion = copyFloat("cohesion")
+ properties.SymbolCount = copyFloat("symbolCount")
+ properties.StepCount = copyFloat("stepCount")
+ properties.Keywords = copyStrings("keywords")
+ properties.Communities = copyStrings("communities")
+ if value, ok := node.Props.GetProp("fileSize"); ok {
+ properties.FileSize = int64(toInt(value))
+ }
+ return &ingestgraph.GraphNode{ID: node.ID, Label: ingestgraph.NodeLabel(node.Label), Properties: properties}
+}
diff --git a/internal/graph/export_test.go b/internal/graph/export_test.go
new file mode 100644
index 0000000..31faee5
--- /dev/null
+++ b/internal/graph/export_test.go
@@ -0,0 +1,27 @@
+package graph
+
+import "testing"
+
+func TestExportKnowledgeGraph(t *testing.T) {
+ graphStore := openTestGS(t)
+ parameterCount := 2
+ node := NewNode("testrepo", LabelFunction, "Run").WithID("Function:main.go:Run").WithFile("main.go").
+ WithProp("startLine", 3).WithProp("parameterCount", parameterCount).WithProp("language", "go")
+ if err := graphStore.AddNode(node); err != nil {
+ t.Fatal(err)
+ }
+ if err := graphStore.AddEdge(NewEdge(RelCalls, node.ID, node.ID).WithID("CALLS:self").WithProp("confidence", 0.9)); err != nil {
+ t.Fatal(err)
+ }
+ exported, err := graphStore.ExportKnowledgeGraph()
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, ok := exported.GetNode(node.ID)
+ if !ok || got.Properties.ParameterCount == nil || *got.Properties.ParameterCount != 2 || got.Properties.Language != "go" {
+ t.Fatalf("node=%#v ok=%v", got, ok)
+ }
+ if exported.RelationshipCount() != 1 {
+ t.Fatalf("relationships=%d", exported.RelationshipCount())
+ }
+}
diff --git a/internal/graph/import.go b/internal/graph/import.go
index e487d75..a689c83 100644
--- a/internal/graph/import.go
+++ b/internal/graph/import.go
@@ -7,24 +7,13 @@ import ingestgraph "github.com/mengshi02/codetrip/internal/model"
// adjacency entry references an existing node.
func (s *GraphStore) ImportKnowledgeGraph(src *ingestgraph.KnowledgeGraph) error {
for _, source := range src.Nodes() {
- node := NewNode(s.repo, Label(source.Label), source.Properties.Name).
- WithID(source.ID).
- WithFile(source.Properties.FilePath)
- copyNodeProperties(node, &source.Properties)
+ node := NodeFromModel(s.repo, source)
if err := s.AddNode(node); err != nil {
return err
}
}
for _, source := range src.Relationships() {
- edge := NewEdge(RelType(source.Type), source.SourceID, source.TargetID).
- WithID(source.ID).
- WithProp("confidence", source.Confidence)
- if source.Reason != "" {
- edge.WithProp("reason", source.Reason)
- }
- if source.Step != nil {
- edge.WithProp("step", *source.Step)
- }
+ edge := EdgeFromModel(s.repo, source)
if err := s.AddEdge(edge); err != nil {
return err
}
@@ -32,6 +21,31 @@ func (s *GraphStore) ImportKnowledgeGraph(src *ingestgraph.KnowledgeGraph) error
return nil
}
+// NodeFromModel converts one analyzer node to the durable graph schema. It is
+// shared by full import and incremental delta generation.
+func NodeFromModel(repo string, source *ingestgraph.GraphNode) *Node {
+ node := NewNode(repo, Label(source.Label), source.Properties.Name).
+ WithID(source.ID).
+ WithFile(source.Properties.FilePath)
+ copyNodeProperties(node, &source.Properties)
+ return node
+}
+
+// EdgeFromModel converts one analyzer relationship to its durable form.
+func EdgeFromModel(repo string, source *ingestgraph.GraphRelationship) *Edge {
+ edge := NewEdge(RelType(source.Type), source.SourceID, source.TargetID).
+ WithID(source.ID).
+ WithProp("confidence", source.Confidence)
+ edge.Repo = repo
+ if source.Reason != "" {
+ edge.WithProp("reason", source.Reason)
+ }
+ if source.Step != nil {
+ edge.WithProp("step", *source.Step)
+ }
+ return edge
+}
+
func copyNodeProperties(node *Node, source *ingestgraph.NodeProperties) {
props := map[string]any{
"language": source.Language,
diff --git a/internal/graph/mutation.go b/internal/graph/mutation.go
new file mode 100644
index 0000000..f5e8a36
--- /dev/null
+++ b/internal/graph/mutation.go
@@ -0,0 +1,311 @@
+package graph
+
+import (
+ "errors"
+ "fmt"
+ "strconv"
+
+ "github.com/cockroachdb/pebble/v2"
+ "github.com/mengshi02/codetrip/internal/util"
+)
+
+var ErrMutationRevisionConflict = errors.New("graph mutation revision conflict")
+
+// Mutation is one atomic graph revision. It is deliberately independent from
+// parsing: analyzers produce mutations, while GraphStore only enforces graph
+// indexes, adjacency, and revision consistency.
+type Mutation struct {
+ BaseRevision uint64
+ Revision uint64
+ PutNodes []*Node
+ DeleteNodes []string
+ PutEdges []*Edge
+ DeleteEdges []string
+}
+
+func graphRevisionKey(repo string) []byte { return []byte("revision:" + repo) }
+
+func (s *GraphStore) Revision() (uint64, error) {
+ encoded, err := s.store.Get(graphRevisionKey(s.repo))
+ if errors.Is(err, pebble.ErrNotFound) {
+ return 0, nil
+ }
+ if err != nil {
+ return 0, err
+ }
+ return strconv.ParseUint(string(encoded), 10, 64)
+}
+
+// InitializeRevision attaches a revision to a newly built full graph. It does
+// not alter graph records and is idempotent for the same revision. Incremental
+// mutations can therefore start from the exact full-snapshot baseline.
+func (s *GraphStore) InitializeRevision(revision uint64) error {
+ if revision == 0 {
+ return fmt.Errorf("graph revision must be positive")
+ }
+ active, err := s.Revision()
+ if err != nil {
+ return err
+ }
+ if active == revision {
+ return nil
+ }
+ if active != 0 {
+ return fmt.Errorf("%w: active=%d initialize=%d", ErrMutationRevisionConflict, active, revision)
+ }
+ return s.store.Set(graphRevisionKey(s.repo), []byte(strconv.FormatUint(revision, 10)))
+}
+
+// ApplyMutation changes only the supplied graph records and publishes the new
+// revision in the same durable batch. All affected adjacency lists are
+// rewritten once, so deleting several related nodes cannot lose updates
+// through repeated read-modify-write operations.
+func (s *GraphStore) ApplyMutation(mutation *Mutation) error {
+ if mutation == nil || mutation.Revision != mutation.BaseRevision+1 {
+ return fmt.Errorf("invalid graph mutation revision")
+ }
+ active, err := s.Revision()
+ if err != nil {
+ return err
+ }
+ if active != mutation.BaseRevision {
+ return fmt.Errorf("%w: active=%d base=%d", ErrMutationRevisionConflict, active, mutation.BaseRevision)
+ }
+ for _, node := range mutation.PutNodes {
+ if node == nil {
+ return fmt.Errorf("graph mutation contains nil node")
+ }
+ // A GraphStore is repository-scoped. Normalize records so their primary
+ // keys and secondary indexes can never land in different namespaces.
+ node.Repo = s.repo
+ if node.ID == "" {
+ node.ID = util.GenerateID(node.Repo, string(node.Label), node.Name)
+ }
+ }
+ for _, edge := range mutation.PutEdges {
+ if edge == nil {
+ return fmt.Errorf("graph mutation contains nil edge")
+ }
+ edge.Repo = s.repo
+ if edge.ID == "" {
+ edge.ID = util.GenerateEdgeID(s.repo, edge.Source, string(edge.Type), edge.Target)
+ }
+ }
+
+ deleteNodes := make(map[string]*Node, len(mutation.DeleteNodes))
+ deleteEdges := make(map[string]*Edge, len(mutation.DeleteEdges))
+ for _, id := range mutation.DeleteNodes {
+ node, getErr := s.GetNode(id)
+ if getErr != nil {
+ return fmt.Errorf("load deleted node %s: %w", id, getErr)
+ }
+ deleteNodes[id] = node
+ outgoing, getErr := s.GetAllOutEdges(id)
+ if getErr != nil {
+ return getErr
+ }
+ incoming, getErr := s.GetAllInEdges(id)
+ if getErr != nil {
+ ReleaseEdges(outgoing)
+ return getErr
+ }
+ for _, edge := range outgoing {
+ copy := *edge
+ copy.Repo = s.repo
+ deleteEdges[copy.ID] = ©
+ }
+ for _, edge := range incoming {
+ copy := *edge
+ copy.Repo = s.repo
+ deleteEdges[copy.ID] = ©
+ }
+ ReleaseEdges(outgoing)
+ ReleaseEdges(incoming)
+ }
+ for _, id := range mutation.DeleteEdges {
+ if _, exists := deleteEdges[id]; exists {
+ continue
+ }
+ edge, getErr := s.GetEdge(id)
+ if getErr != nil {
+ return fmt.Errorf("load deleted edge %s: %w", id, getErr)
+ }
+ deleteEdges[id] = edge
+ }
+
+ // Replacing an existing edge may also change its endpoints or type. Remove
+ // the old adjacency entry before installing the replacement.
+ for _, edge := range mutation.PutEdges {
+ if existing, getErr := s.GetEdge(edge.ID); getErr == nil {
+ deleteEdges[edge.ID] = existing
+ } else if !errors.Is(rootStoreError(getErr), pebble.ErrNotFound) {
+ return getErr
+ }
+ }
+
+ adjacency := make(map[string][]AdjEntry)
+ loadAdjacency := func(key string) ([]AdjEntry, error) {
+ if entries, exists := adjacency[key]; exists {
+ return entries, nil
+ }
+ encoded, getErr := s.store.Get([]byte(key))
+ if errors.Is(getErr, pebble.ErrNotFound) {
+ adjacency[key] = []AdjEntry{}
+ return adjacency[key], nil
+ }
+ if getErr != nil {
+ return nil, getErr
+ }
+ entries, decodeErr := DecodeAdjEntries(encoded)
+ if decodeErr != nil {
+ return nil, decodeErr
+ }
+ adjacency[key] = entries
+ return entries, nil
+ }
+ removeAdjacency := func(key, edgeID string) error {
+ entries, loadErr := loadAdjacency(key)
+ if loadErr != nil {
+ return loadErr
+ }
+ filtered := entries[:0]
+ for _, entry := range entries {
+ if entry.ID != edgeID {
+ filtered = append(filtered, entry)
+ }
+ }
+ adjacency[key] = filtered
+ return nil
+ }
+ for _, edge := range deleteEdges {
+ if err := removeAdjacency(adjKey(s.repo, edge.Source, "out", string(edge.Type)), edge.ID); err != nil {
+ return err
+ }
+ if err := removeAdjacency(adjKey(s.repo, edge.Target, "in", string(edge.Type)), edge.ID); err != nil {
+ return err
+ }
+ }
+ for _, edge := range mutation.PutEdges {
+ outKey := adjKey(s.repo, edge.Source, "out", string(edge.Type))
+ inKey := adjKey(s.repo, edge.Target, "in", string(edge.Type))
+ out, err := loadAdjacency(outKey)
+ if err != nil {
+ return err
+ }
+ in, err := loadAdjacency(inKey)
+ if err != nil {
+ return err
+ }
+ adjacency[outKey] = append(out, AdjEntry{ID: edge.ID, Target: edge.Target, Props: edge.Props})
+ adjacency[inKey] = append(in, AdjEntry{ID: edge.ID, Target: edge.Source, Props: edge.Props})
+ }
+
+ oldPutNodes := make(map[string]*Node)
+ for _, node := range mutation.PutNodes {
+ if old, getErr := s.GetNode(node.ID); getErr == nil {
+ oldPutNodes[node.ID] = old
+ } else if !errors.Is(rootStoreError(getErr), pebble.ErrNotFound) {
+ return getErr
+ }
+ }
+
+ if err := s.store.Batch(func(batch *pebble.Batch) error {
+ for id := range deleteEdges {
+ if err := batch.Delete([]byte(edgeKey(s.repo, id)), nil); err != nil {
+ return err
+ }
+ }
+ for id, node := range deleteNodes {
+ for _, key := range []string{
+ nodeKey(s.repo, id), typeKey(s.repo, string(node.Label), id), nameKey(s.repo, node.Name, id),
+ } {
+ if err := batch.Delete([]byte(key), nil); err != nil {
+ return err
+ }
+ }
+ if node.FilePath != "" {
+ if err := batch.Delete([]byte(fileKey(s.repo, node.FilePath, id)), nil); err != nil {
+ return err
+ }
+ }
+ }
+ for _, node := range mutation.PutNodes {
+ if old := oldPutNodes[node.ID]; old != nil {
+ for _, key := range []string{typeKey(s.repo, string(old.Label), old.ID), nameKey(s.repo, old.Name, old.ID)} {
+ if err := batch.Delete([]byte(key), nil); err != nil {
+ return err
+ }
+ }
+ if old.FilePath != "" {
+ if err := batch.Delete([]byte(fileKey(s.repo, old.FilePath, old.ID)), nil); err != nil {
+ return err
+ }
+ }
+ }
+ encoded, encodeErr := Encode(node)
+ if encodeErr != nil {
+ return encodeErr
+ }
+ if err := batch.Set([]byte(node.Key()), encoded, nil); err != nil {
+ return err
+ }
+ if err := batch.Set([]byte(typeKey(s.repo, string(node.Label), node.ID)), []byte(node.ID), nil); err != nil {
+ return err
+ }
+ if err := batch.Set([]byte(nameKey(s.repo, node.Name, node.ID)), []byte(node.ID), nil); err != nil {
+ return err
+ }
+ if node.FilePath != "" {
+ if err := batch.Set([]byte(fileKey(s.repo, node.FilePath, node.ID)), []byte(node.ID), nil); err != nil {
+ return err
+ }
+ }
+ }
+ for _, edge := range mutation.PutEdges {
+ encoded, encodeErr := Encode(edge)
+ if encodeErr != nil {
+ return encodeErr
+ }
+ if err := batch.Set([]byte(edge.Key()), encoded, nil); err != nil {
+ return err
+ }
+ }
+ for key, entries := range adjacency {
+ if len(entries) == 0 {
+ if err := batch.Delete([]byte(key), nil); err != nil {
+ return err
+ }
+ continue
+ }
+ encoded, encodeErr := EncodeAdjEntries(entries)
+ if encodeErr != nil {
+ return encodeErr
+ }
+ if err := batch.Set([]byte(key), encoded, nil); err != nil {
+ return err
+ }
+ }
+ return batch.Set(graphRevisionKey(s.repo), []byte(strconv.FormatUint(mutation.Revision, 10)), nil)
+ }); err != nil {
+ return err
+ }
+ for id := range deleteNodes {
+ s.nodeCache.Remove(id)
+ }
+ for _, node := range mutation.PutNodes {
+ s.nodeCache.Add(node.ID, node)
+ }
+ return nil
+}
+
+// GraphStore wraps not-found errors with operation context. Walk the error
+// chain before deciding whether a mutation is inserting a new record.
+func rootStoreError(err error) error {
+ for err != nil {
+ if errors.Is(err, pebble.ErrNotFound) {
+ return pebble.ErrNotFound
+ }
+ err = errors.Unwrap(err)
+ }
+ return nil
+}
diff --git a/internal/graph/mutation_test.go b/internal/graph/mutation_test.go
new file mode 100644
index 0000000..ee9401e
--- /dev/null
+++ b/internal/graph/mutation_test.go
@@ -0,0 +1,80 @@
+package graph
+
+import (
+ "errors"
+ "testing"
+)
+
+func TestApplyMutationAtomicallyMaintainsGraphIndexes(t *testing.T) {
+ graphStore := openTestGS(t)
+ a := NewNode("repo", LabelFunction, "A").WithID("A").WithFile("a.go")
+ b := NewNode("repo", LabelFunction, "B").WithID("B").WithFile("b.go")
+ c := NewNode("repo", LabelFunction, "C").WithID("C").WithFile("c.go")
+ if err := graphStore.Batch(func(batch *Batch) error {
+ for _, node := range []*Node{a, b, c} {
+ if err := batch.AddNode(node); err != nil {
+ return err
+ }
+ }
+ return batch.AddEdge(NewEdge(RelCalls, "A", "B").WithID("A-B"))
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ d := NewNode("repo", LabelFunction, "D").WithID("D").WithFile("d.go")
+ if err := graphStore.ApplyMutation(&Mutation{
+ BaseRevision: 0, Revision: 1,
+ DeleteNodes: []string{"B"}, PutNodes: []*Node{d},
+ PutEdges: []*Edge{NewEdge(RelCalls, "A", "D").WithID("A-D")},
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if revision, err := graphStore.Revision(); err != nil || revision != 1 {
+ t.Fatalf("revision=%d error=%v", revision, err)
+ }
+ if _, err := graphStore.GetNode("B"); err == nil {
+ t.Fatal("deleted node remains")
+ }
+ if edges, err := graphStore.GetOutEdges("A", "CALLS"); err != nil || len(edges) != 1 || edges[0].Target != "D" {
+ t.Fatalf("outgoing=%#v error=%v", edges, err)
+ }
+ if nodes, err := graphStore.GetNodesByFile("testrepo", "b.go"); err != nil || len(nodes) != 0 {
+ t.Fatalf("deleted file index=%#v error=%v", nodes, err)
+ }
+ if nodes, err := graphStore.GetNodesByName("testrepo", "D"); err != nil || len(nodes) != 1 {
+ t.Fatalf("new name index=%#v error=%v", nodes, err)
+ }
+}
+
+func TestApplyMutationRejectsStaleRevisionWithoutChanges(t *testing.T) {
+ graphStore := openTestGS(t)
+ if err := graphStore.ApplyMutation(&Mutation{BaseRevision: 0, Revision: 1}); err != nil {
+ t.Fatal(err)
+ }
+ err := graphStore.ApplyMutation(&Mutation{
+ BaseRevision: 0, Revision: 1,
+ PutNodes: []*Node{NewNode("repo", LabelFunction, "Late").WithID("late")},
+ })
+ if !errors.Is(err, ErrMutationRevisionConflict) {
+ t.Fatalf("error=%v", err)
+ }
+ if _, err := graphStore.GetNode("late"); err == nil {
+ t.Fatal("stale mutation changed graph")
+ }
+}
+
+func TestInitializeRevisionIsIdempotentAndNeverRewinds(t *testing.T) {
+ graphStore := openTestGS(t)
+ if err := graphStore.InitializeRevision(4); err != nil {
+ t.Fatal(err)
+ }
+ if err := graphStore.InitializeRevision(4); err != nil {
+ t.Fatal(err)
+ }
+ if err := graphStore.InitializeRevision(3); !errors.Is(err, ErrMutationRevisionConflict) {
+ t.Fatalf("error=%v", err)
+ }
+ if revision, err := graphStore.Revision(); err != nil || revision != 4 {
+ t.Fatalf("revision=%d error=%v", revision, err)
+ }
+}
diff --git a/internal/graph/props.go b/internal/graph/props.go
index c9eee3d..84ccdca 100644
--- a/internal/graph/props.go
+++ b/internal/graph/props.go
@@ -888,8 +888,24 @@ func toInt(v any) int {
switch val := v.(type) {
case int:
return val
+ case int8:
+ return int(val)
+ case int16:
+ return int(val)
+ case int32:
+ return int(val)
case int64:
return int(val)
+ case uint:
+ return int(val)
+ case uint8:
+ return int(val)
+ case uint16:
+ return int(val)
+ case uint32:
+ return int(val)
+ case uint64:
+ return int(val)
case float64:
return int(val)
case json.Number:
diff --git a/internal/incremental/changeset.go b/internal/incremental/changeset.go
new file mode 100644
index 0000000..c8714b9
--- /dev/null
+++ b/internal/incremental/changeset.go
@@ -0,0 +1,127 @@
+package incremental
+
+import "sort"
+
+// Revision identifies one coherent repository state. Zero means that no
+// incremental revision has been published yet.
+type Revision uint64
+
+// FileState is the language-neutral state needed to detect and plan changes.
+// ContentHash changes for every byte change. StructureHash changes only when
+// declarations, imports, exports, or other analyzer-defined structure changes.
+type FileState struct {
+ Path string `json:"path"`
+ ContentHash string `json:"contentHash"`
+ StructureHash string `json:"structureHash,omitempty"`
+ CallHash string `json:"callHash,omitempty"`
+ Size int64 `json:"size"`
+ Language string `json:"language,omitempty"`
+ Unit string `json:"unit,omitempty"`
+}
+
+// SnapshotState describes the files covered by one active revision.
+type SnapshotState struct {
+ Revision Revision `json:"revision"`
+ Files map[string]FileState `json:"files"`
+}
+
+type Modification struct {
+ Before FileState `json:"before"`
+ After FileState `json:"after"`
+}
+
+type Rename struct {
+ Before FileState `json:"before"`
+ After FileState `json:"after"`
+}
+
+// ChangeSet is the common input to every language analyzer and storage
+// backend. Renames are detected only when the content identity is unambiguous.
+type ChangeSet struct {
+ BaseRevision Revision `json:"baseRevision"`
+ Added []FileState `json:"added,omitempty"`
+ Modified []Modification `json:"modified,omitempty"`
+ Deleted []FileState `json:"deleted,omitempty"`
+ Renamed []Rename `json:"renamed,omitempty"`
+}
+
+func (changes ChangeSet) Empty() bool {
+ return len(changes.Added) == 0 && len(changes.Modified) == 0 &&
+ len(changes.Deleted) == 0 && len(changes.Renamed) == 0
+}
+
+// Diff computes a deterministic file change set. A delete/add pair is a rename
+// only when exactly one old and one new path share the same non-empty content
+// hash; ambiguous copies remain explicit additions and deletions.
+func Diff(previous, current SnapshotState) ChangeSet {
+ changes := ChangeSet{BaseRevision: previous.Revision}
+ for path, after := range current.Files {
+ before, exists := previous.Files[path]
+ switch {
+ case !exists:
+ changes.Added = append(changes.Added, after)
+ case before.ContentHash != after.ContentHash || before.Size != after.Size ||
+ before.StructureHash != after.StructureHash || before.Language != after.Language || before.Unit != after.Unit:
+ changes.Modified = append(changes.Modified, Modification{Before: before, After: after})
+ }
+ }
+ for path, before := range previous.Files {
+ if _, exists := current.Files[path]; !exists {
+ changes.Deleted = append(changes.Deleted, before)
+ }
+ }
+ detectRenames(&changes)
+ sortChangeSet(&changes)
+ return changes
+}
+
+func detectRenames(changes *ChangeSet) {
+ addedByHash := make(map[string][]int)
+ deletedByHash := make(map[string][]int)
+ for index, file := range changes.Added {
+ if file.ContentHash != "" {
+ addedByHash[file.ContentHash] = append(addedByHash[file.ContentHash], index)
+ }
+ }
+ for index, file := range changes.Deleted {
+ if file.ContentHash != "" {
+ deletedByHash[file.ContentHash] = append(deletedByHash[file.ContentHash], index)
+ }
+ }
+ usedAdded, usedDeleted := make(map[int]bool), make(map[int]bool)
+ for hash, added := range addedByHash {
+ deleted := deletedByHash[hash]
+ if len(added) != 1 || len(deleted) != 1 {
+ continue
+ }
+ usedAdded[added[0]], usedDeleted[deleted[0]] = true, true
+ changes.Renamed = append(changes.Renamed, Rename{
+ Before: changes.Deleted[deleted[0]], After: changes.Added[added[0]],
+ })
+ }
+ added := changes.Added[:0]
+ for index, file := range changes.Added {
+ if !usedAdded[index] {
+ added = append(added, file)
+ }
+ }
+ deleted := changes.Deleted[:0]
+ for index, file := range changes.Deleted {
+ if !usedDeleted[index] {
+ deleted = append(deleted, file)
+ }
+ }
+ changes.Added, changes.Deleted = added, deleted
+}
+
+func sortChangeSet(changes *ChangeSet) {
+ sort.Slice(changes.Added, func(i, j int) bool { return changes.Added[i].Path < changes.Added[j].Path })
+ sort.Slice(changes.Deleted, func(i, j int) bool { return changes.Deleted[i].Path < changes.Deleted[j].Path })
+ sort.Slice(changes.Modified, func(i, j int) bool { return changes.Modified[i].After.Path < changes.Modified[j].After.Path })
+ sort.Slice(changes.Renamed, func(i, j int) bool {
+ if changes.Renamed[i].Before.Path == changes.Renamed[j].Before.Path {
+ return changes.Renamed[i].After.Path < changes.Renamed[j].After.Path
+ }
+ return changes.Renamed[i].Before.Path < changes.Renamed[j].Before.Path
+ })
+}
diff --git a/internal/incremental/changeset_test.go b/internal/incremental/changeset_test.go
new file mode 100644
index 0000000..6001623
--- /dev/null
+++ b/internal/incremental/changeset_test.go
@@ -0,0 +1,76 @@
+package incremental
+
+import (
+ "reflect"
+ "testing"
+)
+
+func TestDiffClassifiesAddModifyDeleteAndRename(t *testing.T) {
+ previous := SnapshotState{Revision: 7, Files: map[string]FileState{
+ "body.ts": {Path: "body.ts", ContentHash: "old", StructureHash: "shape", Unit: "app"},
+ "deleted.py": {Path: "deleted.py", ContentHash: "gone", Unit: "pkg"},
+ "old.go": {Path: "old.go", ContentHash: "same", Unit: "oldpkg"},
+ }}
+ current := SnapshotState{Files: map[string]FileState{
+ "body.ts": {Path: "body.ts", ContentHash: "new", StructureHash: "shape", Unit: "app"},
+ "added.rs": {Path: "added.rs", ContentHash: "added", Unit: "crate"},
+ "new.go": {Path: "new.go", ContentHash: "same", Unit: "newpkg"},
+ }}
+ changes := Diff(previous, current)
+ if changes.BaseRevision != 7 || len(changes.Added) != 1 || changes.Added[0].Path != "added.rs" {
+ t.Fatalf("added/base=%#v", changes)
+ }
+ if len(changes.Modified) != 1 || changes.Modified[0].After.Path != "body.ts" {
+ t.Fatalf("modified=%#v", changes.Modified)
+ }
+ if len(changes.Deleted) != 1 || changes.Deleted[0].Path != "deleted.py" {
+ t.Fatalf("deleted=%#v", changes.Deleted)
+ }
+ if len(changes.Renamed) != 1 || changes.Renamed[0].Before.Path != "old.go" || changes.Renamed[0].After.Path != "new.go" {
+ t.Fatalf("renamed=%#v", changes.Renamed)
+ }
+}
+
+func TestDiffDoesNotGuessAmbiguousRename(t *testing.T) {
+ previous := SnapshotState{Files: map[string]FileState{
+ "a.ts": {Path: "a.ts", ContentHash: "same"},
+ "b.ts": {Path: "b.ts", ContentHash: "same"},
+ }}
+ current := SnapshotState{Files: map[string]FileState{
+ "c.ts": {Path: "c.ts", ContentHash: "same"},
+ }}
+ changes := Diff(previous, current)
+ if len(changes.Renamed) != 0 || len(changes.Added) != 1 || len(changes.Deleted) != 2 {
+ t.Fatalf("ambiguous rename=%#v", changes)
+ }
+}
+
+type dependencyFixture map[Unit][]Unit
+
+func (fixture dependencyFixture) DirectDependents(unit Unit) []Unit { return fixture[unit] }
+
+func TestPlanKeepsBodyLocalAndExpandsStructuralChange(t *testing.T) {
+ body := ChangeSet{BaseRevision: 3, Modified: []Modification{{
+ Before: FileState{Path: "a.ts", Unit: "a", StructureHash: "same"},
+ After: FileState{Path: "a.ts", Unit: "a", StructureHash: "same", ContentHash: "new"},
+ }}}
+ if got := Plan(body, dependencyFixture{"a": {"b"}}); !reflect.DeepEqual(got.Units, []InvalidatedUnit{{Unit: "a", Reason: ReasonBodyChanged}}) {
+ t.Fatalf("body plan=%#v", got)
+ }
+ calls := body
+ calls.Modified[0].After.CallHash = "changed"
+ if got := Plan(calls, nil); !reflect.DeepEqual(got.Units, []InvalidatedUnit{{Unit: "a", Reason: ReasonCallsChanged}}) {
+ t.Fatalf("calls plan=%#v", got)
+ }
+
+ structural := body
+ structural.Modified[0].After.StructureHash = "changed"
+ got := Plan(structural, dependencyFixture{"a": {"b"}, "b": {"c"}})
+ want := []InvalidatedUnit{
+ {Unit: "a", Reason: ReasonStructureChanged},
+ {Unit: "b", Reason: ReasonDependent},
+ }
+ if !reflect.DeepEqual(got.Units, want) {
+ t.Fatalf("structural plan=%#v want=%#v", got, want)
+ }
+}
diff --git a/internal/incremental/components.go b/internal/incremental/components.go
new file mode 100644
index 0000000..aa71c7b
--- /dev/null
+++ b/internal/incremental/components.go
@@ -0,0 +1,141 @@
+package incremental
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/mengshi02/codetrip/internal/graph"
+ "github.com/mengshi02/codetrip/internal/search/semantic"
+ "github.com/mengshi02/codetrip/internal/search/source"
+ "github.com/mengshi02/codetrip/internal/search/symbol"
+)
+
+// GraphAuthority publishes the graph mutation and authoritative repository
+// revision in one durable graph batch.
+type GraphAuthority struct {
+ graph *graph.GraphStore
+}
+
+type VectorComponent struct {
+ index *semantic.VectorSearch
+}
+
+func NewVectorComponent(index *semantic.VectorSearch) *VectorComponent {
+ return &VectorComponent{index: index}
+}
+
+func (*VectorComponent) Name() string { return "vectors" }
+
+func (component *VectorComponent) Apply(_ context.Context, delta *Delta) error {
+ puts := make([]semantic.VectorChange, 0, len(delta.PutVectors))
+ for _, item := range delta.PutVectors {
+ puts = append(puts, semantic.VectorChange{NodeID: item.NodeID, Modality: item.Modality, Chunk: item.Chunk, Payload: item.Payload})
+ }
+ deletes := make([]semantic.VectorDelete, 0, len(delta.DeleteVectors))
+ for _, item := range delta.DeleteVectors {
+ deletes = append(deletes, semantic.VectorDelete{NodeID: item.NodeID, Modality: item.Modality, Chunk: item.Chunk})
+ }
+ return component.index.ApplyDelta(puts, deletes)
+}
+
+func NewGraphAuthority(graphStore *graph.GraphStore) *GraphAuthority {
+ return &GraphAuthority{graph: graphStore}
+}
+
+func (authority *GraphAuthority) ActiveRevision(context.Context) (Revision, error) {
+ revision, err := authority.graph.Revision()
+ return Revision(revision), err
+}
+
+func (authority *GraphAuthority) Publish(_ context.Context, delta *Delta) error {
+ nodes := make([]*graph.Node, 0, len(delta.PutNodes))
+ for _, item := range delta.PutNodes {
+ var node graph.Node
+ if err := graph.Decode(item.Payload, &node); err != nil {
+ return fmt.Errorf("decode graph node %s: %w", item.ID, err)
+ }
+ if node.ID == "" {
+ node.ID = item.ID
+ }
+ if node.ID != item.ID {
+ return fmt.Errorf("graph node delta ID mismatch: key=%s payload=%s", item.ID, node.ID)
+ }
+ nodes = append(nodes, &node)
+ }
+ edges := make([]*graph.Edge, 0, len(delta.PutEdges))
+ for _, item := range delta.PutEdges {
+ var edge graph.Edge
+ if err := graph.Decode(item.Payload, &edge); err != nil {
+ return fmt.Errorf("decode graph edge %s: %w", item.ID, err)
+ }
+ if edge.ID == "" {
+ edge.ID = item.ID
+ }
+ if edge.ID != item.ID {
+ return fmt.Errorf("graph edge delta ID mismatch: key=%s payload=%s", item.ID, edge.ID)
+ }
+ edges = append(edges, &edge)
+ }
+ return authority.graph.ApplyMutation(&graph.Mutation{
+ BaseRevision: uint64(delta.BaseRevision), Revision: uint64(delta.Revision),
+ PutNodes: nodes, DeleteNodes: delta.DeleteNodes,
+ PutEdges: edges, DeleteEdges: delta.DeleteEdges,
+ })
+}
+
+type SymbolComponent struct {
+ index *symbol.LexicalIndex
+}
+
+func NewSymbolComponent(index *symbol.LexicalIndex) *SymbolComponent {
+ return &SymbolComponent{index: index}
+}
+
+func (*SymbolComponent) Name() string { return "symbols" }
+
+func (component *SymbolComponent) Apply(_ context.Context, delta *Delta) error {
+ nodes := make([]*graph.Node, 0, len(delta.PutSymbols))
+ for id, payload := range delta.PutSymbols {
+ var node graph.Node
+ if err := graph.Decode(payload, &node); err != nil {
+ return fmt.Errorf("decode symbol %s: %w", id, err)
+ }
+ if node.ID == "" {
+ node.ID = id
+ }
+ if node.ID != id {
+ return fmt.Errorf("symbol delta ID mismatch: key=%s payload=%s", id, node.ID)
+ }
+ nodes = append(nodes, &node)
+ }
+ return component.index.ApplyDelta(nodes, delta.DeleteSymbols)
+}
+
+type SourceComponent struct {
+ index *source.Index
+}
+
+func NewSourceComponent(index *source.Index) *SourceComponent {
+ return &SourceComponent{index: index}
+}
+
+func (*SourceComponent) Name() string { return "source" }
+
+func (component *SourceComponent) Apply(_ context.Context, delta *Delta) error {
+ return component.index.ApplyRevision(uint64(delta.Revision), delta.PutSources, delta.DeleteSources)
+}
+
+// ComponentFunc adapts revision-aware derived stores such as vectors without
+// coupling the incremental core to their concrete implementation.
+type ComponentFunc struct {
+ ComponentName string
+ ApplyDelta func(context.Context, *Delta) error
+}
+
+func (component ComponentFunc) Name() string { return component.ComponentName }
+func (component ComponentFunc) Apply(ctx context.Context, delta *Delta) error {
+ if component.ApplyDelta == nil {
+ return nil
+ }
+ return component.ApplyDelta(ctx, delta)
+}
diff --git a/internal/incremental/components_test.go b/internal/incremental/components_test.go
new file mode 100644
index 0000000..28ad2e9
--- /dev/null
+++ b/internal/incremental/components_test.go
@@ -0,0 +1,72 @@
+package incremental
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/mengshi02/codetrip/internal/graph"
+ "github.com/mengshi02/codetrip/internal/search/source"
+ "github.com/mengshi02/codetrip/internal/search/symbol"
+ "github.com/mengshi02/codetrip/internal/store"
+)
+
+func TestRecoverableCommitterPublishesGraphSymbolAndSourceRevision(t *testing.T) {
+ ctx := context.Background()
+ dataDir := t.TempDir()
+ repository := t.TempDir()
+ if err := os.WriteFile(filepath.Join(repository, "main.go"), []byte("package main\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ database, err := store.Open(store.DefaultConfig(filepath.Join(dataDir, "db")))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer database.Close()
+ graphStore := graph.NewGraphStore(database, "snapshot")
+ lexical, err := symbol.NewLexicalIndexWithDir(dataDir, "snapshot", database)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer lexical.Close()
+ content := source.New(dataDir, "snapshot")
+ if err := content.Build(repository, "snapshot"); err != nil {
+ t.Fatal(err)
+ }
+ defer content.Close()
+
+ node := graph.NewNode("snapshot", graph.LabelFunction, "IncrementalSymbol").WithID("symbol-1").WithFile("main.go")
+ payload, err := graph.Encode(node)
+ if err != nil {
+ t.Fatal(err)
+ }
+ delta := &Delta{
+ BaseRevision: 0, Revision: 1,
+ PutNodes: []NodeDelta{{ID: node.ID, Payload: payload}},
+ PutSymbols: map[string][]byte{node.ID: payload},
+ PutSources: map[string][]byte{"main.go": []byte("package main\nfunc IncrementalSymbol() {}\n")},
+ }
+ journal := NewStoreJournal(database)
+ committer := NewRecoverableCommitter(
+ journal, NewGraphAuthority(graphStore), NewSymbolComponent(lexical), NewSourceComponent(content),
+ )
+ if err := NewCoordinator(committer).Apply(ctx, delta); err != nil {
+ t.Fatal(err)
+ }
+ if revision, err := graphStore.Revision(); err != nil || revision != 1 {
+ t.Fatalf("revision=%d error=%v", revision, err)
+ }
+ if stored, err := graphStore.GetNode(node.ID); err != nil || stored.Name != node.Name {
+ t.Fatalf("node=%#v error=%v", stored, err)
+ }
+ if matches, err := lexical.Search("IncrementalSymbol", 10); err != nil || len(matches) == 0 || matches[0].NodeID != node.ID {
+ t.Fatalf("symbols=%#v error=%v", matches, err)
+ }
+ if matches, err := content.Search(ctx, "IncrementalSymbol", source.ScopeCode, 10, 0); err != nil || len(matches) != 1 || matches[0].FilePath != "main.go" {
+ t.Fatalf("source=%#v error=%v", matches, err)
+ }
+ if pending, err := journal.Load(ctx); err != nil || pending != nil {
+ t.Fatalf("pending=%#v error=%v", pending, err)
+ }
+}
diff --git a/internal/incremental/coordinator.go b/internal/incremental/coordinator.go
new file mode 100644
index 0000000..7b2bfe9
--- /dev/null
+++ b/internal/incremental/coordinator.go
@@ -0,0 +1,62 @@
+package incremental
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "sync"
+)
+
+var (
+ ErrRevisionConflict = errors.New("incremental revision conflict")
+ ErrInvalidDelta = errors.New("invalid incremental delta")
+)
+
+// PreparedCommit owns staged graph and derived-index artifacts. Commit must
+// make the new revision visible atomically; Abort removes unpublished data.
+type PreparedCommit interface {
+ Commit(context.Context) error
+ Abort() error
+}
+
+// Committer stages one complete cross-index Delta. Implementations may write
+// immutable search segments before Commit, but those segments must remain
+// invisible until the authoritative revision is atomically advanced.
+type Committer interface {
+ ActiveRevision(context.Context) (Revision, error)
+ Prepare(context.Context, *Delta) (PreparedCommit, error)
+}
+
+// Coordinator serializes revision publication without knowing any language,
+// graph schema, or storage implementation.
+type Coordinator struct {
+ mu sync.Mutex
+ committer Committer
+}
+
+func NewCoordinator(committer Committer) *Coordinator {
+ return &Coordinator{committer: committer}
+}
+
+func (coordinator *Coordinator) Apply(ctx context.Context, delta *Delta) error {
+ if delta == nil || delta.Revision != delta.BaseRevision+1 {
+ return ErrInvalidDelta
+ }
+ coordinator.mu.Lock()
+ defer coordinator.mu.Unlock()
+ active, err := coordinator.committer.ActiveRevision(ctx)
+ if err != nil {
+ return err
+ }
+ if active != delta.BaseRevision {
+ return fmt.Errorf("%w: active=%d base=%d", ErrRevisionConflict, active, delta.BaseRevision)
+ }
+ prepared, err := coordinator.committer.Prepare(ctx, delta)
+ if err != nil {
+ return err
+ }
+ if err := prepared.Commit(ctx); err != nil {
+ return errors.Join(err, prepared.Abort())
+ }
+ return nil
+}
diff --git a/internal/incremental/coordinator_test.go b/internal/incremental/coordinator_test.go
new file mode 100644
index 0000000..ce0c23d
--- /dev/null
+++ b/internal/incremental/coordinator_test.go
@@ -0,0 +1,105 @@
+package incremental
+
+import (
+ "context"
+ "errors"
+ "sync"
+ "testing"
+ "time"
+)
+
+type commitFixture struct {
+ mu sync.Mutex
+ revision Revision
+ fail error
+ aborted bool
+}
+
+func (fixture *commitFixture) ActiveRevision(context.Context) (Revision, error) {
+ fixture.mu.Lock()
+ defer fixture.mu.Unlock()
+ return fixture.revision, nil
+}
+
+func (fixture *commitFixture) Prepare(_ context.Context, delta *Delta) (PreparedCommit, error) {
+ return &preparedFixture{parent: fixture, delta: delta}, nil
+}
+
+type preparedFixture struct {
+ parent *commitFixture
+ delta *Delta
+}
+
+func (prepared *preparedFixture) Commit(context.Context) error {
+ prepared.parent.mu.Lock()
+ defer prepared.parent.mu.Unlock()
+ if prepared.parent.fail != nil {
+ return prepared.parent.fail
+ }
+ prepared.parent.revision = prepared.delta.Revision
+ return nil
+}
+
+func (prepared *preparedFixture) Abort() error {
+ prepared.parent.mu.Lock()
+ defer prepared.parent.mu.Unlock()
+ prepared.parent.aborted = true
+ return nil
+}
+
+func TestCoordinatorPublishesOnlyNextRevision(t *testing.T) {
+ fixture := &commitFixture{revision: 4}
+ coordinator := NewCoordinator(fixture)
+ if err := coordinator.Apply(context.Background(), &Delta{BaseRevision: 4, Revision: 5}); err != nil {
+ t.Fatal(err)
+ }
+ if fixture.revision != 5 {
+ t.Fatalf("revision=%d", fixture.revision)
+ }
+ if err := coordinator.Apply(context.Background(), &Delta{BaseRevision: 4, Revision: 5}); !errors.Is(err, ErrRevisionConflict) {
+ t.Fatalf("conflict error=%v", err)
+ }
+}
+
+func TestCoordinatorAbortsFailedCommit(t *testing.T) {
+ fixture := &commitFixture{revision: 1, fail: errors.New("commit failed")}
+ err := NewCoordinator(fixture).Apply(context.Background(), &Delta{BaseRevision: 1, Revision: 2})
+ if err == nil || !fixture.aborted || fixture.revision != 1 {
+ t.Fatalf("error=%v aborted=%v revision=%d", err, fixture.aborted, fixture.revision)
+ }
+}
+
+func TestFreshnessTrackerBlocksUntilObservedGenerationApplied(t *testing.T) {
+ tracker := NewFreshnessTracker(8)
+ target := tracker.Observe()
+ finished := make(chan Freshness, 1)
+ go func() {
+ state, _ := tracker.Wait(context.Background(), target)
+ finished <- state
+ }()
+ select {
+ case <-finished:
+ t.Fatal("query passed freshness barrier before refresh")
+ case <-time.After(30 * time.Millisecond):
+ }
+ tracker.Applied(target, 9)
+ select {
+ case state := <-finished:
+ if state.Revision != 9 || state.Applied != target {
+ t.Fatalf("freshness=%#v", state)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("query did not pass freshness barrier")
+ }
+}
+
+func TestFreshnessTrackerReturnsRefreshFailureInsteadOfStaleData(t *testing.T) {
+ tracker := NewFreshnessTracker(2)
+ target := tracker.Observe()
+ sentinel := errors.New("analysis failed")
+ tracker.Failed(target, sentinel)
+ state, err := tracker.Wait(context.Background(), target)
+ if !errors.Is(err, sentinel) || state.Revision != 2 {
+ t.Fatalf("state=%#v error=%v", state, err)
+ }
+}
diff --git a/internal/incremental/delta.go b/internal/incremental/delta.go
new file mode 100644
index 0000000..489b2f6
--- /dev/null
+++ b/internal/incremental/delta.go
@@ -0,0 +1,56 @@
+package incremental
+
+// Node and Edge deltas use stable IDs and opaque encoded payloads so the
+// coordinator remains independent of graph schema and storage technology.
+type NodeDelta struct {
+ ID string `json:"id"`
+ Payload []byte `json:"payload,omitempty"`
+}
+
+type EdgeDelta struct {
+ ID string `json:"id"`
+ Payload []byte `json:"payload,omitempty"`
+}
+
+// VectorDelta identifies one embedding without exposing storage keys. Chunk is
+// -1 for the node-level vector used by ANN search and >=0 for a code chunk.
+type VectorDelta struct {
+ NodeID string `json:"nodeId"`
+ Modality string `json:"modality"`
+ Chunk int `json:"chunk"`
+ Payload []byte `json:"payload,omitempty"`
+}
+
+type VectorRef struct {
+ NodeID string `json:"nodeId"`
+ Modality string `json:"modality"`
+ Chunk int `json:"chunk"`
+}
+
+// Delta is the complete atomic effect of rebuilding invalidated units.
+// Search/vector documents use stable IDs and are staged before publication.
+type Delta struct {
+ BaseRevision Revision `json:"baseRevision"`
+ Revision Revision `json:"revision"`
+
+ PutNodes []NodeDelta `json:"putNodes,omitempty"`
+ DeleteNodes []string `json:"deleteNodes,omitempty"`
+ PutEdges []EdgeDelta `json:"putEdges,omitempty"`
+ DeleteEdges []string `json:"deleteEdges,omitempty"`
+
+ PutSymbols map[string][]byte `json:"putSymbols,omitempty"`
+ DeleteSymbols []string `json:"deleteSymbols,omitempty"`
+ PutSources map[string][]byte `json:"putSources,omitempty"`
+ DeleteSources []string `json:"deleteSources,omitempty"`
+ PutVectors []VectorDelta `json:"putVectors,omitempty"`
+ DeleteVectors []VectorRef `json:"deleteVectors,omitempty"`
+ Metadata map[string][]byte `json:"metadata,omitempty"`
+}
+
+// Analyzer is implemented by every production language provider. The core
+// engine never branches on a language name.
+type Analyzer interface {
+ Name() string
+ State(path string, content []byte) (FileState, error)
+ Analyze(changes ChangeSet, plan InvalidationPlan) (*Delta, error)
+}
diff --git a/internal/incremental/freshness.go b/internal/incremental/freshness.go
new file mode 100644
index 0000000..3a40a38
--- /dev/null
+++ b/internal/incremental/freshness.go
@@ -0,0 +1,109 @@
+package incremental
+
+import (
+ "context"
+ "fmt"
+ "sync"
+)
+
+// Generation identifies observed filesystem state. It is separate from the
+// durable Revision because several filesystem events may be debounced into one
+// committed revision.
+type Generation uint64
+
+type Freshness struct {
+ Observed Generation `json:"observed"`
+ Applied Generation `json:"applied"`
+ Revision Revision `json:"revision"`
+ Err error `json:"-"`
+}
+
+type RefreshError struct {
+ Generation Generation
+ Cause error
+}
+
+func (err *RefreshError) Error() string {
+ return fmt.Sprintf("repository refresh generation %d failed: %v", err.Generation, err.Cause)
+}
+
+func (err *RefreshError) Unwrap() error { return err.Cause }
+
+// FreshnessTracker is the query barrier shared by watch and query paths. A
+// query captures Target and waits until that generation is either committed or
+// explicitly failed; it never silently proceeds with an older revision.
+type FreshnessTracker struct {
+ mu sync.Mutex
+ state Freshness
+ changed chan struct{}
+}
+
+func NewFreshnessTracker(revision Revision) *FreshnessTracker {
+ return &FreshnessTracker{state: Freshness{Revision: revision}, changed: make(chan struct{})}
+}
+
+func (tracker *FreshnessTracker) Observe() Generation {
+ tracker.mu.Lock()
+ defer tracker.mu.Unlock()
+ tracker.state.Observed++
+ tracker.state.Err = nil
+ tracker.notifyLocked()
+ return tracker.state.Observed
+}
+
+func (tracker *FreshnessTracker) Target() Generation {
+ tracker.mu.Lock()
+ defer tracker.mu.Unlock()
+ return tracker.state.Observed
+}
+
+func (tracker *FreshnessTracker) Applied(generation Generation, revision Revision) {
+ tracker.mu.Lock()
+ defer tracker.mu.Unlock()
+ if generation > tracker.state.Applied {
+ tracker.state.Applied = generation
+ tracker.state.Revision = revision
+ }
+ tracker.state.Err = nil
+ tracker.notifyLocked()
+}
+
+func (tracker *FreshnessTracker) Failed(generation Generation, err error) {
+ tracker.mu.Lock()
+ defer tracker.mu.Unlock()
+ if generation >= tracker.state.Applied {
+ tracker.state.Err = &RefreshError{Generation: generation, Cause: err}
+ }
+ tracker.notifyLocked()
+}
+
+func (tracker *FreshnessTracker) Wait(ctx context.Context, target Generation) (Freshness, error) {
+ for {
+ tracker.mu.Lock()
+ state := tracker.state
+ changed := tracker.changed
+ tracker.mu.Unlock()
+ if state.Applied >= target {
+ return state, nil
+ }
+ if state.Err != nil {
+ return state, state.Err
+ }
+ select {
+ case <-ctx.Done():
+ return state, ctx.Err()
+ case <-changed:
+ }
+ }
+}
+
+func (tracker *FreshnessTracker) Snapshot() Freshness {
+ tracker.mu.Lock()
+ defer tracker.mu.Unlock()
+ return tracker.state
+}
+
+func (tracker *FreshnessTracker) notifyLocked() {
+ close(tracker.changed)
+ tracker.changed = make(chan struct{})
+}
diff --git a/internal/incremental/invalidation.go b/internal/incremental/invalidation.go
new file mode 100644
index 0000000..f7605ab
--- /dev/null
+++ b/internal/incremental/invalidation.go
@@ -0,0 +1,125 @@
+package incremental
+
+import "sort"
+
+// Unit is the smallest analyzer-owned semantic unit that can be rebuilt and
+// committed independently (for example a file, package, module, or project).
+// The incremental engine treats its value as opaque.
+type Unit string
+
+type InvalidationReason string
+
+const (
+ ReasonBodyChanged InvalidationReason = "body-changed"
+ ReasonCallsChanged InvalidationReason = "calls-changed"
+ ReasonStructureChanged InvalidationReason = "structure-changed"
+ ReasonAdded InvalidationReason = "file-added"
+ ReasonDeleted InvalidationReason = "file-deleted"
+ ReasonRenamed InvalidationReason = "file-renamed"
+ ReasonDependent InvalidationReason = "dependent-unit"
+)
+
+type InvalidatedUnit struct {
+ Unit Unit `json:"unit"`
+ Reason InvalidationReason `json:"reason"`
+}
+
+type InvalidationPlan struct {
+ BaseRevision Revision `json:"baseRevision"`
+ Units []InvalidatedUnit `json:"units"`
+}
+
+// DependencyIndex provides analyzer-produced unit dependencies. The planner
+// itself is independent of language and storage implementation.
+type DependencyIndex interface {
+ DirectDependents(Unit) []Unit
+}
+
+// Plan invalidates only the owning unit for content-only changes. Structural,
+// add, delete, and rename changes also invalidate direct dependent units whose
+// references may resolve differently. Expansion deliberately stops after one
+// hop: reparsing a dependent changes its outgoing resolutions, not its own
+// declarations, so callers of that dependent remain valid.
+func Plan(changes ChangeSet, dependencies DependencyIndex) InvalidationPlan {
+ reasons := make(map[Unit]InvalidationReason)
+ structural := make([]Unit, 0)
+ add := func(file FileState, reason InvalidationReason, expand bool) {
+ unit := Unit(file.Unit)
+ if unit == "" {
+ unit = Unit(file.Path)
+ }
+ setReason(reasons, unit, reason)
+ if expand {
+ structural = append(structural, unit)
+ }
+ }
+ for _, modification := range changes.Modified {
+ structureChanged := modification.Before.StructureHash == "" ||
+ modification.After.StructureHash == "" ||
+ modification.Before.StructureHash != modification.After.StructureHash ||
+ modification.Before.Unit != modification.After.Unit
+ if structureChanged {
+ add(modification.Before, ReasonStructureChanged, true)
+ add(modification.After, ReasonStructureChanged, true)
+ } else if modification.Before.CallHash != modification.After.CallHash {
+ add(modification.After, ReasonCallsChanged, false)
+ } else {
+ add(modification.After, ReasonBodyChanged, false)
+ }
+ }
+ for _, file := range changes.Added {
+ add(file, ReasonAdded, true)
+ }
+ for _, file := range changes.Deleted {
+ add(file, ReasonDeleted, true)
+ }
+ for _, rename := range changes.Renamed {
+ add(rename.Before, ReasonRenamed, true)
+ add(rename.After, ReasonRenamed, true)
+ }
+ if dependencies != nil {
+ seen := make(map[Unit]bool, len(structural))
+ for _, unit := range structural {
+ if seen[unit] {
+ continue
+ }
+ seen[unit] = true
+ for _, dependent := range dependencies.DirectDependents(unit) {
+ if _, exists := reasons[dependent]; !exists {
+ reasons[dependent] = ReasonDependent
+ }
+ }
+ }
+ }
+ result := InvalidationPlan{BaseRevision: changes.BaseRevision, Units: make([]InvalidatedUnit, 0, len(reasons))}
+ for unit, reason := range reasons {
+ result.Units = append(result.Units, InvalidatedUnit{Unit: unit, Reason: reason})
+ }
+ sort.Slice(result.Units, func(i, j int) bool { return result.Units[i].Unit < result.Units[j].Unit })
+ return result
+}
+
+func setReason(reasons map[Unit]InvalidationReason, unit Unit, reason InvalidationReason) {
+ if current, exists := reasons[unit]; !exists || reasonPriority(reason) > reasonPriority(current) {
+ reasons[unit] = reason
+ }
+}
+
+func reasonPriority(reason InvalidationReason) int {
+ switch reason {
+ case ReasonRenamed:
+ return 5
+ case ReasonDeleted:
+ return 4
+ case ReasonAdded:
+ return 3
+ case ReasonStructureChanged:
+ return 2
+ case ReasonCallsChanged:
+ return 2
+ case ReasonBodyChanged:
+ return 1
+ default:
+ return 0
+ }
+}
diff --git a/internal/incremental/journal.go b/internal/incremental/journal.go
new file mode 100644
index 0000000..53afabe
--- /dev/null
+++ b/internal/incremental/journal.go
@@ -0,0 +1,67 @@
+package incremental
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+
+ "github.com/cockroachdb/pebble/v2"
+ "github.com/mengshi02/codetrip/internal/store"
+)
+
+const pendingDeltaKey = "incremental:pending-delta"
+
+// StoreJournal persists a repository's pending delta in its isolated durable
+// store. Delta payloads include everything needed for deterministic replay;
+// recovery therefore never reparses a possibly newer working tree.
+type StoreJournal struct {
+ store *store.Store
+}
+
+func NewStoreJournal(repositoryStore *store.Store) *StoreJournal {
+ return &StoreJournal{store: repositoryStore}
+}
+
+func (journal *StoreJournal) Save(_ context.Context, delta *Delta) error {
+ if journal == nil || journal.store == nil || delta == nil {
+ return fmt.Errorf("invalid incremental journal save")
+ }
+ encoded, err := json.Marshal(delta)
+ if err != nil {
+ return err
+ }
+ return journal.store.Set([]byte(pendingDeltaKey), encoded)
+}
+
+func (journal *StoreJournal) Load(_ context.Context) (*Delta, error) {
+ if journal == nil || journal.store == nil {
+ return nil, fmt.Errorf("incremental journal is not open")
+ }
+ encoded, err := journal.store.Get([]byte(pendingDeltaKey))
+ if errors.Is(err, pebble.ErrNotFound) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ var delta Delta
+ if err := json.Unmarshal(encoded, &delta); err != nil {
+ return nil, fmt.Errorf("decode pending incremental delta: %w", err)
+ }
+ if delta.Revision != delta.BaseRevision+1 {
+ return nil, fmt.Errorf("%w: journal base=%d revision=%d", ErrInvalidDelta, delta.BaseRevision, delta.Revision)
+ }
+ return &delta, nil
+}
+
+func (journal *StoreJournal) Clear(ctx context.Context, revision Revision) error {
+ pending, err := journal.Load(ctx)
+ if err != nil || pending == nil {
+ return err
+ }
+ if pending.Revision != revision {
+ return fmt.Errorf("%w: pending=%d clear=%d", ErrRevisionConflict, pending.Revision, revision)
+ }
+ return journal.store.Delete([]byte(pendingDeltaKey))
+}
diff --git a/internal/incremental/journal_test.go b/internal/incremental/journal_test.go
new file mode 100644
index 0000000..ff90d1d
--- /dev/null
+++ b/internal/incremental/journal_test.go
@@ -0,0 +1,52 @@
+package incremental
+
+import (
+ "context"
+ "os"
+ "testing"
+
+ "github.com/mengshi02/codetrip/internal/store"
+)
+
+func TestStoreJournalPersistsCompleteDelta(t *testing.T) {
+ directory, err := os.MkdirTemp("", "incremental-journal-*")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = os.RemoveAll(directory) })
+ database, err := store.Open(store.DefaultConfig(directory))
+ if err != nil {
+ t.Fatal(err)
+ }
+ journal := NewStoreJournal(database)
+ delta := &Delta{
+ BaseRevision: 9, Revision: 10,
+ PutNodes: []NodeDelta{{ID: "node", Payload: []byte("node-payload")}},
+ PutSources: map[string][]byte{"main.go": []byte("package main")},
+ }
+ if err := journal.Save(context.Background(), delta); err != nil {
+ t.Fatal(err)
+ }
+ loaded, err := journal.Load(context.Background())
+ if err != nil || loaded == nil || loaded.Revision != 10 || string(loaded.PutSources["main.go"]) != "package main" {
+ t.Fatalf("loaded=%#v error=%v", loaded, err)
+ }
+ if err := database.Close(); err != nil {
+ t.Fatal(err)
+ }
+ database, err = store.Open(store.DefaultConfig(directory))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer database.Close()
+ journal = NewStoreJournal(database)
+ if loaded, err = journal.Load(context.Background()); err != nil || loaded == nil || loaded.Revision != 10 {
+ t.Fatalf("reopened=%#v error=%v", loaded, err)
+ }
+ if err := journal.Clear(context.Background(), 10); err != nil {
+ t.Fatal(err)
+ }
+ if loaded, err = journal.Load(context.Background()); err != nil || loaded != nil {
+ t.Fatalf("cleared=%#v error=%v", loaded, err)
+ }
+}
diff --git a/internal/incremental/recovery.go b/internal/incremental/recovery.go
new file mode 100644
index 0000000..aad74d9
--- /dev/null
+++ b/internal/incremental/recovery.go
@@ -0,0 +1,106 @@
+package incremental
+
+import (
+ "context"
+ "errors"
+ "fmt"
+)
+
+var ErrPendingRevision = errors.New("incremental revision is pending recovery")
+
+// Journal durably records the complete delta before any derived index is
+// changed. Implementations should store one pending record per repository.
+type Journal interface {
+ Save(context.Context, *Delta) error
+ Load(context.Context) (*Delta, error)
+ Clear(context.Context, Revision) error
+}
+
+// Component applies one idempotent part of a delta (for example symbols,
+// source documents, or vectors). Apply may be called again after a crash.
+type Component interface {
+ Name() string
+ Apply(context.Context, *Delta) error
+}
+
+// Authority owns the revision visible to queries. Publish runs only after all
+// components are durable. Engine implementations serialize queries with this
+// operation and recover a pending journal before accepting queries.
+type Authority interface {
+ ActiveRevision(context.Context) (Revision, error)
+ Publish(context.Context, *Delta) error
+}
+
+// RecoverableCommitter implements a roll-forward commit protocol across
+// independent local indexes. There is no unsafe rollback: an interrupted
+// revision remains journaled and is completed before the repository opens.
+type RecoverableCommitter struct {
+ journal Journal
+ authority Authority
+ components []Component
+}
+
+func NewRecoverableCommitter(journal Journal, authority Authority, components ...Component) *RecoverableCommitter {
+ return &RecoverableCommitter{journal: journal, authority: authority, components: components}
+}
+
+func (committer *RecoverableCommitter) ActiveRevision(ctx context.Context) (Revision, error) {
+ return committer.authority.ActiveRevision(ctx)
+}
+
+func (committer *RecoverableCommitter) Prepare(ctx context.Context, delta *Delta) (PreparedCommit, error) {
+ if pending, err := committer.journal.Load(ctx); err != nil {
+ return nil, err
+ } else if pending != nil {
+ return nil, fmt.Errorf("%w: revision=%d", ErrPendingRevision, pending.Revision)
+ }
+ if err := committer.journal.Save(ctx, delta); err != nil {
+ return nil, err
+ }
+ return &recoverablePrepared{parent: committer, delta: delta}, nil
+}
+
+// Recover completes a previously journaled revision. It must be called while
+// opening a repository, before any query endpoint becomes available.
+func (committer *RecoverableCommitter) Recover(ctx context.Context) error {
+ delta, err := committer.journal.Load(ctx)
+ if err != nil || delta == nil {
+ return err
+ }
+ active, err := committer.authority.ActiveRevision(ctx)
+ if err != nil {
+ return err
+ }
+ if active == delta.Revision {
+ return committer.journal.Clear(ctx, delta.Revision)
+ }
+ if active != delta.BaseRevision {
+ return fmt.Errorf("%w: active=%d pending_base=%d", ErrRevisionConflict, active, delta.BaseRevision)
+ }
+ return committer.apply(ctx, delta)
+}
+
+func (committer *RecoverableCommitter) apply(ctx context.Context, delta *Delta) error {
+ for _, component := range committer.components {
+ if err := component.Apply(ctx, delta); err != nil {
+ return fmt.Errorf("apply %s delta: %w", component.Name(), err)
+ }
+ }
+ if err := committer.authority.Publish(ctx, delta); err != nil {
+ return fmt.Errorf("publish revision %d: %w", delta.Revision, err)
+ }
+ return committer.journal.Clear(ctx, delta.Revision)
+}
+
+type recoverablePrepared struct {
+ parent *RecoverableCommitter
+ delta *Delta
+}
+
+func (prepared *recoverablePrepared) Commit(ctx context.Context) error {
+ return prepared.parent.apply(ctx, prepared.delta)
+}
+
+// Abort deliberately keeps the journal: component commits may already be
+// durable, so recovery must roll the exact revision forward.
+func (prepared *recoverablePrepared) Abort() error { return nil }
diff --git a/internal/incremental/recovery_test.go b/internal/incremental/recovery_test.go
new file mode 100644
index 0000000..6a8faa2
--- /dev/null
+++ b/internal/incremental/recovery_test.go
@@ -0,0 +1,89 @@
+package incremental
+
+import (
+ "context"
+ "errors"
+ "testing"
+)
+
+type memoryJournal struct{ pending *Delta }
+
+func (journal *memoryJournal) Save(_ context.Context, delta *Delta) error {
+ copy := *delta
+ journal.pending = ©
+ return nil
+}
+func (journal *memoryJournal) Load(context.Context) (*Delta, error) { return journal.pending, nil }
+func (journal *memoryJournal) Clear(_ context.Context, revision Revision) error {
+ if journal.pending != nil && journal.pending.Revision == revision {
+ journal.pending = nil
+ }
+ return nil
+}
+
+type memoryAuthority struct{ revision Revision }
+
+func (authority *memoryAuthority) ActiveRevision(context.Context) (Revision, error) {
+ return authority.revision, nil
+}
+func (authority *memoryAuthority) Publish(_ context.Context, delta *Delta) error {
+ authority.revision = delta.Revision
+ return nil
+}
+
+type memoryComponent struct {
+ name string
+ applied int
+ fail error
+}
+
+func (component *memoryComponent) Name() string { return component.name }
+func (component *memoryComponent) Apply(context.Context, *Delta) error {
+ component.applied++
+ return component.fail
+}
+
+func TestRecoverableCommitterRollsInterruptedRevisionForward(t *testing.T) {
+ ctx := context.Background()
+ journal := &memoryJournal{}
+ authority := &memoryAuthority{revision: 3}
+ first := &memoryComponent{name: "first"}
+ second := &memoryComponent{name: "second", fail: errors.New("interrupted")}
+ committer := NewRecoverableCommitter(journal, authority, first, second)
+ err := NewCoordinator(committer).Apply(ctx, &Delta{BaseRevision: 3, Revision: 4})
+ if err == nil || journal.pending == nil || authority.revision != 3 {
+ t.Fatalf("error=%v pending=%#v revision=%d", err, journal.pending, authority.revision)
+ }
+ second.fail = nil
+ restarted := NewRecoverableCommitter(journal, authority, first, second)
+ if err := restarted.Recover(ctx); err != nil {
+ t.Fatal(err)
+ }
+ if authority.revision != 4 || journal.pending != nil || first.applied != 2 || second.applied != 2 {
+ t.Fatalf("revision=%d pending=%#v applications=(%d,%d)", authority.revision, journal.pending, first.applied, second.applied)
+ }
+}
+
+func TestRecoverableCommitterClearsJournalPublishedBeforeCrash(t *testing.T) {
+ ctx := context.Background()
+ delta := &Delta{BaseRevision: 6, Revision: 7}
+ journal := &memoryJournal{pending: delta}
+ authority := &memoryAuthority{revision: 7}
+ component := &memoryComponent{name: "must-not-repeat"}
+ if err := NewRecoverableCommitter(journal, authority, component).Recover(ctx); err != nil {
+ t.Fatal(err)
+ }
+ if journal.pending != nil || component.applied != 0 {
+ t.Fatalf("pending=%#v applications=%d", journal.pending, component.applied)
+ }
+}
+
+func TestRecoverableCommitterRejectsNewRevisionWhileRecoveryPending(t *testing.T) {
+ ctx := context.Background()
+ journal := &memoryJournal{pending: &Delta{BaseRevision: 1, Revision: 2}}
+ committer := NewRecoverableCommitter(journal, &memoryAuthority{revision: 1})
+ _, err := committer.Prepare(ctx, &Delta{BaseRevision: 1, Revision: 2})
+ if !errors.Is(err, ErrPendingRevision) {
+ t.Fatalf("error=%v", err)
+ }
+}
diff --git a/internal/ingest/call_processor.go b/internal/ingest/call_processor.go
index c398fec..82bfeb2 100644
--- a/internal/ingest/call_processor.go
+++ b/internal/ingest/call_processor.go
@@ -3,6 +3,7 @@ package ingest
import (
"fmt"
"log"
+ "path/filepath"
"sort"
"strings"
@@ -316,11 +317,17 @@ func resolveCallTarget(
constructors = append(constructors, candidate)
}
}
+ hasExplicitConstructor := len(constructors) > 0
constructors = filterCallableCandidates(constructors, argCount, CallFormConstructor)
constructors = collapsePhysicalTargetDuplicates(constructors)
if len(constructors) == 1 {
return &CallResolveResult{NodeID: constructors[0].NodeID, Confidence: 0.95, Reason: "visible-constructor-exact"}
}
+ if !hasExplicitConstructor && len(visibleOwners) == 1 {
+ for ownerID := range visibleOwners {
+ return &CallResolveResult{NodeID: ownerID, Confidence: 0.95, Reason: "visible-implicit-constructor"}
+ }
+ }
// A C++ field_initializer is also used for data-member initializers
// such as env(Env::Default()). Without a visible same-named type this
// is not a constructor target and must not fall back to a free function.
@@ -484,6 +491,57 @@ func resolveCallTarget(
}
}
+// resolveGoPackageQualifierCall resolves pkg.Symbol() only when the receiver
+// spelling identifies an imported package. An untyped receiver such as
+// filter.Run() is usually a local variable; resolving it against every symbol
+// in imported packages creates confident-looking but incorrect edges.
+func resolveGoPackageQualifierCall(
+ receiverName, calledName, currentFile string,
+ ctx *ResolveContext,
+ argCount int,
+) *CallResolveResult {
+ if receiverName == "" {
+ return nil
+ }
+
+ packageDirs := make(map[string]bool)
+ for importedFile := range ctx.ImportMap[currentFile] {
+ dir := filepath.ToSlash(filepath.Dir(importedFile))
+ if filepath.Base(dir) == receiverName {
+ packageDirs[dir] = true
+ }
+ }
+ for dirSuffix := range ctx.PackageMap[currentFile] {
+ dir := filepath.ToSlash(strings.TrimSuffix(dirSuffix, "/"))
+ if filepath.Base(dir) == receiverName {
+ packageDirs[dir] = true
+ }
+ }
+ if len(packageDirs) == 0 {
+ return nil
+ }
+
+ var candidates []*SymbolDefinition
+ for _, def := range ctx.SymbolTable.LookupFuzzy(calledName) {
+ for dir := range packageDirs {
+ if isFileInPackageDir(filepath.ToSlash(def.FilePath), dir) {
+ candidates = append(candidates, def)
+ break
+ }
+ }
+ }
+ candidates = filterCallableCandidates(candidates, argCount, CallFormMember)
+ candidates = collapsePhysicalTargetDuplicates(candidates)
+ if len(candidates) != 1 {
+ return nil
+ }
+ return &CallResolveResult{
+ NodeID: candidates[0].NodeID,
+ Confidence: 0.9,
+ Reason: "go-package-qualified",
+ }
+}
+
// visibleTypeDefinitions prevents a simple receiver type such as Iterator
// from matching unrelated same-name nested classes elsewhere in a repository.
// C/C++ headers are followed transitively because a type may arrive through an
@@ -523,9 +581,19 @@ func visibleTypeDefinitions(typeName, currentFile string, ctx *ResolveContext) [
}
}
var result []*SymbolDefinition
+ seen := make(map[string]bool)
+ for _, def := range WalkBindingChain(
+ typeName, currentFile, ctx.SymbolTable, ctx.NamedImportMap, ctx.SymbolTable.LookupFuzzy(typeName),
+ ) {
+ if def != nil && def.NodeID != "" && !seen[def.NodeID] {
+ seen[def.NodeID] = true
+ result = append(result, def)
+ }
+ }
for name := range names {
for _, def := range ctx.SymbolTable.LookupFuzzy(name) {
- if visibleFiles[def.FilePath] {
+ if visibleFiles[def.FilePath] && def.NodeID != "" && !seen[def.NodeID] {
+ seen[def.NodeID] = true
result = append(result, def)
}
}
@@ -938,7 +1006,19 @@ func ProcessCallsFromExtracted(
if call.CallForm == CallFormMember && receiverTypeName == "" && call.ReceiverName != "" {
// No type from extraction — leave empty, resolution falls back to name-only
}
- res := resolveCallTarget(calledName, call.FilePath, receiverTypeName, ctx, callForm, call.ArgCount)
+ var res *CallResolveResult
+ if call.Language == "go" && callForm == CallFormMember && receiverTypeName == "" {
+ res = resolveGoPackageQualifierCall(call.ReceiverName, calledName, call.FilePath, ctx, call.ArgCount)
+ } else {
+ res = resolveCallTarget(calledName, call.FilePath, receiverTypeName, ctx, callForm, call.ArgCount)
+ }
+ if res != nil && res.Reason == "unique-global" &&
+ (call.Language == "go" || call.Language == "typescript" || call.Language == "tsx") {
+ // A repository-wide unique spelling is not evidence that a symbol
+ // is visible at this call site. Stable languages require lexical,
+ // import, receiver-type, or compiler evidence.
+ res = nil
+ }
if callForm == CallFormMember && receiverTypeName == "" && res != nil && res.Reason == "unique-global" {
// An untyped receiver may use same-file or import-scoped evidence, but
// never repository-wide uniqueness alone.
diff --git a/internal/ingest/dependencies.go b/internal/ingest/dependencies.go
new file mode 100644
index 0000000..fd15e3a
--- /dev/null
+++ b/internal/ingest/dependencies.go
@@ -0,0 +1,64 @@
+package ingest
+
+import (
+ "path/filepath"
+ "sort"
+
+ inc "github.com/mengshi02/codetrip/internal/incremental"
+ graph "github.com/mengshi02/codetrip/internal/model"
+)
+
+// GraphDependencies maps semantic relationships back to analyzer-owned file
+// units. If a target declaration changes, files containing callers,
+// importers, implementers, or subclasses become dependents and are reparsed.
+type GraphDependencies struct {
+ dependents map[inc.Unit][]inc.Unit
+}
+
+func NewGraphDependencies(knowledgeGraph *graph.KnowledgeGraph) *GraphDependencies {
+ result := &GraphDependencies{dependents: make(map[inc.Unit][]inc.Unit)}
+ if knowledgeGraph == nil {
+ return result
+ }
+ files := make(map[string]inc.Unit)
+ knowledgeGraph.ForEachNode(func(node *graph.GraphNode) {
+ if node.Properties.FilePath != "" {
+ files[node.ID] = inc.Unit(filepath.ToSlash(node.Properties.FilePath))
+ }
+ })
+ sets := make(map[inc.Unit]map[inc.Unit]bool)
+ knowledgeGraph.ForEachRelationship(func(relationship *graph.GraphRelationship) {
+ switch relationship.Type {
+ case graph.RelIMPORTS, graph.RelCALLS, graph.RelUSES, graph.RelINHERITS,
+ graph.RelIMPLEMENTS, graph.RelEXTENDS, graph.RelMETHOD_IMPLEMENTS,
+ graph.RelDISPATCHES_TO:
+ default:
+ return
+ }
+ source, sourceOK := files[relationship.SourceID]
+ target, targetOK := files[relationship.TargetID]
+ if !sourceOK || !targetOK || source == target {
+ return
+ }
+ if sets[target] == nil {
+ sets[target] = make(map[inc.Unit]bool)
+ }
+ sets[target][source] = true
+ })
+ for target, sources := range sets {
+ for source := range sources {
+ result.dependents[target] = append(result.dependents[target], source)
+ }
+ sort.Slice(result.dependents[target], func(i, j int) bool {
+ return result.dependents[target][i] < result.dependents[target][j]
+ })
+ }
+ return result
+}
+
+func (dependencies *GraphDependencies) DirectDependents(unit inc.Unit) []inc.Unit {
+ if dependencies == nil {
+ return nil
+ }
+ return append([]inc.Unit(nil), dependencies.dependents[unit]...)
+}
diff --git a/internal/ingest/dependencies_test.go b/internal/ingest/dependencies_test.go
new file mode 100644
index 0000000..3f98a4f
--- /dev/null
+++ b/internal/ingest/dependencies_test.go
@@ -0,0 +1,193 @@
+package ingest
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+
+ inc "github.com/mengshi02/codetrip/internal/incremental"
+ graph "github.com/mengshi02/codetrip/internal/model"
+)
+
+func TestGraphDependenciesReturnsSemanticImporterAndCallerFiles(t *testing.T) {
+ knowledgeGraph := graph.NewKnowledgeGraph()
+ target := &graph.GraphNode{ID: "target", Label: graph.LabelFunction, Properties: graph.NodeProperties{Name: "Target", FilePath: "lib/target.go"}}
+ caller := &graph.GraphNode{ID: "caller", Label: graph.LabelFunction, Properties: graph.NodeProperties{Name: "Caller", FilePath: "app/caller.go"}}
+ importer := &graph.GraphNode{ID: "importer", Label: graph.LabelFile, Properties: graph.NodeProperties{Name: "import.go", FilePath: "app/import.go"}}
+ for _, node := range []*graph.GraphNode{target, caller, importer} {
+ knowledgeGraph.AddNode(node)
+ }
+ knowledgeGraph.AddRelationship(&graph.GraphRelationship{ID: "call", SourceID: caller.ID, TargetID: target.ID, Type: graph.RelCALLS})
+ knowledgeGraph.AddRelationship(&graph.GraphRelationship{ID: "import", SourceID: importer.ID, TargetID: target.ID, Type: graph.RelIMPORTS})
+ dependencies := NewGraphDependencies(knowledgeGraph)
+ got := dependencies.DirectDependents(inc.Unit("lib/target.go"))
+ if len(got) != 2 || got[0] != "app/caller.go" || got[1] != "app/import.go" {
+ t.Fatalf("dependents=%#v", got)
+ }
+}
+
+func TestIncrementalPlanMatchesCleanFullGraphForStructuralDependency(t *testing.T) {
+ repository := t.TempDir()
+ write := func(path, content string) {
+ t.Helper()
+ absolute := filepath.Join(repository, filepath.FromSlash(path))
+ if err := os.MkdirAll(filepath.Dir(absolute), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(absolute, []byte(content), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ write("lib.ts", "export function greet(name: string): string { return name }\n")
+ write("app.ts", "import { greet } from './lib'\nexport function run(): string { return greet('x') }\n")
+ base, err := NewPipeline(repository, "", false).Run()
+ if err != nil {
+ t.Fatal(err)
+ }
+ write("lib.ts", "export function greet(name: string, count: number): string { return name.repeat(count) }\n")
+ changes := inc.ChangeSet{BaseRevision: 1, Modified: []inc.Modification{{
+ Before: inc.FileState{Path: "lib.ts", Unit: "lib.ts", StructureHash: "before"},
+ After: inc.FileState{Path: "lib.ts", Unit: "lib.ts", StructureHash: "after"},
+ }}}
+ plan := inc.Plan(changes, NewGraphDependencies(base.Graph))
+ if len(plan.Units) < 2 {
+ t.Fatalf("plan did not include caller dependency: %#v", plan)
+ }
+ incremental, err := RunIncrementalPlan(repository, base.Graph, plan)
+ if err != nil || !incremental.Supported {
+ t.Fatalf("incremental=%#v error=%v", incremental, err)
+ }
+ clean, err := NewPipeline(repository, "", false).Run()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got, want := canonicalKnowledgeGraph(t, incremental.Pipeline.Graph), canonicalKnowledgeGraph(t, clean.Graph); got != want {
+ t.Fatal("incremental graph differs from a clean full graph")
+ }
+}
+
+func TestIncrementalCallableAdditionUsesPackageLocalGoSemantics(t *testing.T) {
+ repository := t.TempDir()
+ write := func(path, content string) {
+ t.Helper()
+ absolute := filepath.Join(repository, filepath.FromSlash(path))
+ if err := os.MkdirAll(filepath.Dir(absolute), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(absolute, []byte(content), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ write("go.mod", "module example.com/local\n\ngo 1.22\n")
+ write("main.go", "package local\n\nfunc Existing() string { return helper() }\nfunc helper() string { return \"ok\" }\n")
+ base, err := NewPipeline(repository, "", false).Run()
+ if err != nil {
+ t.Fatal(err)
+ }
+ write("main.go", "package local\n\nfunc Existing() string { return helper() }\nfunc helper() string { return \"ok\" }\nfunc Added() string { return Existing() }\n")
+ changes := inc.ChangeSet{BaseRevision: 1, Modified: []inc.Modification{{
+ Before: inc.FileState{Path: "main.go", Unit: "main.go", StructureHash: "before"},
+ After: inc.FileState{Path: "main.go", Unit: "main.go", StructureHash: "after"},
+ }}}
+ incremental, err := RunIncrementalPlan(repository, base.Graph, inc.Plan(changes, NewGraphDependencies(base.Graph)))
+ if err != nil || !incremental.Supported {
+ t.Fatalf("incremental=%#v error=%v", incremental, err)
+ }
+ clean, err := NewPipeline(repository, "", false).Run()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got, want := canonicalKnowledgeGraph(t, incremental.Pipeline.Graph), canonicalKnowledgeGraph(t, clean.Graph); got != want {
+ limit := len(got)
+ if len(want) < limit {
+ limit = len(want)
+ }
+ at := 0
+ for at < limit && got[at] == want[at] {
+ at++
+ }
+ endGot, endWant := at+500, at+500
+ if endGot > len(got) {
+ endGot = len(got)
+ }
+ if endWant > len(want) {
+ endWant = len(want)
+ }
+ t.Fatalf("package-local callable addition differs at byte %d\nincremental: %s\nfull: %s", at, got[at:endGot], want[at:endWant])
+ }
+}
+
+func TestFunctionOnlyChangeRejectsMethodsAndAcceptsRename(t *testing.T) {
+ function := func(id string) string { return id + "\x00" + string(graph.LabelFunction) + "\x000\x00" }
+ method := func(id string) string { return id + "\x00" + string(graph.LabelMethod) + "\x000\x00" }
+ if !functionOnlyChange([]string{function("old")}, []string{function("new")}) {
+ t.Fatal("top-level function rename should use package-local semantics")
+ }
+ if functionOnlyChange([]string{method("old")}, []string{method("new")}) {
+ t.Fatal("method rename must retain full interface and dispatch analysis")
+ }
+ if functionOnlyChange([]string{function("same")}, []string{function("same")}) {
+ t.Fatal("unchanged declarations are not a structural function change")
+ }
+}
+
+func TestIncrementalTopLevelFunctionRenameRefreshesCrossFileCaller(t *testing.T) {
+ repository := t.TempDir()
+ write := func(name, content string) {
+ t.Helper()
+ if err := os.WriteFile(filepath.Join(repository, name), []byte(content), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ write("go.mod", "module example.com/rename\n\ngo 1.22\n")
+ write("target.go", "package rename\nfunc OldTarget() string { return \"ok\" }\n")
+ write("caller.go", "package rename\nfunc Caller() string { return OldTarget() }\n")
+ base, err := NewPipeline(repository, "", false).Run()
+ if err != nil {
+ t.Fatal(err)
+ }
+ write("target.go", "package rename\nfunc NewTarget() string { return \"ok\" }\n")
+ write("caller.go", "package rename\nfunc Caller() string { return NewTarget() }\n")
+ changes := inc.ChangeSet{BaseRevision: 1, Modified: []inc.Modification{
+ {Before: inc.FileState{Path: "target.go", Unit: "target.go", StructureHash: "old-target"}, After: inc.FileState{Path: "target.go", Unit: "target.go", StructureHash: "new-target"}},
+ {Before: inc.FileState{Path: "caller.go", Unit: "caller.go", StructureHash: "same", CallHash: "old-call"}, After: inc.FileState{Path: "caller.go", Unit: "caller.go", StructureHash: "same", CallHash: "new-call"}},
+ }}
+ incremental, err := RunIncrementalPlan(repository, base.Graph, inc.Plan(changes, NewGraphDependencies(base.Graph)))
+ if err != nil || !incremental.Supported {
+ t.Fatalf("incremental=%#v error=%v", incremental, err)
+ }
+ clean, err := NewPipeline(repository, "", false).Run()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got, want := canonicalKnowledgeGraph(t, incremental.Pipeline.Graph), canonicalKnowledgeGraph(t, clean.Graph); got != want {
+ t.Fatal("cross-file function rename differs from clean full graph")
+ }
+ found := false
+ incremental.Pipeline.Graph.ForEachRelationship(func(relationship *graph.GraphRelationship) {
+ if relationship.Type == graph.RelCALLS && relationship.SourceID == "Function:caller.go:Caller" &&
+ relationship.TargetID == "Function:target.go:NewTarget" && relationship.Reason == "go-types-call-target" {
+ found = true
+ }
+ })
+ if !found {
+ t.Fatal("cross-file caller was not compiler-resolved to renamed target")
+ }
+}
+
+func canonicalKnowledgeGraph(t *testing.T, knowledgeGraph *graph.KnowledgeGraph) string {
+ t.Helper()
+ var nodes []*graph.GraphNode
+ var relationships []*graph.GraphRelationship
+ knowledgeGraph.ForEachNode(func(node *graph.GraphNode) { nodes = append(nodes, node) })
+ knowledgeGraph.ForEachRelationship(func(relationship *graph.GraphRelationship) { relationships = append(relationships, relationship) })
+ encoded, err := json.Marshal(struct {
+ Nodes []*graph.GraphNode `json:"nodes"`
+ Relationships []*graph.GraphRelationship `json:"relationships"`
+ }{nodes, relationships})
+ if err != nil {
+ t.Fatal(err)
+ }
+ return string(encoded)
+}
diff --git a/internal/ingest/enrich/community_processor.go b/internal/ingest/enrich/community_processor.go
index 0bdc185..fb28fa2 100644
--- a/internal/ingest/enrich/community_processor.go
+++ b/internal/ingest/enrich/community_processor.go
@@ -1,6 +1,7 @@
package enrich
import (
+ "crypto/sha256"
"fmt"
"log"
"math"
@@ -272,7 +273,7 @@ func generateHeuristicLabel(memberIDs []string, nodePathMap map[string]string, n
bestFolder := ""
maxCount := 0
for folder, count := range folderCounts {
- if count > maxCount {
+ if count > maxCount || (count == maxCount && (bestFolder == "" || folder < bestFolder)) {
maxCount = count
bestFolder = folder
}
@@ -407,7 +408,7 @@ func ProcessCommunities(kg *graph.KnowledgeGraph) CommunityDetectionResult {
communities := result.communities
// Create community nodes
- communityNodes := createCommunityNodes(communities, result.count, lg, kg)
+ communityNodes, communityIDs := createCommunityNodes(communities, result.count, lg, kg)
materializedCommunities := make(map[string]struct{}, len(communityNodes))
for _, community := range communityNodes {
materializedCommunities[community.ID] = struct{}{}
@@ -418,7 +419,7 @@ func ProcessCommunities(kg *graph.KnowledgeGraph) CommunityDetectionResult {
// MEMBER_OF relationships behind.
var memberships []CommunityMembership
for nodeID, commNum := range communities {
- communityID := fmt.Sprintf("comm_%d", commNum)
+ communityID := communityIDs[commNum]
if _, ok := materializedCommunities[communityID]; !ok {
continue
}
@@ -427,6 +428,12 @@ func ProcessCommunities(kg *graph.KnowledgeGraph) CommunityDetectionResult {
CommunityID: communityID,
})
}
+ sort.Slice(memberships, func(i, j int) bool {
+ if memberships[i].CommunityID != memberships[j].CommunityID {
+ return memberships[i].CommunityID < memberships[j].CommunityID
+ }
+ return memberships[i].NodeID < memberships[j].NodeID
+ })
return CommunityDetectionResult{
Communities: communityNodes,
@@ -448,7 +455,7 @@ func createCommunityNodes(
communityCount int,
lg *leidenGraph,
kg *graph.KnowledgeGraph,
-) []CommunityNode {
+) ([]CommunityNode, map[int]string) {
// Group node IDs by community
communityMembers := make(map[int][]string)
for nodeID, commNum := range communities {
@@ -465,16 +472,20 @@ func createCommunityNodes(
// Create community nodes — skip singletons
var communityNodes []CommunityNode
+ communityIDs := make(map[int]string)
for commNum, memberIDs := range communityMembers {
if len(memberIDs) < 2 {
continue
}
+ sort.Strings(memberIDs)
heuristicLabel := generateHeuristicLabel(memberIDs, nodePathMap, lg.nodeAttr, commNum)
+ communityID := stableCommunityID(memberIDs)
+ communityIDs[commNum] = communityID
communityNodes = append(communityNodes, CommunityNode{
- ID: fmt.Sprintf("comm_%d", commNum),
+ ID: communityID,
Label: heuristicLabel,
HeuristicLabel: heuristicLabel,
Cohesion: calculateCohesion(memberIDs, lg.adj),
@@ -484,10 +495,22 @@ func createCommunityNodes(
// Sort by size descending
sort.Slice(communityNodes, func(i, j int) bool {
- return communityNodes[i].SymbolCount > communityNodes[j].SymbolCount
+ if communityNodes[i].SymbolCount != communityNodes[j].SymbolCount {
+ return communityNodes[i].SymbolCount > communityNodes[j].SymbolCount
+ }
+ return communityNodes[i].ID < communityNodes[j].ID
})
- return communityNodes
+ return communityNodes, communityIDs
+}
+
+func stableCommunityID(sortedMemberIDs []string) string {
+ hash := sha256.New()
+ for _, id := range sortedMemberIDs {
+ hash.Write([]byte(id))
+ hash.Write([]byte{0})
+ }
+ return fmt.Sprintf("comm_%x", hash.Sum(nil))
}
// ─────────────────────────────────────────────────────────────────────────────
diff --git a/internal/ingest/enrich/community_processor_test.go b/internal/ingest/enrich/community_processor_test.go
index cf9788f..cf4224e 100644
--- a/internal/ingest/enrich/community_processor_test.go
+++ b/internal/ingest/enrich/community_processor_test.go
@@ -1,11 +1,31 @@
package enrich
import (
+ "fmt"
+ "reflect"
"testing"
graph "github.com/mengshi02/codetrip/internal/model"
)
+func TestProcessCommunitiesIsDeterministic(t *testing.T) {
+ kg := graph.NewKnowledgeGraph()
+ for index := 0; index < 80; index++ {
+ id := fmt.Sprintf("Function:pkg/service.go:f%03d", index)
+ kg.AddNode(&graph.GraphNode{ID: id, Label: graph.LabelFunction, Properties: graph.NodeProperties{Name: fmt.Sprintf("f%03d", index), FilePath: "pkg/service.go"}})
+ if index > 0 {
+ previous := fmt.Sprintf("Function:pkg/service.go:f%03d", index-1)
+ kg.AddRelationship(&graph.GraphRelationship{ID: "CALLS:" + previous + "->" + id, SourceID: previous, TargetID: id, Type: graph.RelCALLS, Confidence: 1})
+ }
+ }
+ want := ProcessCommunities(kg)
+ for iteration := 0; iteration < 10; iteration++ {
+ if got := ProcessCommunities(kg); !reflect.DeepEqual(got, want) {
+ t.Fatalf("community result changed on iteration %d", iteration)
+ }
+ }
+}
+
func TestApplyCommunitiesSkipsMembershipsWithMissingEndpoints(t *testing.T) {
kg := graph.NewKnowledgeGraph()
kg.AddNode(&graph.GraphNode{
diff --git a/internal/ingest/enrich/process_processor.go b/internal/ingest/enrich/process_processor.go
index 96df2fc..b459078 100644
--- a/internal/ingest/enrich/process_processor.go
+++ b/internal/ingest/enrich/process_processor.go
@@ -134,7 +134,10 @@ func ProcessProcesses(
// Step 4: Limit to max processes (prioritize longer traces)
sort.Slice(endpointDeduped, func(i, j int) bool {
- return len(endpointDeduped[i]) > len(endpointDeduped[j])
+ if len(endpointDeduped[i]) != len(endpointDeduped[j]) {
+ return len(endpointDeduped[i]) > len(endpointDeduped[j])
+ }
+ return strings.Join(endpointDeduped[i], "\x00") < strings.Join(endpointDeduped[j], "\x00")
})
if len(endpointDeduped) > cfg.MaxProcesses {
endpointDeduped = endpointDeduped[:cfg.MaxProcesses]
@@ -279,6 +282,9 @@ func buildCallsGraph(kg *graph.KnowledgeGraph) map[string][]string {
adj[rel.SourceID] = append(adj[rel.SourceID], rel.TargetID)
}
})
+ for source := range adj {
+ sort.Strings(adj[source])
+ }
return adj
}
@@ -289,6 +295,9 @@ func buildReverseCallsGraph(kg *graph.KnowledgeGraph) map[string][]string {
adj[rel.TargetID] = append(adj[rel.TargetID], rel.SourceID)
}
})
+ for target := range adj {
+ sort.Strings(adj[target])
+ }
return adj
}
@@ -368,7 +377,10 @@ func findEntryPoints(
// Sort by score descending
sort.Slice(candidates, func(i, j int) bool {
- return candidates[i].Score > candidates[j].Score
+ if candidates[i].Score != candidates[j].Score {
+ return candidates[i].Score > candidates[j].Score
+ }
+ return candidates[i].ID < candidates[j].ID
})
// Limit to prevent explosion (top 200)
@@ -464,7 +476,10 @@ func deduplicateTraces(traces [][]string) [][]string {
sorted := make([][]string, len(traces))
copy(sorted, traces)
sort.Slice(sorted, func(i, j int) bool {
- return len(sorted[i]) > len(sorted[j])
+ if len(sorted[i]) != len(sorted[j]) {
+ return len(sorted[i]) > len(sorted[j])
+ }
+ return strings.Join(sorted[i], "\x00") < strings.Join(sorted[j], "\x00")
})
unique := [][]string{}
@@ -496,7 +511,10 @@ func deduplicateByEndpoints(traces [][]string) [][]string {
sorted := make([][]string, len(traces))
copy(sorted, traces)
sort.Slice(sorted, func(i, j int) bool {
- return len(sorted[i]) > len(sorted[j])
+ if len(sorted[i]) != len(sorted[j]) {
+ return len(sorted[i]) > len(sorted[j])
+ }
+ return strings.Join(sorted[i], "\x00") < strings.Join(sorted[j], "\x00")
})
byEndpoints := make(map[string][]string)
@@ -507,9 +525,14 @@ func deduplicateByEndpoints(traces [][]string) [][]string {
}
}
- result := [][]string{}
- for _, trace := range byEndpoints {
- result = append(result, trace)
+ keys := make([]string, 0, len(byEndpoints))
+ for key := range byEndpoints {
+ keys = append(keys, key)
+ }
+ sort.Strings(keys)
+ result := make([][]string, 0, len(keys))
+ for _, key := range keys {
+ result = append(result, byEndpoints[key])
}
return result
}
diff --git a/internal/ingest/filesystem_walker.go b/internal/ingest/filesystem_walker.go
index c4a852a..deeac31 100644
--- a/internal/ingest/filesystem_walker.go
+++ b/internal/ingest/filesystem_walker.go
@@ -112,6 +112,15 @@ func WalkRepositoryPaths(repoPath string) (*WalkResult, error) {
return nil
}
+ // Repository indexes contain file contents, not filesystem aliases or
+ // special devices. filepath.Walk reports a symlink itself (rather than
+ // its target), so without this guard a link to a directory is classified
+ // as a file and later fails during fingerprinting or parsing.
+ if !info.Mode().IsRegular() {
+ result.SkippedFiles++
+ return nil
+ }
+
// Skip specific files by name or extension
if shouldIgnoreFile(base) {
result.SkippedFiles++
diff --git a/internal/ingest/fingerprint.go b/internal/ingest/fingerprint.go
new file mode 100644
index 0000000..6529998
--- /dev/null
+++ b/internal/ingest/fingerprint.go
@@ -0,0 +1,96 @@
+package ingest
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "sort"
+ "strings"
+
+ graph "github.com/mengshi02/codetrip/internal/model"
+)
+
+// StructureFingerprint hashes declarations and dependency-bearing captures,
+// excluding executable bodies and comments. It uses the same registry and
+// queries as full ingestion, so every supported language follows one contract
+// without language switches in the incremental core.
+func StructureFingerprint(path string, content []byte) (string, error) {
+ structure, _, err := AnalysisFingerprints(path, content)
+ return structure, err
+}
+
+// AnalysisFingerprints returns declaration/dependency shape and executable
+// call-site shape from one parse. The latter lets incremental analyzers avoid
+// compiler work when a body edit does not change call topology.
+func AnalysisFingerprints(path string, content []byte) (string, string, error) {
+ language := GetLanguageFromFilename(path)
+ registry := NewLanguageRegistry()
+ if language == "" || !registry.HasParser(GetParserFromFilename(path)) {
+ // Engineering configuration can affect resolution even without a parser;
+ // conservatively treat any byte change as structural.
+ sum := sha256.Sum256(content)
+ value := hex.EncodeToString(sum[:])
+ return value, value, nil
+ }
+ knowledgeGraph := graph.NewKnowledgeGraph()
+ extracted := ProcessParsing(
+ knowledgeGraph,
+ []FileInput{{Path: path, Content: string(content)}},
+ NewSymbolTable(), registry, nil,
+ )
+ records := make([]string, 0)
+ callRecords := make([]string, 0)
+ knowledgeGraph.ForEachNode(func(node *graph.GraphNode) {
+ properties := node.Properties
+ records = append(records, strings.Join([]string{
+ "node", string(node.Label), properties.Name,
+ optionalInt(properties.ParameterCount), properties.ReturnType,
+ properties.Visibility, properties.Modifiers,
+ optionalBool(properties.IsExported), optionalBool(properties.IsAbstract),
+ optionalBool(properties.IsStatic), optionalBool(properties.IsAsync),
+ }, "\x00"))
+ })
+ if extracted != nil {
+ for _, item := range extracted.Calls {
+ callRecords = append(callRecords, strings.Join([]string{
+ item.SourceID, item.CallName, item.ReceiverName, item.ReceiverTypeName,
+ strings.Join(item.ReceiverChain, "."), fmt.Sprint(item.ReceiverChainArgCounts),
+ fmt.Sprint(item.CallForm), fmt.Sprint(item.ArgCount), item.CallNodeKind,
+ }, "\x00"))
+ }
+ for _, item := range extracted.Imports {
+ records = append(records, strings.Join([]string{
+ "import", item.ImportPath, item.NamedBinding, item.ExportedName,
+ }, "\x00"))
+ }
+ for _, item := range extracted.Heritage {
+ records = append(records, strings.Join([]string{
+ "heritage", item.ChildID, item.ParentName, item.HeritageType,
+ }, "\x00"))
+ }
+ for _, item := range extracted.Routes {
+ records = append(records, strings.Join([]string{
+ "route", item.Controller, item.Method, item.RoutePath, item.HTTPMethod,
+ }, "\x00"))
+ }
+ }
+ sort.Strings(records)
+ sort.Strings(callRecords)
+ sum := sha256.Sum256([]byte(strings.Join(records, "\n")))
+ callSum := sha256.Sum256([]byte(strings.Join(callRecords, "\n")))
+ return hex.EncodeToString(sum[:]), hex.EncodeToString(callSum[:]), nil
+}
+
+func optionalInt(value *int) string {
+ if value == nil {
+ return ""
+ }
+ return fmt.Sprint(*value)
+}
+
+func optionalBool(value *bool) string {
+ if value == nil {
+ return ""
+ }
+ return fmt.Sprint(*value)
+}
diff --git a/internal/ingest/fingerprint_test.go b/internal/ingest/fingerprint_test.go
new file mode 100644
index 0000000..f126abe
--- /dev/null
+++ b/internal/ingest/fingerprint_test.go
@@ -0,0 +1,71 @@
+package ingest
+
+import "testing"
+
+func TestStructureFingerprintIgnoresBodiesButDetectsSignatures(t *testing.T) {
+ tests := []struct {
+ path string
+ before string
+ bodyOnly string
+ structure string
+ }{
+ {
+ path: "main.go",
+ before: "package p\nfunc Run(value string) int { return 1 }\n",
+ bodyOnly: "package p\n// comment\nfunc Run(value string) int { return 2 }\n",
+ structure: "package p\nfunc Run(value string, count int) int { return count }\n",
+ },
+ {
+ path: "main.ts",
+ before: "export function run(value: string): number { return 1 }\n",
+ bodyOnly: "export function run(value: string): number { return 2 }\n",
+ structure: "export function run(value: string, count: number): number { return count }\n",
+ },
+ }
+ for _, test := range tests {
+ t.Run(test.path, func(t *testing.T) {
+ before, err := StructureFingerprint(test.path, []byte(test.before))
+ if err != nil {
+ t.Fatal(err)
+ }
+ bodyOnly, err := StructureFingerprint(test.path, []byte(test.bodyOnly))
+ if err != nil {
+ t.Fatal(err)
+ }
+ structure, err := StructureFingerprint(test.path, []byte(test.structure))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if before != bodyOnly {
+ t.Fatalf("body-only edit changed structure hash: %s != %s", before, bodyOnly)
+ }
+ if before == structure {
+ t.Fatal("signature edit did not change structure hash")
+ }
+ })
+ }
+}
+
+func TestAnalysisFingerprintsSeparateBodyFromCallChanges(t *testing.T) {
+ structureA, callsA, err := AnalysisFingerprints("main.go", []byte("package p\nfunc Run() { println(1) }\n"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ structureB, callsB, err := AnalysisFingerprints("main.go", []byte("package p\n// comment\nfunc Run() { println(2) }\n"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ structureC, callsC, err := AnalysisFingerprints("main.go", []byte("package p\nfunc Run() { panic(\"changed\") }\n"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if structureA != structureB || structureA != structureC {
+ t.Fatal("body-only edits changed structure fingerprint")
+ }
+ if callsA != callsB {
+ t.Fatal("literal/comment edit changed call fingerprint")
+ }
+ if callsA == callsC {
+ t.Fatal("changed call expression did not change call fingerprint")
+ }
+}
diff --git a/internal/ingest/go_semantic.go b/internal/ingest/go_semantic.go
index 671da40..66afbfe 100644
--- a/internal/ingest/go_semantic.go
+++ b/internal/ingest/go_semantic.go
@@ -5,6 +5,7 @@ import (
"go/ast"
"go/token"
"go/types"
+ "os"
"path/filepath"
"sort"
"strings"
@@ -28,8 +29,19 @@ type GoSemanticStats struct {
type goSemanticRefiner struct{}
+const maxGoFilesForSemanticTests = 5000
+
func (goSemanticRefiner) Name() string { return "go-types" }
+func (goSemanticRefiner) SupportsUnits(units []string) bool {
+ for _, unit := range units {
+ if strings.EqualFold(filepath.Ext(unit), ".go") {
+ return true
+ }
+ }
+ return false
+}
+
func (goSemanticRefiner) Supports(knowledgeGraph *graph.KnowledgeGraph) bool {
supported := false
knowledgeGraph.ForEachNode(func(node *graph.GraphNode) {
@@ -42,6 +54,19 @@ func (goSemanticRefiner) Supports(knowledgeGraph *graph.KnowledgeGraph) bool {
func (goSemanticRefiner) Refine(repoPath string, knowledgeGraph *graph.KnowledgeGraph) (SemanticStats, error) {
stats, err := ProcessGoSemantics(repoPath, knowledgeGraph)
+ return goSemanticResult(stats, err)
+}
+
+func (goSemanticRefiner) RefineUnits(repoPath string, knowledgeGraph *graph.KnowledgeGraph, units []string) (SemanticStats, error) {
+ // Package graph compaction currently owns repository-wide IMPORTS edges.
+ // Until that transform exposes a true delta API, Go changes must retain the
+ // complete compiler pass to preserve clean-full equivalence. The incremental
+ // selector still avoids this pass entirely for non-Go changes.
+ stats, err := ProcessGoSemantics(repoPath, knowledgeGraph)
+ return goSemanticResult(stats, err)
+}
+
+func goSemanticResult(stats GoSemanticStats, err error) (SemanticStats, error) {
if err != nil {
return SemanticStats{}, err
}
@@ -71,6 +96,7 @@ type goSemanticIndex struct {
interfaceOwner map[*types.Func]string
callables map[string][]goCallableRange
nodesByKey map[string][]*graph.GraphNode
+ loadedFiles map[string]struct{}
}
// ProcessGoSemantics uses the Go compiler's type information for facts that
@@ -81,13 +107,42 @@ func ProcessGoSemantics(repoPath string, g *graph.KnowledgeGraph) (GoSemanticSta
if err != nil {
return GoSemanticStats{}, fmt.Errorf("resolve repository path: %w", err)
}
- configuration := &packages.Config{
- Mode: packages.NeedName | packages.NeedFiles | packages.NeedCompiledGoFiles |
- packages.NeedSyntax | packages.NeedTypes | packages.NeedTypesInfo |
- packages.NeedImports | packages.NeedDeps | packages.NeedModule,
- Dir: absoluteRepoPath,
+ loaded, err := loadGoSemanticPackages(absoluteRepoPath)
+ if err != nil {
+ return GoSemanticStats{}, err
+ }
+ return processLoadedGoSemantics(absoluteRepoPath, g, loaded)
+}
+
+// ProcessGoCallSemanticsForFiles refreshes compiler-resolved CALLS whose
+// source belongs to a body-only changed file. Declaration, package, interface,
+// and import facts remain valid because structural fingerprinting routes those
+// changes through the complete semantic pass.
+func ProcessGoCallSemanticsForFiles(repoPath string, g *graph.KnowledgeGraph, files []string) (GoSemanticStats, error) {
+ return processGoCallSemanticsForFiles(repoPath, g, files, true)
+}
+
+// ProcessGoCallSemanticsForAffectedFiles loads test variants only when the
+// invalidation closure contains a test file. Resolved test callers of a
+// changed declaration are included by GraphDependencies, while ordinary
+// production changes avoid compiling every test variant in the package.
+func ProcessGoCallSemanticsForAffectedFiles(repoPath string, g *graph.KnowledgeGraph, files []string) (GoSemanticStats, error) {
+ loadTests := false
+ for _, file := range files {
+ if strings.HasSuffix(strings.ToLower(file), "_test.go") {
+ loadTests = true
+ break
+ }
}
- loaded, err := packages.Load(configuration, "./...")
+ return processGoCallSemanticsForFiles(repoPath, g, files, loadTests)
+}
+
+func processGoCallSemanticsForFiles(repoPath string, g *graph.KnowledgeGraph, files []string, loadTests bool) (GoSemanticStats, error) {
+ absoluteRepoPath, err := filepath.Abs(repoPath)
+ if err != nil {
+ return GoSemanticStats{}, fmt.Errorf("resolve repository path: %w", err)
+ }
+ loaded, err := loadChangedGoPackages(absoluteRepoPath, files, loadTests)
if err != nil {
return GoSemanticStats{}, err
}
@@ -95,7 +150,117 @@ func ProcessGoSemantics(repoPath string, g *graph.KnowledgeGraph) (GoSemanticSta
repoPath: absoluteRepoPath, graph: g,
objects: make(map[types.Object]string), positions: make(map[types.Object]token.Position), named: make(map[*types.Named]string),
interfaceOwner: make(map[*types.Func]string), callables: make(map[string][]goCallableRange),
- nodesByKey: make(map[string][]*graph.GraphNode),
+ nodesByKey: make(map[string][]*graph.GraphNode), loadedFiles: make(map[string]struct{}),
+ }
+ index.indexGraphNodes()
+ for _, pkg := range loaded {
+ if pkg.TypesInfo != nil && pkg.Fset != nil && len(pkg.Syntax) > 0 {
+ index.indexPackageDefinitions(pkg)
+ }
+ }
+ stats := GoSemanticStats{}
+ stats.PackageNodes = index.addLoadedPackageMemberships(loaded)
+ changed := make(map[string]bool, len(files))
+ for _, file := range files {
+ changed[filepath.ToSlash(file)] = true
+ }
+ sources := make(map[string]bool)
+ changedFileNodes := make(map[string]bool)
+ g.ForEachNode(func(node *graph.GraphNode) {
+ if changed[filepath.ToSlash(node.Properties.FilePath)] {
+ if node.Label == graph.LabelFunction || node.Label == graph.LabelMethod {
+ sources[node.ID] = true
+ }
+ if node.Label == graph.LabelFile {
+ changedFileNodes[node.ID] = true
+ }
+ }
+ })
+ g.RemoveRelationships(func(relationship *graph.GraphRelationship) bool {
+ if relationship.Type == graph.RelCALLS && sources[relationship.SourceID] && relationship.Reason != "visible-implicit-constructor" {
+ return true
+ }
+ if relationship.Type == graph.RelIMPORTS {
+ return changedFileNodes[relationship.SourceID]
+ }
+ return false
+ })
+ for _, pkg := range loaded {
+ resolved, unresolved := index.addCallsForFiles(pkg, changed)
+ stats.Calls += resolved
+ stats.UnresolvedCalls += unresolved
+ }
+ return stats, nil
+}
+
+// addLoadedPackageMemberships refreshes only package-to-file ownership for
+// packages selected by an incremental update. Unlike addPackageGraph it does
+// not compact repository-wide imports or infer ownership for build-tagged
+// files outside the selected packages.
+func (index *goSemanticIndex) addLoadedPackageMemberships(loaded []*packages.Package) int {
+ seen := make(map[string]bool)
+ loadedPackages := make(map[string]*packages.Package)
+ for _, pkg := range loaded {
+ if pkg.TypesInfo == nil || !index.packageBelongsToRepository(pkg) {
+ continue
+ }
+ packageID := "Package:" + pkg.PkgPath
+ loadedPackages[packageID] = pkg
+ directory := ""
+ files := make([]string, 0, len(pkg.CompiledGoFiles))
+ for _, filename := range pkg.CompiledGoFiles {
+ path, ok := index.relativePath(filename)
+ if !ok {
+ continue
+ }
+ files = append(files, path)
+ if directory == "" {
+ directory = filepath.ToSlash(filepath.Dir(path))
+ if directory == "." {
+ directory = ""
+ }
+ }
+ }
+ index.graph.AddNode(&graph.GraphNode{ID: packageID, Label: graph.LabelPackage, Properties: graph.NodeProperties{
+ Name: pkg.PkgPath, FilePath: directory, PackageName: pkg.Name, Language: "go",
+ }})
+ for _, path := range files {
+ key := packageID + "\x00" + path
+ if seen[key] {
+ continue
+ }
+ seen[key] = true
+ index.addRelationship(graph.RelCONTAINS, packageID, "File:"+path, 1, "go-package-membership")
+ }
+ }
+ // Replace imports owned by the selected packages only. Existing target
+ // package nodes remain authoritative for the rest of the repository.
+ index.graph.RemoveRelationships(func(relationship *graph.GraphRelationship) bool {
+ return relationship.Type == graph.RelIMPORTS && loadedPackages[relationship.SourceID] != nil
+ })
+ for sourceID, pkg := range loadedPackages {
+ for importedPath := range pkg.Imports {
+ targetID := "Package:" + importedPath
+ if targetID == sourceID {
+ continue
+ }
+ if _, exists := index.graph.GetNode(targetID); !exists {
+ continue
+ }
+ index.addRelationship(graph.RelIMPORTS, sourceID, targetID, 1, "go-package-import")
+ }
+ }
+ return len(seen)
+}
+
+func processLoadedGoSemantics(absoluteRepoPath string, g *graph.KnowledgeGraph, loaded []*packages.Package) (GoSemanticStats, error) {
+ removePreviousGoTypeInterfaceMethods(g)
+ index := &goSemanticIndex{
+ repoPath: absoluteRepoPath, graph: g,
+ objects: make(map[types.Object]string), positions: make(map[types.Object]token.Position), named: make(map[*types.Named]string),
+ interfaceOwner: make(map[*types.Func]string), callables: make(map[string][]goCallableRange),
+ nodesByKey: make(map[string][]*graph.GraphNode),
+ loadedFiles: make(map[string]struct{}),
}
index.indexGraphNodes()
stats := GoSemanticStats{}
@@ -107,14 +272,23 @@ func ProcessGoSemantics(repoPath string, g *graph.KnowledgeGraph) (GoSemanticSta
index.indexPackageDefinitions(pkg)
}
stats.PackageNodes, stats.PackageImports = index.addPackageGraph(loaded)
+ index.removeSupersededTypeRelationships()
stats.InterfaceMethods = index.addInterfaceMethods()
+ index.addInterfaceEmbeddings()
implements, methodImplements, dispatches := index.addImplementations(loaded)
stats.Implements, stats.MethodImplements, stats.Dispatches = implements, methodImplements, dispatches
// Replace syntax-heuristic Go CALLS with compiler-resolved facts. Facts for
// other languages remain untouched.
goSources := make(map[string]struct{})
+ goTypeNodes := make(map[string]struct{})
g.ForEachNode(func(node *graph.GraphNode) {
+ if node.Properties.Language == "go" {
+ switch node.Label {
+ case graph.LabelStruct:
+ goTypeNodes[node.ID] = struct{}{}
+ }
+ }
if _, semanticallyLoaded := index.callables[node.Properties.FilePath]; !semanticallyLoaded {
return
}
@@ -127,7 +301,14 @@ func ProcessGoSemantics(repoPath string, g *graph.KnowledgeGraph) (GoSemanticSta
return false
}
_, ok := goSources[relationship.SourceID]
- return ok
+ if !ok {
+ return false
+ }
+ _, constructorTarget := goTypeNodes[relationship.TargetID]
+ if constructorTarget && relationship.Reason == "visible-implicit-constructor" {
+ return false
+ }
+ return true
})
for _, pkg := range loaded {
if pkg.TypesInfo == nil || pkg.Fset == nil || len(pkg.Syntax) == 0 || !index.packageBelongsToRepository(pkg) {
@@ -140,6 +321,221 @@ func ProcessGoSemantics(repoPath string, g *graph.KnowledgeGraph) (GoSemanticSta
return stats, nil
}
+// Generated sources may be excluded from the syntax graph while go/types can
+// still expose their interfaces. Those synthetic method nodes must be rebuilt
+// from the current compiler result on every pass; otherwise a second pass can
+// resolve calls against state that did not exist during the first pass.
+func removePreviousGoTypeInterfaceMethods(g *graph.KnowledgeGraph) {
+ targets := make(map[string]bool)
+ g.ForEachRelationship(func(relationship *graph.GraphRelationship) {
+ if relationship.Type == graph.RelHAS_METHOD && relationship.Reason == "go-types-interface-method" {
+ targets[relationship.TargetID] = true
+ }
+ })
+ for target := range targets {
+ g.RemoveNode(target)
+ }
+}
+
+func loadChangedGoPackages(repoPath string, files []string, loadTests bool) ([]*packages.Package, error) {
+ modules := LoadGoModulePaths(repoPath)
+ if len(modules) == 0 {
+ modules = []GoModuleConfig{{ModuleDir: ""}}
+ }
+ patterns := make(map[int]map[string]bool)
+ for _, filename := range files {
+ file := strings.TrimPrefix(filepath.ToSlash(filepath.Clean(filename)), "./")
+ selected := -1
+ selectedDir := ""
+ for index, module := range modules {
+ dir := strings.Trim(filepath.ToSlash(module.ModuleDir), "/")
+ if dir == "" || file == dir || strings.HasPrefix(file, dir+"/") {
+ if selected < 0 || len(dir) > len(selectedDir) {
+ selected, selectedDir = index, dir
+ }
+ }
+ }
+ if selected < 0 {
+ continue
+ }
+ relative := file
+ if selectedDir != "" {
+ relative = strings.TrimPrefix(file, selectedDir+"/")
+ }
+ directory := filepath.ToSlash(filepath.Dir(relative))
+ pattern := "."
+ if directory != "." && directory != "" {
+ pattern = "./" + directory
+ }
+ if patterns[selected] == nil {
+ patterns[selected] = make(map[string]bool)
+ }
+ patterns[selected][pattern] = true
+ }
+ mode := packages.NeedName | packages.NeedFiles | packages.NeedCompiledGoFiles |
+ packages.NeedSyntax | packages.NeedTypes | packages.NeedTypesInfo |
+ packages.NeedImports | packages.NeedModule
+ result := make([]*packages.Package, 0)
+ for moduleIndex, set := range patterns {
+ modulePatterns := make([]string, 0, len(set))
+ for pattern := range set {
+ modulePatterns = append(modulePatterns, pattern)
+ }
+ sort.Strings(modulePatterns)
+ loaded, err := packages.Load(&packages.Config{
+ Mode: mode, Dir: filepath.Join(repoPath, filepath.FromSlash(modules[moduleIndex].ModuleDir)),
+ Tests: loadTests, Env: canonicalGoSemanticEnv(""),
+ }, modulePatterns...)
+ if err != nil {
+ return nil, fmt.Errorf("load changed Go packages: %w", err)
+ }
+ result = append(result, loaded...)
+ }
+ return result, nil
+}
+
+// loadGoSemanticPackages loads every repository module independently. Calling
+// packages.Load only at the repository root silently misses repositories whose
+// root is a workspace/container rather than a Go module (for example etcd).
+func loadGoSemanticPackages(repoPath string) ([]*packages.Package, error) {
+ modules := LoadGoModulePaths(repoPath)
+ if len(modules) == 0 {
+ modules = []GoModuleConfig{{ModuleDir: ""}}
+ }
+ mode := packages.NeedName | packages.NeedFiles | packages.NeedCompiledGoFiles |
+ packages.NeedSyntax | packages.NeedTypes | packages.NeedTypesInfo |
+ packages.NeedImports | packages.NeedDeps | packages.NeedModule
+ loadTests := countRepositoryGoFiles(repoPath, maxGoFilesForSemanticTests+1) <= maxGoFilesForSemanticTests
+ if len(modules) > 1 {
+ workspacePath, cleanup, err := createTemporaryGoWorkspace(repoPath, modules)
+ if err != nil {
+ return nil, err
+ }
+ defer cleanup()
+ patterns := make([]string, 0, len(modules))
+ for _, module := range modules {
+ if module.ModuleDir == "" {
+ patterns = append(patterns, "./...")
+ } else {
+ patterns = append(patterns, "./"+module.ModuleDir+"/...")
+ }
+ }
+ packagesInWorkspace, loadErr := packages.Load(&packages.Config{
+ Mode: mode, Dir: repoPath, Tests: loadTests, Env: canonicalGoSemanticEnv(workspacePath),
+ }, patterns...)
+ if loadErr != nil {
+ // Some repositories intentionally assign the same forbidden
+ // dependency different local replacements in different modules.
+ // Go rejects the combined workspace even when that dependency is
+ // unused. Retain compiler semantics by loading modules in isolation.
+ return loadGoSemanticModulesIndependently(repoPath, modules, mode, loadTests)
+ }
+ return packagesInWorkspace, nil
+ }
+ return loadGoSemanticModulesIndependently(repoPath, modules, mode, loadTests)
+}
+
+func loadGoSemanticModulesIndependently(
+ repoPath string,
+ modules []GoModuleConfig,
+ mode packages.LoadMode,
+ loadTests bool,
+) ([]*packages.Package, error) {
+ loaded := make([]*packages.Package, 0)
+ seen := make(map[string]struct{})
+ for _, module := range modules {
+ moduleDir := filepath.Join(repoPath, filepath.FromSlash(module.ModuleDir))
+ packagesInModule, loadErr := packages.Load(&packages.Config{
+ Mode: mode, Dir: moduleDir, Tests: loadTests, Env: canonicalGoSemanticEnv(""),
+ }, "./...")
+ if loadErr != nil {
+ return nil, fmt.Errorf("load Go module %q: %w", module.ModuleDir, loadErr)
+ }
+ for _, pkg := range packagesInModule {
+ // Nested modules can appear through local replace directives. Avoid
+ // processing the same compiler package twice.
+ key := pkg.ID
+ if key == "" {
+ key = pkg.PkgPath
+ }
+ if _, exists := seen[key]; exists {
+ continue
+ }
+ seen[key] = struct{}{}
+ loaded = append(loaded, pkg)
+ }
+ }
+ return loaded, nil
+}
+
+func createTemporaryGoWorkspace(repoPath string, modules []GoModuleConfig) (string, func(), error) {
+ file, err := os.CreateTemp("", "codetrip-go-work-*.work")
+ if err != nil {
+ return "", func() {}, fmt.Errorf("create temporary Go workspace: %w", err)
+ }
+ cleanup := func() { _ = os.Remove(file.Name()) }
+ var content strings.Builder
+ content.WriteString("go 1.26.0\n\nuse (\n")
+ for _, module := range modules {
+ content.WriteString("\t")
+ content.WriteString(filepath.ToSlash(filepath.Join(repoPath, filepath.FromSlash(module.ModuleDir))))
+ content.WriteString("\n")
+ }
+ content.WriteString(")\n")
+ if _, err := file.WriteString(content.String()); err != nil {
+ _ = file.Close()
+ cleanup()
+ return "", func() {}, fmt.Errorf("write temporary Go workspace: %w", err)
+ }
+ if err := file.Close(); err != nil {
+ cleanup()
+ return "", func() {}, fmt.Errorf("close temporary Go workspace: %w", err)
+ }
+ return file.Name(), cleanup, nil
+}
+
+func countRepositoryGoFiles(repoPath string, limit int) int {
+ count := 0
+ _ = filepath.WalkDir(repoPath, func(path string, entry os.DirEntry, walkErr error) error {
+ if walkErr != nil {
+ return nil
+ }
+ if entry.IsDir() {
+ switch entry.Name() {
+ case ".git", "node_modules", "vendor", "build", "dist":
+ if path != repoPath {
+ return filepath.SkipDir
+ }
+ }
+ return nil
+ }
+ if strings.HasSuffix(entry.Name(), ".go") {
+ count++
+ if limit > 0 && count >= limit {
+ return filepath.SkipAll
+ }
+ }
+ return nil
+ })
+ return count
+}
+
+func canonicalGoSemanticEnv(workspacePath string) []string {
+ environment := make([]string, 0, len(os.Environ())+2)
+ for _, value := range os.Environ() {
+ if strings.HasPrefix(value, "GOOS=") || strings.HasPrefix(value, "GOARCH=") ||
+ strings.HasPrefix(value, "GOWORK=") {
+ continue
+ }
+ environment = append(environment, value)
+ }
+ environment = append(environment, "GOOS=linux", "GOARCH=amd64")
+ if workspacePath != "" {
+ environment = append(environment, "GOWORK="+workspacePath)
+ }
+ return environment
+}
+
// addPackageGraph replaces Go's file-to-every-file import expansion with a
// compact persisted graph. The file-level resolution maps remain available to
// call resolution, while graph traversal uses Package --IMPORTS--> Package.
@@ -310,12 +706,24 @@ func (index *goSemanticIndex) findNode(path, name string, label graph.NodeLabel,
if len(candidates) == 0 {
return nil
}
+ var best *graph.GraphNode
for _, candidate := range candidates {
if candidate.Properties.StartLine != nil && *candidate.Properties.StartLine == line {
- return candidate
+ if best == nil || len(candidate.ID) < len(best.ID) || (len(candidate.ID) == len(best.ID) && candidate.ID < best.ID) {
+ best = candidate
+ }
}
}
- return candidates[0]
+ if best != nil {
+ return best
+ }
+ best = candidates[0]
+ for _, candidate := range candidates[1:] {
+ if len(candidate.ID) < len(best.ID) || (len(candidate.ID) == len(best.ID) && candidate.ID < best.ID) {
+ best = candidate
+ }
+ }
+ return best
}
func (index *goSemanticIndex) indexPackageDefinitions(pkg *packages.Package) {
@@ -343,7 +751,27 @@ func (index *goSemanticIndex) indexPackageDefinitions(pkg *packages.Package) {
default:
continue
}
- if node := index.findNode(path, value.Name(), label, position.Line-1); node != nil {
+ node := index.findNode(path, value.Name(), label, position.Line)
+ if node == nil && label == graph.LabelInterface {
+ // Generated RPC/protobuf sources are intentionally skipped by
+ // the syntax index, but their server interfaces are essential
+ // contracts for handwritten implementations. Preserve the
+ // local compiler-known interface as a semantic graph node.
+ line, end := position.Line, position.Line
+ exported := value.Exported()
+ node = &graph.GraphNode{
+ ID: "Interface:" + path + ":" + value.Name(),
+ Label: graph.LabelInterface,
+ Properties: graph.NodeProperties{
+ Name: value.Name(), FilePath: path, StartLine: &line,
+ EndLine: &end, IsExported: &exported, Language: "go",
+ },
+ }
+ index.graph.AddNode(node)
+ index.nodesByKey[semanticNodeKey(path, value.Name(), label)] =
+ append(index.nodesByKey[semanticNodeKey(path, value.Name(), label)], node)
+ }
+ if node != nil {
index.objects[value] = node.ID
index.named[named] = node.ID
if iface, ok := named.Underlying().(*types.Interface); ok {
@@ -359,7 +787,7 @@ func (index *goSemanticIndex) indexPackageDefinitions(pkg *packages.Package) {
if signature, ok := value.Type().(*types.Signature); ok && signature.Recv() != nil {
label = graph.LabelMethod
}
- if node := index.findNode(path, value.Name(), label, position.Line-1); node != nil {
+ if node := index.findNode(path, value.Name(), label, position.Line); node != nil {
index.objects[value] = node.ID
}
}
@@ -370,6 +798,7 @@ func (index *goSemanticIndex) indexPackageDefinitions(pkg *packages.Package) {
if !ok {
continue
}
+ index.loadedFiles[path] = struct{}{}
for _, declaration := range file.Decls {
function, ok := declaration.(*ast.FuncDecl)
if !ok || function.Name == nil {
@@ -384,6 +813,33 @@ func (index *goSemanticIndex) indexPackageDefinitions(pkg *packages.Package) {
}
}
+func (index *goSemanticIndex) removeSupersededTypeRelationships() {
+ loadedNodeIDs := make(map[string]struct{})
+ loadedInterfaceIDs := make(map[string]struct{})
+ index.graph.ForEachNode(func(node *graph.GraphNode) {
+ if _, loaded := index.loadedFiles[node.Properties.FilePath]; loaded {
+ loadedNodeIDs[node.ID] = struct{}{}
+ }
+ })
+ for named, nodeID := range index.named {
+ if _, ok := named.Underlying().(*types.Interface); ok {
+ loadedInterfaceIDs[nodeID] = struct{}{}
+ }
+ }
+ index.graph.RemoveRelationships(func(relationship *graph.GraphRelationship) bool {
+ switch relationship.Type {
+ case graph.RelIMPLEMENTS, graph.RelMETHOD_IMPLEMENTS, graph.RelDISPATCHES_TO:
+ case graph.RelHAS_METHOD:
+ _, loadedInterface := loadedInterfaceIDs[relationship.SourceID]
+ return loadedInterface
+ default:
+ return false
+ }
+ _, loaded := loadedNodeIDs[relationship.SourceID]
+ return loaded
+ })
+}
+
func (index *goSemanticIndex) addInterfaceMethods() int {
added := 0
for named, interfaceID := range index.named {
@@ -392,25 +848,21 @@ func (index *goSemanticIndex) addInterfaceMethods() int {
continue
}
iface.Complete()
- if iface.NumMethods() == 0 {
+ if iface.NumExplicitMethods() == 0 {
continue
}
- for methodIndex := 0; methodIndex < iface.NumMethods(); methodIndex++ {
- method := iface.Method(methodIndex)
- if _, exists := index.objects[method]; exists {
- index.interfaceOwner[method] = interfaceID
- continue
- }
+ owner, ok := index.graph.GetNode(interfaceID)
+ if !ok {
+ continue
+ }
+ for methodIndex := 0; methodIndex < iface.NumExplicitMethods(); methodIndex++ {
+ method := iface.ExplicitMethod(methodIndex)
position := index.positions[method]
path, ok := index.relativePath(position.Filename)
if !ok {
continue
}
- owner, ok := index.graph.GetNode(interfaceID)
- if !ok {
- continue
- }
- line := position.Line - 1
+ line := position.Line
end := line
exported := method.Exported()
node := &graph.GraphNode{
@@ -428,6 +880,56 @@ func (index *goSemanticIndex) addInterfaceMethods() int {
return added
}
+func (index *goSemanticIndex) addInterfaceEmbeddings() int {
+ added := 0
+ for named, interfaceID := range index.named {
+ iface, ok := named.Underlying().(*types.Interface)
+ if !ok {
+ continue
+ }
+ for embeddedIndex := 0; embeddedIndex < iface.NumEmbeddeds(); embeddedIndex++ {
+ embeddedID := index.namedTypeNodeID(iface.EmbeddedType(embeddedIndex))
+ if embeddedID == "" || embeddedID == interfaceID {
+ continue
+ }
+ index.addRelationship(graph.RelEXTENDS, interfaceID, embeddedID, 1, "go-types-embedded-interface")
+ added++
+ }
+ }
+ return added
+}
+
+func (index *goSemanticIndex) namedTypeNodeID(value types.Type) string {
+ if pointer, ok := value.(*types.Pointer); ok {
+ value = pointer.Elem()
+ }
+ named, ok := value.(*types.Named)
+ if !ok || named.Obj() == nil {
+ return ""
+ }
+ if nodeID := index.named[named]; nodeID != "" {
+ return nodeID
+ }
+ name := named.Obj().Name()
+ packagePath := ""
+ if named.Obj().Pkg() != nil {
+ packagePath = named.Obj().Pkg().Path()
+ }
+ for candidate, nodeID := range index.named {
+ if candidate.Obj() == nil || candidate.Obj().Name() != name {
+ continue
+ }
+ candidatePath := ""
+ if candidate.Obj().Pkg() != nil {
+ candidatePath = candidate.Obj().Pkg().Path()
+ }
+ if candidatePath == packagePath {
+ return nodeID
+ }
+ }
+ return ""
+}
+
func (index *goSemanticIndex) addImplementations(_ []*packages.Package) (int, int, int) {
structs := make([]*types.Named, 0)
interfaces := make([]*types.Named, 0)
@@ -451,18 +953,25 @@ func (index *goSemanticIndex) addImplementations(_ []*packages.Package) (int, in
}
candidate := types.Type(concrete)
pointer := false
- if !types.Implements(candidate, iface) {
+ crossLoad := false
+ if !types.Implements(candidate, iface) && !implementsInterfaceByQualifiedSignature(candidate, iface) {
candidate = types.NewPointer(concrete)
pointer = true
- if !types.Implements(candidate, iface) {
+ if !types.Implements(candidate, iface) && !implementsInterfaceByQualifiedSignature(candidate, iface) {
continue
}
+ crossLoad = !types.Implements(candidate, iface)
+ } else {
+ crossLoad = !types.Implements(candidate, iface)
}
concreteID, interfaceID := index.named[concrete], index.named[abstract]
reason := "go-types-method-set"
if pointer {
reason = "go-types-pointer-method-set"
}
+ if crossLoad {
+ reason += "-qualified-signature"
+ }
index.addRelationship(graph.RelIMPLEMENTS, concreteID, interfaceID, 1, reason)
implements++
for methodIndex := 0; methodIndex < iface.NumMethods(); methodIndex++ {
@@ -486,7 +995,60 @@ func (index *goSemanticIndex) addImplementations(_ []*packages.Package) (int, in
return implements, methodImplements, dispatches
}
+// implementsInterfaceByQualifiedSignature reconciles types loaded in separate
+// module operations. go/types intentionally treats their object identities as
+// distinct even when package paths and declarations are identical; comparing
+// fully qualified method signatures preserves the same semantic constraint
+// without falling back to name-only matching.
+func implementsInterfaceByQualifiedSignature(candidate types.Type, iface *types.Interface) bool {
+ iface.Complete()
+ for index := 0; index < iface.NumMethods(); index++ {
+ required := iface.Method(index)
+ object, _, _ := types.LookupFieldOrMethod(candidate, true, required.Pkg(), required.Name())
+ method, ok := object.(*types.Func)
+ if !ok || qualifiedMethodSignature(method) != qualifiedMethodSignature(required) {
+ return false
+ }
+ }
+ return true
+}
+
+func qualifiedMethodSignature(method *types.Func) string {
+ signature, ok := method.Type().(*types.Signature)
+ if !ok {
+ return ""
+ }
+ qualifier := func(pkg *types.Package) string {
+ if pkg == nil {
+ return ""
+ }
+ return pkg.Path()
+ }
+ var result strings.Builder
+ if signature.Variadic() {
+ result.WriteString("variadic:")
+ }
+ for index := 0; index < signature.Params().Len(); index++ {
+ if index > 0 {
+ result.WriteByte(',')
+ }
+ result.WriteString(types.TypeString(signature.Params().At(index).Type(), qualifier))
+ }
+ result.WriteString("->")
+ for index := 0; index < signature.Results().Len(); index++ {
+ if index > 0 {
+ result.WriteByte(',')
+ }
+ result.WriteString(types.TypeString(signature.Results().At(index).Type(), qualifier))
+ }
+ return result.String()
+}
+
func (index *goSemanticIndex) addCalls(pkg *packages.Package) (int, int) {
+ return index.addCallsForFiles(pkg, nil)
+}
+
+func (index *goSemanticIndex) addCallsForFiles(pkg *packages.Package, selected map[string]bool) (int, int) {
resolved, unresolved := 0, 0
for _, file := range pkg.Syntax {
position := pkg.Fset.Position(file.Pos())
@@ -494,6 +1056,9 @@ func (index *goSemanticIndex) addCalls(pkg *packages.Package) (int, int) {
if !ok {
continue
}
+ if selected != nil && !selected[path] {
+ continue
+ }
ast.Inspect(file, func(node ast.Node) bool {
call, ok := node.(*ast.CallExpr)
if !ok {
@@ -508,7 +1073,7 @@ func (index *goSemanticIndex) addCalls(pkg *packages.Package) (int, int) {
if callee == nil {
return true
}
- target := index.objects[callee]
+ target := index.callableNodeID(pkg.Fset, callee)
if target == "" {
unresolved++
return true
@@ -521,6 +1086,28 @@ func (index *goSemanticIndex) addCalls(pkg *packages.Package) (int, int) {
return resolved, unresolved
}
+func (index *goSemanticIndex) callableNodeID(fileSet *token.FileSet, function *types.Func) string {
+ if nodeID := index.objects[function]; nodeID != "" {
+ return nodeID
+ }
+ // Loading nested modules independently can produce distinct types.Object
+ // instances for the same locally replaced dependency. Source position and
+ // declaration kind remain stable across those package loads.
+ position := fileSet.Position(function.Pos())
+ path, ok := index.relativePath(position.Filename)
+ if !ok {
+ return ""
+ }
+ label := graph.LabelFunction
+ if signature, ok := function.Type().(*types.Signature); ok && signature.Recv() != nil {
+ label = graph.LabelMethod
+ }
+ if node := index.findNode(path, function.Name(), label, position.Line); node != nil {
+ return node.ID
+ }
+ return ""
+}
+
func (index *goSemanticIndex) enclosingCallable(path string, position token.Pos) string {
best := ""
bestSize := int(^uint(0) >> 1)
diff --git a/internal/ingest/go_semantic_test.go b/internal/ingest/go_semantic_test.go
index bda0655..b861e84 100644
--- a/internal/ingest/go_semantic_test.go
+++ b/internal/ingest/go_semantic_test.go
@@ -1,6 +1,8 @@
package ingest
import (
+ "go/token"
+ "go/types"
"os"
"path/filepath"
"strings"
@@ -32,6 +34,8 @@ func Build() error {
service := &Service{Repo: repository}
return service.Run()
}
+type Local struct{}
+func NewLocal() *Local { return &Local{} }
`,
}
for name, content := range files {
@@ -54,6 +58,7 @@ func Build() error {
"DISPATCHES_TO:Method:contract.go:Repo.Create->Method:repository.go:Create": false,
"CALLS:Method:service.go:Run->Method:contract.go:Repo.Create": false,
"CALLS:Function:wire.go:Build->Method:service.go:Run": false,
+ "CALLS:Function:wire.go:NewLocal->Struct:wire.go:Local": false,
"IMPORTS:Package:example.com/semanticfixture->Package:example.com/semanticfixture/helper": false,
}
result.Graph.ForEachRelationship(func(relationship *graph.GraphRelationship) {
@@ -73,3 +78,281 @@ func Build() error {
}
})
}
+
+func TestCanonicalGoSemanticEnvironmentIsHostIndependent(t *testing.T) {
+ t.Setenv("GOOS", "windows")
+ t.Setenv("GOARCH", "arm64")
+ environment := canonicalGoSemanticEnv("/tmp/codetrip-test.work")
+ joined := strings.Join(environment, "\n")
+ if !strings.Contains(joined, "GOOS=linux") || !strings.Contains(joined, "GOARCH=amd64") {
+ t.Fatalf("canonical Go environment missing target: %q", joined)
+ }
+ if strings.Contains(joined, "GOOS=windows") || strings.Contains(joined, "GOARCH=arm64") {
+ t.Fatalf("canonical Go environment retained host target: %q", joined)
+ }
+ if !strings.Contains(joined, "GOWORK=/tmp/codetrip-test.work") {
+ t.Fatalf("canonical Go environment missing workspace: %q", joined)
+ }
+}
+
+func TestCountRepositoryGoFilesIsBoundedAndExcludesDependencies(t *testing.T) {
+ repository := t.TempDir()
+ for _, name := range []string{"a.go", "pkg/b.go", "pkg/c.go", "vendor/external.go"} {
+ path := filepath.Join(repository, name)
+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte("package fixture\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if count := countRepositoryGoFiles(repository, 2); count != 2 {
+ t.Fatalf("bounded Go file count = %d, want 2", count)
+ }
+ if count := countRepositoryGoFiles(repository, 0); count != 3 {
+ t.Fatalf("Go file count = %d, want 3", count)
+ }
+}
+
+func TestGoSemanticFallsBackWhenWorkspaceReplacementsConflict(t *testing.T) {
+ repository := t.TempDir()
+ for _, module := range []string{"first", "second"} {
+ directory := filepath.Join(repository, module)
+ if err := os.MkdirAll(directory, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ goMod := "module example.com/" + module +
+ "\n\ngo 1.26\n\nreplace example.com/forbidden => ./" + module + "-forbidden\n"
+ if err := os.WriteFile(filepath.Join(directory, "go.mod"), []byte(goMod), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(directory, "main.go"), []byte("package "+module+"\nfunc Work() {}\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ loaded, err := loadGoSemanticPackages(repository)
+ if err != nil {
+ t.Fatalf("load modules with conflicting replacements: %v", err)
+ }
+ found := map[string]bool{}
+ for _, pkg := range loaded {
+ if pkg.PkgPath == "example.com/first" || pkg.PkgPath == "example.com/second" {
+ found[pkg.PkgPath] = true
+ }
+ }
+ if !found["example.com/first"] || !found["example.com/second"] {
+ t.Fatalf("isolated module fallback did not load both packages: %#v", found)
+ }
+}
+
+func TestGoSemanticRemovesNameOnlyFalseImplementation(t *testing.T) {
+ repository := t.TempDir()
+ files := map[string]string{
+ "go.mod": "module example.com/semanticfalse\n\ngo 1.26\n",
+ "contract/contract.go": `package contract
+import "context"
+type Cluster interface { MemberList(context.Context) }
+`,
+ "implementation/implementation.go": `package implementation
+type Cluster struct{}
+type integrationCluster struct { Cluster }
+`,
+ }
+ for name, content := range files {
+ path := filepath.Join(repository, name)
+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ result, err := NewPipeline(repository, "", false).Run()
+ if err != nil {
+ t.Fatal(err)
+ }
+ result.Graph.ForEachRelationship(func(relationship *graph.GraphRelationship) {
+ if relationship.Type == graph.RelIMPLEMENTS &&
+ relationship.SourceID == "Struct:implementation/implementation.go:integrationCluster" &&
+ relationship.TargetID == "Interface:contract/contract.go:Cluster" {
+ t.Errorf("retained name-only false implementation: %#v", relationship)
+ }
+ })
+}
+
+func TestGoSemanticPreservesGeneratedInterfaceContract(t *testing.T) {
+ repository := t.TempDir()
+ files := map[string]string{
+ "go.mod": "module example.com/generatedcontract\n\ngo 1.26\n",
+ "rpc.generated.go": `package generatedcontract
+type MaintenanceServer interface { Status() error }
+`,
+ "server.go": `package generatedcontract
+type maintenanceServer struct{}
+func (*maintenanceServer) Status() error { return nil }
+`,
+ }
+ for name, content := range files {
+ path := filepath.Join(repository, name)
+ if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ result, err := NewPipeline(repository, "", false).Run()
+ if err != nil {
+ t.Fatal(err)
+ }
+ interfaceNode, ok := result.Graph.GetNode("Interface:rpc.generated.go:MaintenanceServer")
+ if !ok {
+ t.Fatal("compiler-known generated interface was not represented")
+ }
+ if interfaceNode.Properties.StartLine == nil || *interfaceNode.Properties.StartLine != 2 {
+ t.Fatalf("generated interface line = %#v, want 2", interfaceNode.Properties.StartLine)
+ }
+ found := false
+ result.Graph.ForEachRelationship(func(relationship *graph.GraphRelationship) {
+ if relationship.Type == graph.RelIMPLEMENTS &&
+ relationship.SourceID == "Struct:server.go:maintenanceServer" &&
+ relationship.TargetID == interfaceNode.ID {
+ found = true
+ }
+ })
+ if !found {
+ t.Fatal("handwritten implementation is missing its generated interface contract")
+ }
+}
+
+func TestQualifiedSignatureReconcilesSeparatePackageLoads(t *testing.T) {
+ first := types.NewPackage("example.com/api", "api")
+ second := types.NewPackage("example.com/api", "api")
+ firstRequest := types.NewNamed(types.NewTypeName(token.NoPos, first, "Request", nil), types.NewStruct(nil, nil), nil)
+ secondRequest := types.NewNamed(types.NewTypeName(token.NoPos, second, "Request", nil), types.NewStruct(nil, nil), nil)
+
+ requiredSignature := types.NewSignatureType(nil, nil, nil,
+ types.NewTuple(types.NewVar(token.NoPos, first, "request", types.NewPointer(firstRequest))),
+ types.NewTuple(types.NewVar(token.NoPos, nil, "", types.Universe.Lookup("error").Type())), false)
+ required := types.NewFunc(token.NoPos, first, "Status", requiredSignature)
+ iface := types.NewInterfaceType([]*types.Func{required}, nil)
+ iface.Complete()
+
+ concrete := types.NewNamed(types.NewTypeName(token.NoPos, second, "server", nil), types.NewStruct(nil, nil), nil)
+ implementedSignature := types.NewSignatureType(
+ types.NewVar(token.NoPos, second, "", types.NewPointer(concrete)), nil, nil,
+ types.NewTuple(types.NewVar(token.NoPos, second, "request", types.NewPointer(secondRequest))),
+ types.NewTuple(types.NewVar(token.NoPos, nil, "", types.Universe.Lookup("error").Type())), false)
+ concrete.AddMethod(types.NewFunc(token.NoPos, second, "Status", implementedSignature))
+
+ if types.Implements(types.NewPointer(concrete), iface) {
+ t.Fatal("fixture unexpectedly shares go/types object identity")
+ }
+ if !implementsInterfaceByQualifiedSignature(types.NewPointer(concrete), iface) {
+ t.Fatal("identical package-qualified signatures were not reconciled")
+ }
+}
+
+func TestGoEmbeddedInterfaceMethodKeepsDeclaringOwner(t *testing.T) {
+ repository := t.TempDir()
+ files := map[string]string{
+ "go.mod": "module example.com/interfaceowner\n\ngo 1.26\n",
+ "contract.go": `package interfaceowner
+type Root interface { Run() }
+type Embedded interface { Root }
+func Invoke(value Embedded) { value.Run() }
+`,
+ }
+ for name, content := range files {
+ if err := os.WriteFile(filepath.Join(repository, name), []byte(content), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ result, err := NewPipeline(repository, "", false).Run()
+ if err != nil {
+ t.Fatal(err)
+ }
+ foundCall, foundOwner, foundEmbedding := false, false, false
+ result.Graph.ForEachRelationship(func(relationship *graph.GraphRelationship) {
+ if relationship.Type == graph.RelCALLS &&
+ relationship.SourceID == "Function:contract.go:Invoke" &&
+ relationship.TargetID == "Method:contract.go:Root.Run" {
+ foundCall = true
+ }
+ if relationship.Type == graph.RelHAS_METHOD &&
+ relationship.SourceID == "Interface:contract.go:Root" &&
+ relationship.TargetID == "Method:contract.go:Root.Run" {
+ foundOwner = true
+ }
+ if relationship.Type == graph.RelEXTENDS &&
+ relationship.SourceID == "Interface:contract.go:Embedded" &&
+ relationship.TargetID == "Interface:contract.go:Root" {
+ foundEmbedding = true
+ }
+ if relationship.Type == graph.RelHAS_METHOD &&
+ relationship.SourceID == "Interface:contract.go:Embedded" &&
+ strings.HasSuffix(relationship.TargetID, ".Run") {
+ t.Errorf("promoted method was incorrectly redeclared on Embedded: %s", relationship.ID)
+ }
+ })
+ if !foundCall || !foundOwner || !foundEmbedding {
+ t.Fatalf("canonical interface method: call=%v owner=%v embedding=%v", foundCall, foundOwner, foundEmbedding)
+ }
+}
+
+func TestPipelineResolvesQualifiedCallsAcrossNestedGoModules(t *testing.T) {
+ repository := t.TempDir()
+ files := map[string]string{
+ "server/go.mod": "module example.com/server\n\ngo 1.26\n",
+ "server/api/server.go": `package api
+func NewServer() {}
+`,
+ "server/api/server_test.go": `package api
+import "testing"
+func TestNewServer(t *testing.T) { NewServer() }
+`,
+ "server/embed/embed.go": `package embed
+import "example.com/server/api"
+func Start() { api.NewServer() }
+`,
+ "tools/go.mod": "module example.com/tools\n\ngo 1.26\n\nrequire example.com/server v0.0.0\nreplace example.com/server => ../server\n",
+ "tools/run.go": `package tools
+import "example.com/server/api"
+func Run() { api.NewServer() }
+`,
+ }
+ for name, content := range files {
+ path := filepath.Join(repository, name)
+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ result, err := NewPipeline(repository, "", false).Run()
+ if err != nil {
+ t.Fatal(err)
+ }
+ expected := map[string]bool{
+ "CALLS:Function:server/embed/embed.go:Start->Function:server/api/server.go:NewServer": false,
+ "CALLS:Function:server/api/server_test.go:TestNewServer->Function:server/api/server.go:NewServer": false,
+ "CALLS:Function:tools/run.go:Run->Function:server/api/server.go:NewServer": false,
+ }
+ serverCallReason := ""
+ result.Graph.ForEachRelationship(func(relationship *graph.GraphRelationship) {
+ key := string(relationship.Type) + ":" + relationship.SourceID + "->" + relationship.TargetID
+ if _, ok := expected[key]; ok {
+ expected[key] = true
+ }
+ if key == "CALLS:Function:server/embed/embed.go:Start->Function:server/api/server.go:NewServer" {
+ serverCallReason = relationship.Reason
+ }
+ })
+ for relationship, found := range expected {
+ if !found {
+ t.Errorf("missing nested-module relationship %s", relationship)
+ }
+ }
+ if serverCallReason != "go-types-call-target" {
+ t.Errorf("nested module was not compiler-resolved: reason=%q", serverCallReason)
+ }
+}
diff --git a/internal/ingest/import_processor.go b/internal/ingest/import_processor.go
index c386dd2..84e58cd 100644
--- a/internal/ingest/import_processor.go
+++ b/internal/ingest/import_processor.go
@@ -4,6 +4,7 @@ import (
"log"
"path/filepath"
"regexp"
+ "sort"
"strings"
graph "github.com/mengshi02/codetrip/internal/model"
@@ -295,10 +296,16 @@ func resolveGoPackageDir(raw string, cfg *GoModuleConfig) string {
}
rel := strings.TrimPrefix(raw, cfg.ModulePath)
if rel == "" {
- return "" // self-package import: skip
+ if cfg.ModuleDir == "" {
+ return "."
+ }
+ return pathNorm(cfg.ModuleDir)
}
rel = strings.TrimPrefix(rel, "/")
- return rel
+ if cfg.ModuleDir == "" {
+ return rel
+ }
+ return pathNorm(cfg.ModuleDir + "/" + rel)
}
func resolveGoPackage(raw string, cfg *GoModuleConfig, nfl, afl []string) []string {
@@ -622,24 +629,85 @@ func resolveImportPath(fp string, raw string, lang string, cfgs *LanguageConfigs
return &ImportResult{Kind: ImportResultFiles, Files: []string{r}}
}
- // Step 1: TypeScript path alias rewrite
- if cfgs != nil && cfgs.TsconfigPaths != nil && len(cfgs.TsconfigPaths.Aliases) > 0 {
- for alias, target := range cfgs.TsconfigPaths.Aliases {
- if strings.HasPrefix(raw, alias) {
- rewritten := strings.Replace(raw, alias, target, 1)
- base := rewritten
- if cfgs.TsconfigPaths.BaseURL != "" {
- base = cfgs.TsconfigPaths.BaseURL + "/" + rewritten
+ // Step 1: TypeScript path alias rewrite. Monorepos can define independent
+ // aliases in nested packages; try the closest owning config first.
+ if cfgs != nil {
+ pathSets := cfgs.TsconfigPathSets
+ if len(pathSets) == 0 && cfgs.TsconfigPaths != nil {
+ pathSets = []TsconfigPaths{*cfgs.TsconfigPaths}
+ }
+ for _, config := range pathSets {
+ if config.ConfigDir != "" &&
+ fp != config.ConfigDir &&
+ !strings.HasPrefix(fp, strings.TrimSuffix(config.ConfigDir, "/")+"/") {
+ continue
+ }
+ aliases := make([]string, 0, len(config.Aliases))
+ for alias := range config.Aliases {
+ aliases = append(aliases, alias)
+ }
+ sort.Slice(aliases, func(i, j int) bool {
+ if len(aliases[i]) != len(aliases[j]) {
+ return len(aliases[i]) > len(aliases[j])
+ }
+ return aliases[i] < aliases[j]
+ })
+ for _, alias := range aliases {
+ if !strings.HasPrefix(raw, alias) {
+ continue
+ }
+ rewritten := config.Aliases[alias] + strings.TrimPrefix(raw, alias)
+ base := pathNorm(filepath.ToSlash(filepath.Join(config.ConfigDir, config.BaseURL, rewritten)))
+ if resolved := tryResolveIdx(base, rctx); resolved != "" {
+ rctx.ResolveCache[fp+"::"+raw] = resolved
+ return &ImportResult{Kind: ImportResultFiles, Files: []string{resolved}}
+ }
+ }
+ }
+ }
+
+ // Step 2: local JavaScript/TypeScript workspace package resolution.
+ if cfgs != nil && (lang == "javascript" || lang == "typescript" || lang == "tsx") {
+ names := make([]string, 0, len(cfgs.WorkspacePackages))
+ for name := range cfgs.WorkspacePackages {
+ names = append(names, name)
+ }
+ sort.Slice(names, func(i, j int) bool {
+ if len(names[i]) != len(names[j]) {
+ return len(names[i]) > len(names[j])
+ }
+ return names[i] < names[j]
+ })
+ for _, name := range names {
+ if raw != name && !strings.HasPrefix(raw, name+"/") {
+ continue
+ }
+ directory := cfgs.WorkspacePackages[name]
+ subpath := strings.TrimPrefix(raw, name)
+ var candidates []string
+ if subpath == "" {
+ candidates = []string{
+ filepath.ToSlash(filepath.Join(directory, "src", "index")),
+ filepath.ToSlash(filepath.Join(directory, "index")),
}
- if r := tryResolveIdx(base, rctx); r != "" {
- rctx.ResolveCache[fp+"::"+raw] = r
- return &ImportResult{Kind: ImportResultFiles, Files: []string{r}}
+ } else {
+ subpath = strings.TrimPrefix(subpath, "/")
+ candidates = []string{
+ filepath.ToSlash(filepath.Join(directory, "src", subpath)),
+ filepath.ToSlash(filepath.Join(directory, subpath)),
+ }
+ }
+ for _, candidate := range candidates {
+ if resolved := tryResolveIdx(candidate, rctx); resolved != "" {
+ rctx.ResolveCache[fp+"::"+raw] = resolved
+ return &ImportResult{Kind: ImportResultFiles, Files: []string{resolved}}
}
}
+ return nil
}
}
- // Step 2: Rust crate::/self::/super:: resolution
+ // Step 3: Rust crate::/self::/super:: resolution
if strings.HasPrefix(raw, "crate::") || strings.HasPrefix(raw, "self::") || strings.HasPrefix(raw, "super::") {
if r := resolveRustImport(fp, raw, rctx.AllFilePaths); r != "" {
rctx.ResolveCache[fp+"::"+raw] = r
@@ -647,7 +715,7 @@ func resolveImportPath(fp string, raw string, lang string, cfgs *LanguageConfigs
}
}
- // Step 3: Python relative import
+ // Step 4: Python relative import
if strings.HasPrefix(raw, ".") {
if r := resolvePythonRel(fp, raw, rctx); r != "" {
rctx.ResolveCache[fp+"::"+raw] = r
@@ -655,7 +723,7 @@ func resolveImportPath(fp string, raw string, lang string, cfgs *LanguageConfigs
}
}
- // Step 4: Generic relative (./ or ../)
+ // Step 5: Generic relative (./ or ../)
if raw == "." || strings.HasPrefix(raw, "./") || strings.HasPrefix(raw, "../") {
dir := filepath.Dir(fp)
clean := pathNorm(dir + "/" + raw)
@@ -696,6 +764,29 @@ func resolveImportPath(fp string, raw string, lang string, cfgs *LanguageConfigs
}
func tryResolveRelativeExact(base string, rctx *ResolveCtx) string {
+ if rctx.AllFilePaths[base] {
+ return base
+ }
+ // NodeNext TypeScript sources conventionally import their emitted .js
+ // path. During source analysis that specifier must bind to the matching
+ // TypeScript file without enabling broad suffix guessing.
+ for emitted, sources := range map[string][]string{
+ ".js": {".ts", ".tsx"},
+ ".jsx": {".tsx", ".ts"},
+ ".mjs": {".mts"},
+ ".cjs": {".cts"},
+ } {
+ if !strings.HasSuffix(base, emitted) {
+ continue
+ }
+ stem := strings.TrimSuffix(base, emitted)
+ for _, sourceExtension := range sources {
+ if candidate := stem + sourceExtension; rctx.AllFilePaths[candidate] {
+ return candidate
+ }
+ }
+ return ""
+ }
for _, ext := range ResolveExtensions {
if rctx.AllFilePaths[base+ext] {
return base + ext
@@ -822,10 +913,20 @@ func resolveLanguageImport(fp string, raw string, lang string, cfgs *LanguageCon
return &ImportResult{Kind: ImportResultFiles, Files: []string{r}}
}
case "go":
- if cfgs != nil && cfgs.GoModule != nil && strings.HasPrefix(raw, cfgs.GoModule.ModulePath) {
- if files := resolveGoPackage(raw, cfgs.GoModule, rctx.NormalizedFileList, rctx.AllFileList); len(files) > 0 {
- ds := resolveGoPackageDir(raw, cfgs.GoModule)
- return &ImportResult{Kind: ImportResultPackage, Files: files, DirSuffix: ds}
+ if cfgs != nil {
+ modules := cfgs.GoModules
+ if len(modules) == 0 && cfgs.GoModule != nil {
+ modules = []GoModuleConfig{*cfgs.GoModule}
+ }
+ for index := range modules {
+ module := &modules[index]
+ if raw != module.ModulePath && !strings.HasPrefix(raw, module.ModulePath+"/") {
+ continue
+ }
+ if files := resolveGoPackage(raw, module, rctx.NormalizedFileList, rctx.AllFileList); len(files) > 0 {
+ ds := resolveGoPackageDir(raw, module)
+ return &ImportResult{Kind: ImportResultPackage, Files: files, DirSuffix: ds}
+ }
}
}
case "csharp":
@@ -1113,7 +1214,7 @@ func ProcessImportsFromExtracted(
rctx *ResolveCtx,
order ImportOrderMap,
) {
- log.Printf("[import-processor] ProcessImportsFromExtracted: %d captures, GoModule=%v", len(captures), cfgs.GoModule)
+ log.Printf("[import-processor] ProcessImportsFromExtracted: %d captures, GoModules=%d", len(captures), len(cfgs.GoModules))
for _, c := range captures {
ir := resolveLanguageImport(c.FilePath, c.ImportPath, c.Language, cfgs, rctx)
applyImportResult(g, c.FilePath, ir, im, pm, nim)
diff --git a/internal/ingest/import_processor_test.go b/internal/ingest/import_processor_test.go
index 3fe531e..b71f2f5 100644
--- a/internal/ingest/import_processor_test.go
+++ b/internal/ingest/import_processor_test.go
@@ -1,11 +1,38 @@
package ingest
import (
+ "os"
+ "path/filepath"
"testing"
graph "github.com/mengshi02/codetrip/internal/model"
)
+func TestTypeScriptAliasCallInDefaultParameter(t *testing.T) {
+ repository := t.TempDir()
+ files := map[string]string{
+ "config.ts": "export function getAgentDir(): string { return '/tmp'; }\n",
+ "session-manager.ts": "import { getAgentDir as getDefaultAgentDir } from './config.ts';\nexport function getDefaultSessionDir(agentDir: string = getDefaultAgentDir()): string { return agentDir; }\n",
+ }
+ for name, content := range files {
+ if err := os.WriteFile(filepath.Join(repository, name), []byte(content), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ result, err := NewPipeline(repository, "", false).Run()
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, relation := range result.Graph.Relationships() {
+ if relation.Type == graph.RelCALLS &&
+ relation.SourceID == "Function:session-manager.ts:getDefaultSessionDir" &&
+ relation.TargetID == "Function:config.ts:getAgentDir" {
+ return
+ }
+ }
+ t.Fatalf("aliased default-parameter call was not emitted: %#v", result.Graph.Relationships())
+}
+
func TestResolveGoPackageExcludesTestFiles(t *testing.T) {
cfg := &GoModuleConfig{ModulePath: "example.com/project"}
normalized := []string{
@@ -40,6 +67,47 @@ func TestResolveJavaScriptRelativeImportDoesNotSuffixFallback(t *testing.T) {
}
}
+func TestResolveTypeScriptNodeNextJavaScriptSpecifierToSource(t *testing.T) {
+ paths := []string{"src/index.ts", "src/parser.ts"}
+ ctx := BuildImportResolutionContext(paths, paths)
+ got := resolveImportPath("src/index.ts", "./parser.js", "typescript", nil, ctx)
+ if got == nil || len(got.Files) != 1 || got.Files[0] != "src/parser.ts" {
+ t.Fatalf("NodeNext TypeScript import = %#v, want src/parser.ts", got)
+ }
+}
+
+func TestResolveTypeScriptAliasUsesClosestMonorepoConfig(t *testing.T) {
+ paths := []string{
+ "packages/app/src/main.ts",
+ "packages/app/src/local/service.ts",
+ "src/root/service.ts",
+ }
+ ctx := BuildImportResolutionContext(paths, paths)
+ configs := &LanguageConfigs{TsconfigPathSets: []TsconfigPaths{
+ {ConfigDir: "packages/app", BaseURL: ".", Aliases: map[string]string{"@/": "src/local/"}},
+ {ConfigDir: "", BaseURL: ".", Aliases: map[string]string{"@/": "src/root/"}},
+ }}
+ got := resolveImportPath(paths[0], "@/service", "typescript", configs, ctx)
+ if got == nil || len(got.Files) != 1 || got.Files[0] != "packages/app/src/local/service.ts" {
+ t.Fatalf("nested TypeScript alias = %#v, want packages/app/src/local/service.ts", got)
+ }
+}
+
+func TestResolveTypeScriptWorkspacePackageToSourceIndex(t *testing.T) {
+ paths := []string{
+ "packages/app/src/main.ts",
+ "packages/tui/src/index.ts",
+ }
+ ctx := BuildImportResolutionContext(paths, paths)
+ configs := &LanguageConfigs{
+ WorkspacePackages: map[string]string{"@example/tui": "packages/tui"},
+ }
+ got := resolveImportPath(paths[0], "@example/tui", "typescript", configs, ctx)
+ if got == nil || len(got.Files) != 1 || got.Files[0] != "packages/tui/src/index.ts" {
+ t.Fatalf("workspace TypeScript import = %#v, want packages/tui/src/index.ts", got)
+ }
+}
+
func TestResolveRustImportUsesWorkspaceCrateRoot(t *testing.T) {
files := map[string]bool{
"crates/cli/src/decompress.rs": true,
diff --git a/internal/ingest/incremental.go b/internal/ingest/incremental.go
new file mode 100644
index 0000000..5c8f579
--- /dev/null
+++ b/internal/ingest/incremental.go
@@ -0,0 +1,551 @@
+package ingest
+
+import (
+ "fmt"
+ "log/slog"
+ "path/filepath"
+ "sort"
+ "strings"
+ "time"
+
+ inc "github.com/mengshi02/codetrip/internal/incremental"
+ "github.com/mengshi02/codetrip/internal/ingest/enrich"
+ graph "github.com/mengshi02/codetrip/internal/model"
+)
+
+// IncrementalResult explains whether a copy-on-write graph could be updated
+// safely without reparsing every file.
+type IncrementalResult struct {
+ Pipeline *PipelineResult
+ Supported bool
+ Reason string
+}
+
+// RunIncremental preserves the compatibility entry point for body-only file
+// changes. New callers should provide an invalidation plan.
+func RunIncremental(repoPath string, base *graph.KnowledgeGraph, modified []string) (*IncrementalResult, error) {
+ units := make([]inc.InvalidatedUnit, 0, len(modified))
+ for _, path := range modified {
+ units = append(units, inc.InvalidatedUnit{Unit: inc.Unit(filepath.ToSlash(path)), Reason: inc.ReasonBodyChanged})
+ }
+ return RunIncrementalPlan(repoPath, base, inc.InvalidationPlan{Units: units})
+}
+
+// RunIncrementalPlan rebuilds analyzer-owned file units selected by the
+// language-neutral invalidation planner. The parsing and resolution pipeline
+// is shared by every supported language.
+func RunIncrementalPlan(repoPath string, base *graph.KnowledgeGraph, plan inc.InvalidationPlan) (*IncrementalResult, error) {
+ started := time.Now()
+ if base == nil || len(plan.Units) == 0 {
+ return &IncrementalResult{Reason: "no modified files"}, nil
+ }
+ stageStarted := time.Now()
+ walk, err := WalkRepository(repoPath)
+ if err != nil {
+ return nil, err
+ }
+ slog.Info("incremental analyze stage", "stage", "walk", "duration", time.Since(stageStarted))
+ changed := make(map[string]bool, len(plan.Units))
+ bodyOnly := make(map[string]bool, len(plan.Units))
+ callsChanged := make(map[string]bool, len(plan.Units))
+ for _, unit := range plan.Units {
+ path := filepath.ToSlash(string(unit.Unit))
+ changed[path] = true
+ bodyOnly[path] = unit.Reason == inc.ReasonBodyChanged || unit.Reason == inc.ReasonCallsChanged
+ callsChanged[path] = unit.Reason == inc.ReasonCallsChanged
+ }
+ stageStarted = time.Now()
+ declarationsBefore := declarationShape(base, changed)
+ packageMembershipBefore := packageMembershipRelationships(base, changed)
+ preserved := preservedBodyIncomingRelationships(base, changed, bodyOnly)
+ preservedNodes := preservedRelationshipNodes(base, preserved, changed)
+ bodyCallsBefore := bodyCallRelationships(base, changed)
+ preserveDerivedCandidate := true
+ for _, unit := range plan.Units {
+ if unit.Reason != inc.ReasonBodyChanged {
+ preserveDerivedCandidate = false
+ break
+ }
+ }
+ var coreBefore []string
+ var derivedBefore derivedAnalysis
+ if preserveDerivedCandidate {
+ coreBefore = coreTopology(base)
+ derivedBefore = captureDerivedAnalysis(base)
+ }
+
+ // Derived communities and processes are cheap enough to rebuild and must
+ // never retain memberships from the previous call graph.
+ removeDerivedAnalysis(base)
+ slog.Info("incremental analyze stage", "stage", "capture-and-remove-derived", "duration", time.Since(stageStarted))
+ stageStarted = time.Now()
+ for path := range changed {
+ base.RemoveNodesByFile(path)
+ }
+ ProcessStructure(base, walk)
+ slog.Info("incremental analyze stage", "stage", "structure", "duration", time.Since(stageStarted))
+ // Body-only rebuilds remove and recreate syntax nodes for the changed file.
+ // Compiler-generated interface method nodes have no syntax declaration, so
+ // retain them with their semantic relationships when declarations did not
+ // change. Without this, an unrelated CALLS edit can silently erase
+ // HAS_METHOD/METHOD_IMPLEMENTS/DISPATCHES_TO facts.
+ restorePreservedNodes(base, preservedNodes)
+
+ stageStarted = time.Now()
+ symbolTable := hydrateSymbolTable(base)
+ slog.Info("incremental analyze stage", "stage", "symbol-table", "duration", time.Since(stageStarted))
+ registry := NewLanguageRegistry()
+ inputs := make([]FileInput, 0, len(plan.Units))
+ allPaths := make([]string, 0, len(walk.Files))
+ for _, file := range walk.Files {
+ path := filepath.ToSlash(file.RelativePath)
+ allPaths = append(allPaths, path)
+ if !changed[path] || file.LanguageID == "" || !registry.HasParser(file.ParserID) {
+ continue
+ }
+ content, readErr := ReadFileContents(file.AbsolutePath, file.Size)
+ if readErr != nil {
+ return nil, readErr
+ }
+ inputs = append(inputs, FileInput{Path: path, Content: content})
+ }
+ stageStarted = time.Now()
+ extracted := ProcessParsing(base, inputs, symbolTable, registry, nil)
+ slog.Info("incremental analyze stage", "stage", "parse", "inputs", len(inputs), "duration", time.Since(stageStarted))
+
+ stageStarted = time.Now()
+ resolveCtx := BuildImportResolutionContext(allPaths, allPaths)
+ slog.Info("incremental analyze stage", "stage", "resolution-context", "paths", len(allPaths), "duration", time.Since(stageStarted))
+ importMap := NewImportMap()
+ packageMap := NewPackageMap()
+ namedImportMap := make(NamedImportMap)
+ importOrderMap := make(ImportOrderMap)
+ configs := LoadAllLanguageConfigs(repoPath)
+ stageStarted = time.Now()
+ if extracted != nil {
+ captures := make([]ImportQueryCapture, len(extracted.Imports))
+ for index, item := range extracted.Imports {
+ captures[index] = ImportQueryCapture{
+ FilePath: item.FilePath, ImportPath: cleanImportPath(item.ImportPath), Language: item.Language,
+ NamedBinding: item.NamedBinding, ExportedName: item.ExportedName,
+ }
+ }
+ ProcessImportsFromExtracted(base, captures, importMap, packageMap, namedImportMap, configs, resolveCtx, importOrderMap)
+ }
+ slog.Info("incremental analyze stage", "stage", "syntax-relations", "duration", time.Since(stageStarted))
+ PopulateImplicitPackageVisibility(inputs, importMap)
+ symbolTable.FinalizeRangeIndex()
+ if extracted != nil {
+ assignableOwners := BuildAssignableOwnerIDs(
+ extracted.Heritage, symbolTable, importMap, packageMap, namedImportMap, importOrderMap,
+ )
+ ProcessCallsFromExtracted(base, extracted.Calls, symbolTable, importMap, packageMap, namedImportMap, importOrderMap, assignableOwners)
+ ProcessHeritageFromExtracted(base, extracted.Heritage, symbolTable, importMap, packageMap, namedImportMap, importOrderMap)
+ ProcessRoutesFromExtracted(base, extracted.Routes, symbolTable, importMap, packageMap)
+ }
+ declarationsAfter := declarationShape(base, changed)
+ localCallableAddition := callableOnlyAddition(declarationsBefore, declarationsAfter)
+ localFunctionChange := functionOnlyChange(declarationsBefore, declarationsAfter)
+ restoreIncomingRelationships(base, preserved)
+ changedUnits := make([]string, 0, len(changed))
+ for path := range changed {
+ changedUnits = append(changedUnits, path)
+ }
+ sort.Strings(changedUnits)
+ bodyOnlyGo := len(changedUnits) > 0
+ goCallsChanged := false
+ for _, path := range changedUnits {
+ if !bodyOnly[path] || !strings.EqualFold(filepath.Ext(path), ".go") {
+ bodyOnlyGo = false
+ break
+ }
+ goCallsChanged = goCallsChanged || callsChanged[path]
+ }
+ stageStarted = time.Now()
+ if bodyOnlyGo {
+ removeChangedFileImports(base, changed)
+ if !goCallsChanged {
+ restoreBodyCalls(base, changed, bodyCallsBefore)
+ } else {
+ if _, err := ProcessGoCallSemanticsForFiles(repoPath, base, changedUnits); err != nil {
+ return nil, err
+ }
+ }
+ } else if localFunctionChange && allUnitsUseExtension(changedUnits, ".go") {
+ // Top-level function changes cannot alter method sets, interface
+ // implementations, or dispatch targets. The invalidation plan already
+ // includes files that called the old declaration, so compiler-resolve
+ // CALLS only for the affected package closure instead of loading every
+ // package in the repository.
+ restoreIncomingRelationships(base, packageMembershipBefore)
+ if _, err := ProcessGoCallSemanticsForAffectedFiles(repoPath, base, changedUnits); err != nil {
+ return nil, err
+ }
+ } else if localCallableAddition && allUnitsUseExtension(changedUnits, ".go") {
+ // Adding functions or methods without changing an existing declaration
+ // cannot invalidate callers in other packages: no previous call site
+ // could resolve to a declaration that did not exist. Refresh compiler-
+ // resolved calls for the owning package instead of loading every module
+ // in a large repository.
+ restoreIncomingRelationships(base, packageMembershipBefore)
+ if _, err := ProcessGoCallSemanticsForFiles(repoPath, base, changedUnits); err != nil {
+ return nil, err
+ }
+ } else {
+ ProcessIncrementalSemanticRefinements(repoPath, base, changedUnits)
+ }
+ slog.Info("incremental analyze stage", "stage", "semantic", "duration", time.Since(stageStarted))
+ base.RemoveDanglingRelationships()
+ stageStarted = time.Now()
+ if preserveDerivedCandidate && equalStringSets(coreBefore, coreTopology(base)) {
+ restoreDerivedAnalysis(base, derivedBefore)
+ return &IncrementalResult{Supported: true, Pipeline: &PipelineResult{
+ Graph: base, WalkResult: walk, LangConfigs: configs, Duration: time.Since(started),
+ StageStats: map[PipelineStage]StageStat{},
+ }}, nil
+ }
+ slog.Info("incremental analyze stage", "stage", "topology", "duration", time.Since(stageStarted))
+
+ stageStarted = time.Now()
+ mro := enrich.ComputeMRO(base)
+ _ = mro
+ communities := enrich.ProcessCommunities(base)
+ enrich.ApplyCommunitiesToGraph(base, communities)
+ symbolCount := 0
+ base.ForEachNode(func(node *graph.GraphNode) {
+ if node.Label != graph.LabelFile {
+ symbolCount++
+ }
+ })
+ maxProcesses := 20 + symbolCount/10
+ if maxProcesses > 300 {
+ maxProcesses = 300
+ }
+ processes := enrich.ProcessProcesses(base, communities.Memberships, enrich.ProcessDetectionConfig{
+ MaxTraceDepth: 10, MaxBranching: 4, MaxProcesses: maxProcesses, MinSteps: 3,
+ })
+ enrich.ApplyProcessesToGraph(base, processes)
+ slog.Info("incremental analyze stage", "stage", "derived", "duration", time.Since(stageStarted))
+
+ return &IncrementalResult{Supported: true, Pipeline: &PipelineResult{
+ Graph: base, WalkResult: walk, LangConfigs: configs, Duration: time.Since(started),
+ StageStats: map[PipelineStage]StageStat{},
+ }}, nil
+}
+
+func bodyCallRelationships(knowledgeGraph *graph.KnowledgeGraph, changed map[string]bool) []*graph.GraphRelationship {
+ paths := make(map[string]string)
+ labels := make(map[string]graph.NodeLabel)
+ knowledgeGraph.ForEachNode(func(node *graph.GraphNode) {
+ paths[node.ID] = filepath.ToSlash(node.Properties.FilePath)
+ labels[node.ID] = node.Label
+ })
+ result := make([]*graph.GraphRelationship, 0)
+ knowledgeGraph.ForEachRelationship(func(relationship *graph.GraphRelationship) {
+ if relationship.Type != graph.RelCALLS || !changed[paths[relationship.SourceID]] {
+ return
+ }
+ if labels[relationship.SourceID] != graph.LabelFunction && labels[relationship.SourceID] != graph.LabelMethod {
+ return
+ }
+ copy := *relationship
+ result = append(result, ©)
+ })
+ return result
+}
+
+func restoreBodyCalls(knowledgeGraph *graph.KnowledgeGraph, changed map[string]bool, relationships []*graph.GraphRelationship) {
+ current := bodyCallRelationships(knowledgeGraph, changed)
+ ids := make(map[string]bool, len(current))
+ for _, relationship := range current {
+ ids[relationship.ID] = true
+ }
+ knowledgeGraph.RemoveRelationships(func(relationship *graph.GraphRelationship) bool { return ids[relationship.ID] })
+ restoreIncomingRelationships(knowledgeGraph, relationships)
+}
+
+func removeChangedFileImports(knowledgeGraph *graph.KnowledgeGraph, changed map[string]bool) {
+ files := make(map[string]bool)
+ knowledgeGraph.ForEachNode(func(node *graph.GraphNode) {
+ if node.Label == graph.LabelFile && changed[filepath.ToSlash(node.Properties.FilePath)] {
+ files[node.ID] = true
+ }
+ })
+ knowledgeGraph.RemoveRelationships(func(relationship *graph.GraphRelationship) bool {
+ return relationship.Type == graph.RelIMPORTS && files[relationship.SourceID]
+ })
+}
+
+type derivedAnalysis struct {
+ nodes []*graph.GraphNode
+ relationships []*graph.GraphRelationship
+}
+
+func captureDerivedAnalysis(knowledgeGraph *graph.KnowledgeGraph) derivedAnalysis {
+ result := derivedAnalysis{}
+ derivedIDs := make(map[string]bool)
+ knowledgeGraph.ForEachNode(func(node *graph.GraphNode) {
+ if node.Label == graph.LabelCommunity || node.Label == graph.LabelProcess {
+ copy := *node
+ result.nodes = append(result.nodes, ©)
+ derivedIDs[node.ID] = true
+ }
+ })
+ knowledgeGraph.ForEachRelationship(func(relationship *graph.GraphRelationship) {
+ if derivedIDs[relationship.SourceID] || derivedIDs[relationship.TargetID] ||
+ relationship.Type == graph.RelMEMBER_OF || relationship.Type == graph.RelSTEP_IN_PROCESS {
+ copy := *relationship
+ result.relationships = append(result.relationships, ©)
+ }
+ })
+ return result
+}
+
+func restoreDerivedAnalysis(knowledgeGraph *graph.KnowledgeGraph, analysis derivedAnalysis) {
+ for _, node := range analysis.nodes {
+ knowledgeGraph.AddNode(node)
+ }
+ for _, relationship := range analysis.relationships {
+ if _, exists := knowledgeGraph.GetNode(relationship.SourceID); !exists {
+ continue
+ }
+ if _, exists := knowledgeGraph.GetNode(relationship.TargetID); !exists {
+ continue
+ }
+ knowledgeGraph.AddRelationship(relationship)
+ }
+}
+
+func coreTopology(knowledgeGraph *graph.KnowledgeGraph) []string {
+ derivedIDs := make(map[string]bool)
+ shape := make([]string, 0, knowledgeGraph.NodeCount()+knowledgeGraph.RelationshipCount())
+ knowledgeGraph.ForEachNode(func(node *graph.GraphNode) {
+ if node.Label == graph.LabelCommunity || node.Label == graph.LabelProcess {
+ derivedIDs[node.ID] = true
+ return
+ }
+ shape = append(shape, "n\x00"+node.ID)
+ })
+ knowledgeGraph.ForEachRelationship(func(relationship *graph.GraphRelationship) {
+ if derivedIDs[relationship.SourceID] || derivedIDs[relationship.TargetID] ||
+ relationship.Type == graph.RelMEMBER_OF || relationship.Type == graph.RelSTEP_IN_PROCESS {
+ return
+ }
+ shape = append(shape, "r\x00"+relationship.ID)
+ })
+ sort.Strings(shape)
+ return shape
+}
+
+func declarationShape(knowledgeGraph *graph.KnowledgeGraph, changed map[string]bool) []string {
+ shape := make([]string, 0)
+ knowledgeGraph.ForEachNode(func(node *graph.GraphNode) {
+ if !changed[filepath.ToSlash(node.Properties.FilePath)] || node.Label == graph.LabelFile {
+ return
+ }
+ parameterCount := ""
+ if node.Properties.ParameterCount != nil {
+ parameterCount = fmt.Sprint(*node.Properties.ParameterCount)
+ }
+ shape = append(shape, strings.Join([]string{
+ node.ID, string(node.Label), parameterCount, node.Properties.ReturnType,
+ }, "\x00"))
+ })
+ sort.Strings(shape)
+ return shape
+}
+
+func packageMembershipRelationships(knowledgeGraph *graph.KnowledgeGraph, changed map[string]bool) []*graph.GraphRelationship {
+ files := make(map[string]bool)
+ knowledgeGraph.ForEachNode(func(node *graph.GraphNode) {
+ if node.Label == graph.LabelFile && changed[filepath.ToSlash(node.Properties.FilePath)] {
+ files[node.ID] = true
+ }
+ })
+ result := make([]*graph.GraphRelationship, 0)
+ knowledgeGraph.ForEachRelationship(func(relationship *graph.GraphRelationship) {
+ if relationship.Type == graph.RelCONTAINS && files[relationship.TargetID] && relationship.Reason == "go-package-membership" {
+ copy := *relationship
+ result = append(result, ©)
+ }
+ })
+ return result
+}
+
+func callableOnlyAddition(before, after []string) bool {
+ old := make(map[string]bool, len(before))
+ for _, declaration := range before {
+ old[declaration] = true
+ }
+ added := 0
+ for _, declaration := range after {
+ if old[declaration] {
+ delete(old, declaration)
+ continue
+ }
+ parts := strings.Split(declaration, "\x00")
+ if len(parts) < 2 || (parts[1] != string(graph.LabelFunction) && parts[1] != string(graph.LabelMethod)) {
+ return false
+ }
+ added++
+ }
+ return added > 0 && len(old) == 0
+}
+
+// functionOnlyChange reports whether every added or removed declaration is a
+// top-level function. Methods are deliberately excluded because changing one
+// can invalidate Go interface satisfaction and dynamic dispatch facts.
+func functionOnlyChange(before, after []string) bool {
+ old := make(map[string]bool, len(before))
+ for _, declaration := range before {
+ old[declaration] = true
+ }
+ changed, removed := 0, 0
+ for _, declaration := range after {
+ if old[declaration] {
+ delete(old, declaration)
+ continue
+ }
+ parts := strings.Split(declaration, "\x00")
+ if len(parts) < 2 || parts[1] != string(graph.LabelFunction) {
+ return false
+ }
+ changed++
+ }
+ for declaration := range old {
+ parts := strings.Split(declaration, "\x00")
+ if len(parts) < 2 || parts[1] != string(graph.LabelFunction) {
+ return false
+ }
+ changed++
+ removed++
+ }
+ // Pure additions retain callableOnlyAddition's conservative test-aware
+ // behavior because previously unresolved test calls have no dependency edge
+ // that could place them in the invalidation closure.
+ return changed > 0 && removed > 0
+}
+
+func allUnitsUseExtension(units []string, extension string) bool {
+ if len(units) == 0 {
+ return false
+ }
+ for _, unit := range units {
+ if !strings.EqualFold(filepath.Ext(unit), extension) {
+ return false
+ }
+ }
+ return true
+}
+
+func equalStringSets(left, right []string) bool {
+ if len(left) != len(right) {
+ return false
+ }
+ for index := range left {
+ if left[index] != right[index] {
+ return false
+ }
+ }
+ return true
+}
+
+func removeDerivedAnalysis(knowledgeGraph *graph.KnowledgeGraph) {
+ knowledgeGraph.RemoveNodes(func(node *graph.GraphNode) bool {
+ return node.Label == graph.LabelCommunity || node.Label == graph.LabelProcess
+ })
+}
+
+func hydrateSymbolTable(knowledgeGraph *graph.KnowledgeGraph) *SymbolTable {
+ owners := make(map[string]string)
+ knowledgeGraph.ForEachRelationship(func(relationship *graph.GraphRelationship) {
+ if relationship.Type == graph.RelHAS_METHOD {
+ owners[relationship.TargetID] = relationship.SourceID
+ }
+ })
+ table := NewSymbolTable()
+ knowledgeGraph.ForEachNode(func(node *graph.GraphNode) {
+ switch node.Label {
+ case graph.LabelFile, graph.LabelFolder, graph.LabelCommunity, graph.LabelProcess, graph.LabelPackage:
+ return
+ }
+ table.Add(
+ node.Properties.FilePath, node.Properties.Name, node.ID, string(node.Label),
+ node.Properties.ParameterCount, owners[node.ID], 0, 0,
+ )
+ table.SetReturnType(node.ID, 0, node.Properties.ReturnType)
+ })
+ return table
+}
+
+func preservedBodyIncomingRelationships(knowledgeGraph *graph.KnowledgeGraph, changed, bodyOnly map[string]bool) []*graph.GraphRelationship {
+ paths := make(map[string]string)
+ knowledgeGraph.ForEachNode(func(node *graph.GraphNode) { paths[node.ID] = filepath.ToSlash(node.Properties.FilePath) })
+ result := make([]*graph.GraphRelationship, 0)
+ knowledgeGraph.ForEachRelationship(func(relationship *graph.GraphRelationship) {
+ sourceBody := bodyOnly[paths[relationship.SourceID]]
+ targetBody := bodyOnly[paths[relationship.TargetID]]
+ preserve := targetBody && !changed[paths[relationship.SourceID]]
+ if sourceBody {
+ switch relationship.Type {
+ case graph.RelIMPLEMENTS, graph.RelMETHOD_IMPLEMENTS, graph.RelDISPATCHES_TO,
+ graph.RelINHERITS, graph.RelEXTENDS, graph.RelOVERRIDES, graph.RelHAS_METHOD:
+ preserve = true
+ }
+ if relationship.Type == graph.RelCALLS && relationship.Reason == "visible-implicit-constructor" {
+ preserve = true
+ }
+ }
+ if !preserve {
+ return
+ }
+ switch relationship.Type {
+ case graph.RelMEMBER_OF, graph.RelSTEP_IN_PROCESS, graph.RelDEFINES:
+ return
+ }
+ copy := *relationship
+ result = append(result, ©)
+ })
+ return result
+}
+
+func restoreIncomingRelationships(knowledgeGraph *graph.KnowledgeGraph, relationships []*graph.GraphRelationship) {
+ for _, relationship := range relationships {
+ if _, exists := knowledgeGraph.GetNode(relationship.SourceID); !exists {
+ continue
+ }
+ if _, exists := knowledgeGraph.GetNode(relationship.TargetID); !exists {
+ continue
+ }
+ knowledgeGraph.AddRelationship(relationship)
+ }
+}
+
+func preservedRelationshipNodes(
+ knowledgeGraph *graph.KnowledgeGraph,
+ relationships []*graph.GraphRelationship,
+ changed map[string]bool,
+) []*graph.GraphNode {
+ ids := make(map[string]bool)
+ for _, relationship := range relationships {
+ ids[relationship.SourceID] = true
+ ids[relationship.TargetID] = true
+ }
+ result := make([]*graph.GraphNode, 0)
+ knowledgeGraph.ForEachNode(func(node *graph.GraphNode) {
+ if !ids[node.ID] || !changed[filepath.ToSlash(node.Properties.FilePath)] {
+ return
+ }
+ copy := *node
+ result = append(result, ©)
+ })
+ return result
+}
+
+func restorePreservedNodes(knowledgeGraph *graph.KnowledgeGraph, nodes []*graph.GraphNode) {
+ for _, node := range nodes {
+ if _, exists := knowledgeGraph.GetNode(node.ID); !exists {
+ knowledgeGraph.AddNode(node)
+ }
+ }
+}
diff --git a/internal/ingest/language_config.go b/internal/ingest/language_config.go
index dbb4fe2..5ddb200 100644
--- a/internal/ingest/language_config.go
+++ b/internal/ingest/language_config.go
@@ -4,21 +4,25 @@ package ingest
import (
"encoding/json"
+ "io/fs"
"os"
"path/filepath"
"regexp"
+ "sort"
"strings"
)
// TsconfigPaths holds TypeScript path alias configuration.
type TsconfigPaths struct {
- Aliases map[string]string // alias prefix -> target prefix (e.g., "@/" -> "src/")
- BaseURL string
+ Aliases map[string]string // alias prefix -> target prefix (e.g., "@/" -> "src/")
+ BaseURL string
+ ConfigDir string // repository-relative directory containing the config
}
// GoModuleConfig holds Go module configuration.
type GoModuleConfig struct {
ModulePath string // e.g., "github.com/user/repo"
+ ModuleDir string // repository-relative directory containing go.mod
}
// ComposerConfig holds PHP Composer PSR-4 autoload configuration.
@@ -40,12 +44,59 @@ type SwiftPackageConfig struct {
// LanguageConfigs bundles all language-specific configs loaded once per ingestion run.
type LanguageConfigs struct {
TsconfigPaths *TsconfigPaths
+ TsconfigPathSets []TsconfigPaths
+ WorkspacePackages map[string]string // package name -> repository-relative directory
GoModule *GoModuleConfig
+ GoModules []GoModuleConfig
ComposerConfig *ComposerConfig
SwiftPackageConfig *SwiftPackageConfig
CsharpConfigs []CSharpProjectConfig
}
+// LoadWorkspacePackages discovers local JavaScript/TypeScript package names.
+// It intentionally records only package.json files inside the repository and
+// skips installed dependencies.
+func LoadWorkspacePackages(repoRoot string) map[string]string {
+ packages := make(map[string]string)
+ _ = filepath.WalkDir(repoRoot, func(packagePath string, entry fs.DirEntry, walkErr error) error {
+ if walkErr != nil {
+ return nil
+ }
+ if entry.IsDir() {
+ switch entry.Name() {
+ case ".git", "node_modules", "vendor", "build", "dist":
+ if packagePath != repoRoot {
+ return filepath.SkipDir
+ }
+ }
+ return nil
+ }
+ if entry.Name() != "package.json" {
+ return nil
+ }
+ raw, err := os.ReadFile(packagePath)
+ if err != nil {
+ return nil
+ }
+ var manifest struct {
+ Name string `json:"name"`
+ }
+ if json.Unmarshal(raw, &manifest) != nil || manifest.Name == "" {
+ return nil
+ }
+ directory, err := filepath.Rel(repoRoot, filepath.Dir(packagePath))
+ if err != nil {
+ return nil
+ }
+ if directory == "." {
+ directory = ""
+ }
+ packages[manifest.Name] = filepath.ToSlash(directory)
+ return nil
+ })
+ return packages
+}
+
// LoadTsconfigPaths parses tsconfig.json to extract path aliases.
// Tries tsconfig.json, tsconfig.app.json, tsconfig.base.json in order.
func LoadTsconfigPaths(repoRoot string) *TsconfigPaths {
@@ -109,6 +160,79 @@ func LoadTsconfigPaths(repoRoot string) *TsconfigPaths {
return nil
}
+// LoadTsconfigPathSets discovers path mappings throughout a TypeScript
+// monorepo. The closest config directory is preferred for a source file.
+func LoadTsconfigPathSets(repoRoot string) []TsconfigPaths {
+ var configs []TsconfigPaths
+ _ = filepath.WalkDir(repoRoot, func(configPath string, entry fs.DirEntry, walkErr error) error {
+ if walkErr != nil {
+ return nil
+ }
+ if entry.IsDir() {
+ switch entry.Name() {
+ case ".git", "node_modules", "vendor", "build", "dist":
+ if configPath != repoRoot {
+ return filepath.SkipDir
+ }
+ }
+ return nil
+ }
+ if !strings.HasPrefix(entry.Name(), "tsconfig") || !strings.HasSuffix(entry.Name(), ".json") {
+ return nil
+ }
+ raw, err := os.ReadFile(configPath)
+ if err != nil {
+ return nil
+ }
+ var document map[string]interface{}
+ if json.Unmarshal([]byte(stripJSONComments(string(raw))), &document) != nil {
+ return nil
+ }
+ options, ok := document["compilerOptions"].(map[string]interface{})
+ if !ok {
+ return nil
+ }
+ paths, ok := options["paths"].(map[string]interface{})
+ if !ok {
+ return nil
+ }
+ aliases := make(map[string]string)
+ for pattern, rawTargets := range paths {
+ targets, ok := rawTargets.([]interface{})
+ if !ok || len(targets) == 0 {
+ continue
+ }
+ target, ok := targets[0].(string)
+ if !ok {
+ continue
+ }
+ aliases[strings.TrimSuffix(pattern, "*")] = strings.TrimSuffix(target, "*")
+ }
+ if len(aliases) == 0 {
+ return nil
+ }
+ directory, err := filepath.Rel(repoRoot, filepath.Dir(configPath))
+ if err != nil {
+ return nil
+ }
+ if directory == "." {
+ directory = ""
+ }
+ baseURL, _ := options["baseUrl"].(string)
+ configs = append(configs, TsconfigPaths{
+ Aliases: aliases, BaseURL: baseURL, ConfigDir: filepath.ToSlash(directory),
+ })
+ return nil
+ })
+ sort.Slice(configs, func(i, j int) bool {
+ if len(configs[i].ConfigDir) != len(configs[j].ConfigDir) {
+ return len(configs[i].ConfigDir) > len(configs[j].ConfigDir)
+ }
+ return configs[i].ConfigDir < configs[j].ConfigDir
+ })
+ return configs
+}
+
var goModuleRegex = regexp.MustCompile(`(?m)^module\s+(\S+)`)
// LoadGoModulePath parses go.mod to extract module path.
@@ -120,11 +244,62 @@ func LoadGoModulePath(repoRoot string) *GoModuleConfig {
}
match := goModuleRegex.FindSubmatch(content)
if len(match) >= 2 {
- return &GoModuleConfig{ModulePath: string(match[1])}
+ return &GoModuleConfig{ModulePath: string(match[1]), ModuleDir: ""}
}
return nil
}
+// LoadGoModulePaths discovers every Go module in a repository. Multi-module
+// repositories such as etcd keep go.mod files below the repository root, so a
+// single root module is insufficient for resolving qualified internal calls.
+func LoadGoModulePaths(repoRoot string) []GoModuleConfig {
+ var modules []GoModuleConfig
+ _ = filepath.WalkDir(repoRoot, func(path string, entry fs.DirEntry, walkErr error) error {
+ if walkErr != nil {
+ return nil
+ }
+ if entry.IsDir() {
+ switch entry.Name() {
+ case ".git", "node_modules", "vendor", "build", "dist":
+ if path != repoRoot {
+ return filepath.SkipDir
+ }
+ }
+ return nil
+ }
+ if entry.Name() != "go.mod" {
+ return nil
+ }
+ content, err := os.ReadFile(path)
+ if err != nil {
+ return nil
+ }
+ match := goModuleRegex.FindSubmatch(content)
+ if len(match) < 2 {
+ return nil
+ }
+ directory, err := filepath.Rel(repoRoot, filepath.Dir(path))
+ if err != nil {
+ return nil
+ }
+ if directory == "." {
+ directory = ""
+ }
+ modules = append(modules, GoModuleConfig{
+ ModulePath: string(match[1]),
+ ModuleDir: filepath.ToSlash(directory),
+ })
+ return nil
+ })
+ sort.Slice(modules, func(i, j int) bool {
+ if len(modules[i].ModulePath) != len(modules[j].ModulePath) {
+ return len(modules[i].ModulePath) > len(modules[j].ModulePath)
+ }
+ return modules[i].ModuleDir < modules[j].ModuleDir
+ })
+ return modules
+}
+
// LoadComposerConfig parses composer.json to extract PSR-4 autoload mappings.
func LoadComposerConfig(repoRoot string) *ComposerConfig {
composerPath := filepath.Join(repoRoot, "composer.json")
@@ -245,9 +420,28 @@ func LoadSwiftPackageConfig(repoRoot string) *SwiftPackageConfig {
// LoadAllLanguageConfigs loads all language configs for a repository.
func LoadAllLanguageConfigs(repoRoot string) *LanguageConfigs {
+ goModules := LoadGoModulePaths(repoRoot)
+ tsconfigPathSets := LoadTsconfigPathSets(repoRoot)
+ primaryTsconfig := LoadTsconfigPaths(repoRoot)
+ if primaryTsconfig == nil && len(tsconfigPathSets) > 0 {
+ primaryTsconfig = &tsconfigPathSets[0]
+ }
+ var primaryGoModule *GoModuleConfig
+ for index := range goModules {
+ if goModules[index].ModuleDir == "" {
+ primaryGoModule = &goModules[index]
+ break
+ }
+ }
+ if primaryGoModule == nil && len(goModules) > 0 {
+ primaryGoModule = &goModules[0]
+ }
return &LanguageConfigs{
- TsconfigPaths: LoadTsconfigPaths(repoRoot),
- GoModule: LoadGoModulePath(repoRoot),
+ TsconfigPaths: primaryTsconfig,
+ TsconfigPathSets: tsconfigPathSets,
+ WorkspacePackages: LoadWorkspacePackages(repoRoot),
+ GoModule: primaryGoModule,
+ GoModules: goModules,
ComposerConfig: LoadComposerConfig(repoRoot),
SwiftPackageConfig: LoadSwiftPackageConfig(repoRoot),
CsharpConfigs: LoadCSharpProjectConfigs(repoRoot),
diff --git a/internal/ingest/named_binding.go b/internal/ingest/named_binding.go
index a4ba6d7..ef528bd 100644
--- a/internal/ingest/named_binding.go
+++ b/internal/ingest/named_binding.go
@@ -52,57 +52,62 @@ func WalkBindingChain(
namedImportMap NamedImportMap,
allDefs []*SymbolDefinition,
) []*SymbolDefinition {
- lookupFile := currentFilePath
- lookupName := name
visited := make(map[string]bool)
-
- for depth := 0; depth < 5; depth++ {
- bindings, ok := namedImportMap[lookupFile]
- if !ok {
+ var walk func(string, string, int) []*SymbolDefinition
+ walk = func(lookupName, lookupFile string, depth int) []*SymbolDefinition {
+ if depth >= 5 {
return nil
}
-
- binding, ok := bindings[lookupName]
- if !ok {
+ bindings := namedImportMap[lookupFile]
+ if len(bindings) == 0 {
return nil
}
-
- key := binding.SourcePath + ":" + binding.ExportedName
- if visited[key] {
- return nil // circular
+ candidates := make([]NamedImportBinding, 0, 1)
+ if binding, ok := bindings[lookupName]; ok {
+ candidates = append(candidates, binding)
+ } else {
+ for key, binding := range bindings {
+ if strings.HasPrefix(key, "*:") {
+ candidates = append(candidates, binding)
+ }
+ }
}
- visited[key] = true
-
- targetName := binding.ExportedName
- var resolvedDefs []*SymbolDefinition
-
- if targetName != lookupName || depth > 0 {
- // Filter allDefs by source path
- filtered := symbolTable.LookupFuzzy(targetName)
- for _, def := range filtered {
- if def.FilePath == binding.SourcePath {
- resolvedDefs = append(resolvedDefs, def)
+ var result []*SymbolDefinition
+ seenNodes := make(map[string]bool)
+ for _, binding := range candidates {
+ targetName := binding.ExportedName
+ if targetName == "" || targetName == "*" {
+ targetName = lookupName
+ }
+ key := binding.SourcePath + ":" + targetName
+ if visited[key] {
+ continue
+ }
+ visited[key] = true
+ definitions := symbolTable.LookupFuzzy(targetName)
+ if depth == 0 && targetName == name {
+ definitions = allDefs
+ }
+ found := false
+ for _, definition := range definitions {
+ if definition.FilePath == binding.SourcePath && !seenNodes[definition.NodeID] {
+ seenNodes[definition.NodeID] = true
+ result = append(result, definition)
+ found = true
}
}
- } else {
- // Use the pre-computed allDefs, filtered by source path
- for _, def := range allDefs {
- if def.FilePath == binding.SourcePath {
- resolvedDefs = append(resolvedDefs, def)
+ if !found {
+ for _, definition := range walk(targetName, binding.SourcePath, depth+1) {
+ if !seenNodes[definition.NodeID] {
+ seenNodes[definition.NodeID] = true
+ result = append(result, definition)
+ }
}
}
}
-
- if len(resolvedDefs) > 0 {
- return resolvedDefs
- }
-
- // No definition in source file → follow re-export chain
- lookupFile = binding.SourcePath
- lookupName = targetName
+ return result
}
-
- return nil
+ return walk(name, currentFilePath, 0)
}
// ─────────────────────────────────────────────────────────────────────────────
diff --git a/internal/ingest/parsing_processor.go b/internal/ingest/parsing_processor.go
index e13c3d7..4347e08 100644
--- a/internal/ingest/parsing_processor.go
+++ b/internal/ingest/parsing_processor.go
@@ -193,6 +193,24 @@ func processMatch(
importPath = appendKotlinWildcard(importPath, importNode)
}
if importPath != "" {
+ if language == "typescript" || language == "tsx" || language == "javascript" {
+ if bindings := ExtractNamedBindings(importNode, language, source); len(bindings) > 0 {
+ for _, binding := range bindings {
+ extracted.Imports = append(extracted.Imports, ExtractedImport{
+ FilePath: filePath, ImportPath: importPath, Language: language,
+ NamedBinding: binding.Local, ExportedName: binding.Exported,
+ })
+ }
+ return
+ }
+ if strings.HasPrefix(strings.TrimSpace(importNode.Utf8Text(source)), "export *") {
+ extracted.Imports = append(extracted.Imports, ExtractedImport{
+ FilePath: filePath, ImportPath: importPath, Language: language,
+ NamedBinding: "*:" + importPath, ExportedName: "*",
+ })
+ return
+ }
+ }
namedBinding := ""
exportedName := ""
if node := captureMap["import.name"]; node != nil {
@@ -380,19 +398,21 @@ func processMatch(
startLine := 0
var defStartByte, defEndByte uint
if definitionNode != nil {
- startLine = int(definitionNode.StartPosition().Row)
+ // Tree-sitter positions are zero-based; public graph locations follow
+ // the editor/CLI convention and are one-based.
+ startLine = int(definitionNode.StartPosition().Row) + 1
// Use byte offsets from tree-sitter node
defStartByte = uint(definitionNode.StartByte())
defEndByte = uint(definitionNode.EndByte())
} else if nameNode != nil {
- startLine = int(nameNode.StartPosition().Row)
+ startLine = int(nameNode.StartPosition().Row) + 1
defStartByte = uint(nameNode.StartByte())
defEndByte = uint(nameNode.EndByte())
}
endLine := startLine
if definitionNode != nil {
- endLine = int(definitionNode.EndPosition().Row)
+ endLine = int(definitionNode.EndPosition().Row) + 1
}
// Check if exported
diff --git a/internal/ingest/semantic.go b/internal/ingest/semantic.go
index facec44..b5a21ac 100644
--- a/internal/ingest/semantic.go
+++ b/internal/ingest/semantic.go
@@ -2,6 +2,7 @@ package ingest
import (
"fmt"
+ "path/filepath"
"sort"
"strings"
@@ -39,6 +40,15 @@ type SemanticRefiner interface {
Refine(repoPath string, knowledgeGraph *graph.KnowledgeGraph) (SemanticStats, error)
}
+// IncrementalSemanticRefiner can decline an incremental pass when none of the
+// invalidated analyzer units belong to its language. This prevents an edit in
+// one language from starting an unrelated compiler/type-system analysis.
+type IncrementalSemanticRefiner interface {
+ SemanticRefiner
+ SupportsUnits([]string) bool
+ RefineUnits(string, *graph.KnowledgeGraph, []string) (SemanticStats, error)
+}
+
var semanticRefiners = []SemanticRefiner{
goSemanticRefiner{},
}
@@ -46,12 +56,41 @@ var semanticRefiners = []SemanticRefiner{
// ProcessSemanticRefinements runs every applicable language refinement pass.
// A failed refiner does not prevent other languages from being processed.
func ProcessSemanticRefinements(repoPath string, knowledgeGraph *graph.KnowledgeGraph) ([]SemanticStats, []error) {
+ return processSemanticRefinements(repoPath, knowledgeGraph, nil)
+}
+
+// ProcessIncrementalSemanticRefinements runs only refiners affected by the
+// invalidated units. Refiners without incremental selection retain the safe
+// full-pass behavior.
+func ProcessIncrementalSemanticRefinements(repoPath string, knowledgeGraph *graph.KnowledgeGraph, units []string) ([]SemanticStats, []error) {
+ clean := make([]string, 0, len(units))
+ for _, unit := range units {
+ clean = append(clean, filepath.ToSlash(unit))
+ }
+ return processSemanticRefinements(repoPath, knowledgeGraph, clean)
+}
+
+func processSemanticRefinements(repoPath string, knowledgeGraph *graph.KnowledgeGraph, units []string) ([]SemanticStats, []error) {
stats := make([]SemanticStats, 0, len(semanticRefiners))
errors := make([]error, 0)
for _, refiner := range semanticRefiners {
if !refiner.Supports(knowledgeGraph) {
continue
}
+ if units != nil {
+ if incremental, ok := refiner.(IncrementalSemanticRefiner); ok {
+ if !incremental.SupportsUnits(units) {
+ continue
+ }
+ result, err := incremental.RefineUnits(repoPath, knowledgeGraph, units)
+ if err != nil {
+ errors = append(errors, fmt.Errorf("%s: %w", refiner.Name(), err))
+ } else {
+ stats = append(stats, result)
+ }
+ continue
+ }
+ }
result, err := refiner.Refine(repoPath, knowledgeGraph)
if err != nil {
errors = append(errors, fmt.Errorf("%s: %w", refiner.Name(), err))
diff --git a/internal/ingest/semantic_quality_test.go b/internal/ingest/semantic_quality_test.go
index ec6bd4f..04bd34c 100644
--- a/internal/ingest/semantic_quality_test.go
+++ b/internal/ingest/semantic_quality_test.go
@@ -1,11 +1,44 @@
package ingest
import (
+ "os"
+ "path/filepath"
"testing"
graph "github.com/mengshi02/codetrip/internal/model"
)
+func TestTypeScriptNamedReexportResolvesImplicitConstructor(t *testing.T) {
+ repository := t.TempDir()
+ files := map[string]string{
+ "container.ts": "export class Container {}\n",
+ "index.ts": "export * from \"./container.js\";\n",
+ "app.ts": `import { Container } from "./index.js";
+export function build() { return new Container(); }
+`,
+ }
+ for name, content := range files {
+ if err := os.WriteFile(filepath.Join(repository, name), []byte(content), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ result, err := NewPipeline(repository, "", false).Run()
+ if err != nil {
+ t.Fatal(err)
+ }
+ found := false
+ result.Graph.ForEachRelationship(func(relationship *graph.GraphRelationship) {
+ if relationship.Type == graph.RelCALLS &&
+ relationship.SourceID == "Function:app.ts:build" &&
+ relationship.TargetID == "Class:container.ts:Container" {
+ found = true
+ }
+ })
+ if !found {
+ t.Fatal("missing TypeScript constructor call through named re-export")
+ }
+}
+
func TestCPPInlineMethodKeepsItsOwnDefinitionRangeAndArity(t *testing.T) {
source := `class Widget {
public:
@@ -126,6 +159,28 @@ func TestCPPQualifiedNamespaceCallIsNotAnObjectMember(t *testing.T) {
}
}
+func TestStableLanguagesRejectUniqueGlobalCallGuess(t *testing.T) {
+ for _, language := range []string{"go", "typescript", "tsx"} {
+ t.Run(language, func(t *testing.T) {
+ st := NewSymbolTable()
+ st.Add("other.go", "Target", "Function:other.go:Target", "Function", nil, "", 0, 10)
+ g := graph.NewKnowledgeGraph()
+ ProcessCallsFromExtracted(
+ g, []ExtractedCall{{
+ FilePath: "caller.go", Language: language, SourceID: "Function:caller.go:Caller",
+ CallName: "Target", CallForm: CallFormFree, ArgCount: -1,
+ }}, st, NewImportMap(), NewPackageMap(), make(NamedImportMap), make(ImportOrderMap),
+ map[string]map[string]bool{},
+ )
+ for _, relationship := range g.Relationships() {
+ if relationship.Type == graph.RelCALLS {
+ t.Fatalf("%s emitted unsupported unique-global call: %#v", language, relationship)
+ }
+ }
+ })
+ }
+}
+
func TestKotlinLocalPropertyIsNotAClassMember(t *testing.T) {
source := `package fixture
@@ -206,6 +261,35 @@ func TestResolveCallAllowsUniqueExactMemberDefaultArguments(t *testing.T) {
}
}
+func TestGoUntypedMemberRequiresImportedPackageQualifier(t *testing.T) {
+ st := NewSymbolTable()
+ zero := 0
+ st.Add("hooks/config.go", "Run", "Method:hooks/config.go:Hook.Run", "Method", &zero,
+ "Struct:hooks/config.go:Hook", 10, 20)
+ st.Add("util/rand/rand.go", "String", "Function:util/rand/rand.go:String", "Function", &zero,
+ "", 10, 20)
+ packages := NewPackageMap()
+ packages.AddPackage("main.go", "hooks")
+ packages.AddPackage("main.go", "util/rand")
+ ctx := &ResolveContext{
+ SymbolTable: st,
+ ImportMap: NewImportMap(),
+ PackageMap: packages,
+ NamedImportMap: NewNamedImportMap(),
+ }
+
+ if resolved := resolveGoPackageQualifierCall("filter", "Run", "main.go", ctx, 0); resolved != nil {
+ t.Fatalf("local variable was resolved as imported Hook.Run: %#v", resolved)
+ }
+ resolved := resolveGoPackageQualifierCall("rand", "String", "main.go", ctx, 0)
+ if resolved == nil || resolved.NodeID != "Function:util/rand/rand.go:String" {
+ t.Fatalf("package-qualified rand.String resolved to %#v", resolved)
+ }
+ if resolved.Reason != "go-package-qualified" {
+ t.Fatalf("resolution reason = %q", resolved.Reason)
+ }
+}
+
func TestResolveCallFiltersReceiverTypesByTransitiveIncludes(t *testing.T) {
st := NewSymbolTable()
zero := 0
@@ -315,7 +399,7 @@ int main() { auto value = response(100); }
}
constructor, ok := g.GetNode("Constructor:main.cc:response.response")
if !ok || constructor.Properties.StartLine == nil || constructor.Properties.EndLine == nil ||
- *constructor.Properties.StartLine > 2 || *constructor.Properties.EndLine < 2 {
+ *constructor.Properties.StartLine > 3 || *constructor.Properties.EndLine < 3 {
t.Fatalf("collapsed constructor range = %#v", constructor)
}
if !baseInitializerOwnedByConstructor {
@@ -339,6 +423,49 @@ func TestCppVisibleConstructorOutranksImportedForwardDeclaration(t *testing.T) {
}
}
+func TestVisibleTypeWithoutDeclaredConstructorResolvesImplicitConstructor(t *testing.T) {
+ st := NewSymbolTable()
+ st.Add("parser.ts", "TerraformParser", "Class:parser.ts:TerraformParser", "Class", nil, "", 0, 100)
+ imports := NewImportMap()
+ imports.AddImport("index.ts", "parser.ts")
+ ctx := &ResolveContext{
+ SymbolTable: st,
+ ImportMap: imports,
+ AssignableOwnerIDs: map[string]map[string]bool{
+ "Class:parser.ts:TerraformParser": {},
+ },
+ }
+ resolved := resolveCallTarget("TerraformParser", "index.ts", "", ctx, CallFormConstructor, 0)
+ if resolved == nil || resolved.NodeID != "Class:parser.ts:TerraformParser" ||
+ resolved.Reason != "visible-implicit-constructor" {
+ t.Fatalf("implicit constructor resolution = %#v", resolved)
+ }
+}
+
+func TestImplicitConstructorFollowsTypeScriptReexportChain(t *testing.T) {
+ st := NewSymbolTable()
+ st.Add("packages/tui/src/tui.ts", "Container", "Class:packages/tui/src/tui.ts:Container", "Class", nil, "", 0, 100)
+ imports := NewImportMap()
+ imports.AddImport("app.ts", "packages/tui/src/index.ts")
+ named := NewNamedImportMap()
+ named["app.ts"] = map[string]NamedImportBinding{
+ "Container": {SourcePath: "packages/tui/src/index.ts", ExportedName: "Container"},
+ }
+ named["packages/tui/src/index.ts"] = map[string]NamedImportBinding{
+ "Container": {SourcePath: "packages/tui/src/tui.ts", ExportedName: "Container"},
+ }
+ ctx := &ResolveContext{
+ SymbolTable: st, ImportMap: imports, NamedImportMap: named,
+ AssignableOwnerIDs: map[string]map[string]bool{
+ "Class:packages/tui/src/tui.ts:Container": {},
+ },
+ }
+ resolved := resolveCallTarget("Container", "app.ts", "", ctx, CallFormConstructor, 0)
+ if resolved == nil || resolved.NodeID != "Class:packages/tui/src/tui.ts:Container" {
+ t.Fatalf("re-exported implicit constructor resolution = %#v", resolved)
+ }
+}
+
func TestCppHeritageNeverUsesSameNamedConstructors(t *testing.T) {
source := `class Base { public: Base() {} }; class Child : public Base { public: Child() {} };`
g := graph.NewKnowledgeGraph()
diff --git a/internal/ingest/semantic_test.go b/internal/ingest/semantic_test.go
new file mode 100644
index 0000000..4cbf9d0
--- /dev/null
+++ b/internal/ingest/semantic_test.go
@@ -0,0 +1,30 @@
+package ingest
+
+import (
+ "testing"
+
+ graph "github.com/mengshi02/codetrip/internal/model"
+)
+
+func TestGoSemanticRefinerSelectsOnlyGoInvalidations(t *testing.T) {
+ refiner := goSemanticRefiner{}
+ if refiner.SupportsUnits([]string{"web/app.ts", "README.md"}) {
+ t.Fatal("Go semantic refinement selected for non-Go changes")
+ }
+ if !refiner.SupportsUnits([]string{"pkg/service.go"}) {
+ t.Fatal("Go semantic refinement not selected for Go change")
+ }
+}
+
+func TestGoSemanticFindNodePrefersStableSyntaxIdentity(t *testing.T) {
+ line := 42
+ index := &goSemanticIndex{nodesByKey: map[string][]*graph.GraphNode{}}
+ key := semanticNodeKey("worker.go", "start", graph.LabelMethod)
+ index.nodesByKey[key] = []*graph.GraphNode{
+ {ID: "Method:worker.go:parentProcess.start", Label: graph.LabelMethod, Properties: graph.NodeProperties{Name: "start", FilePath: "worker.go", StartLine: &line}},
+ {ID: "Method:worker.go:start", Label: graph.LabelMethod, Properties: graph.NodeProperties{Name: "start", FilePath: "worker.go", StartLine: &line}},
+ }
+ if got := index.findNode("worker.go", "start", graph.LabelMethod, line); got == nil || got.ID != "Method:worker.go:start" {
+ t.Fatalf("findNode=%#v", got)
+ }
+}
diff --git a/internal/ingest/symbol_table.go b/internal/ingest/symbol_table.go
index 2853fac..810cd25 100644
--- a/internal/ingest/symbol_table.go
+++ b/internal/ingest/symbol_table.go
@@ -27,12 +27,8 @@ func (st *SymbolTable) SetReturnType(nodeID string, startByte uint, returnType s
}
st.mu.Lock()
defer st.mu.Unlock()
- for _, defs := range st.globalIndex {
- for _, def := range defs {
- if def.NodeID == nodeID && def.StartByte == startByte {
- def.ReturnType = returnType
- }
- }
+ for _, def := range st.definitionIndex[symbolDefinitionKey{nodeID: nodeID, startByte: startByte}] {
+ def.ReturnType = returnType
}
}
@@ -88,6 +84,12 @@ type SymbolTable struct {
rangeIndex map[string][]*SymbolDefinition // FilePath → function-like defs sorted by StartByte (for enclosing lookup)
ownerRangeIndex map[string][]*SymbolDefinition // FilePath → class-like defs sorted by StartByte
typeAliases map[string][]TypeAliasDefinition
+ definitionIndex map[symbolDefinitionKey][]*SymbolDefinition
+}
+
+type symbolDefinitionKey struct {
+ nodeID string
+ startByte uint
}
// NewSymbolTable creates an empty SymbolTable.
@@ -98,6 +100,7 @@ func NewSymbolTable() *SymbolTable {
rangeIndex: make(map[string][]*SymbolDefinition),
ownerRangeIndex: make(map[string][]*SymbolDefinition),
typeAliases: make(map[string][]TypeAliasDefinition),
+ definitionIndex: make(map[symbolDefinitionKey][]*SymbolDefinition),
}
}
@@ -141,6 +144,8 @@ func (st *SymbolTable) Add(filePath, name, nodeID, symType string, parameterCoun
// B. Add to global index (same pointer — zero additional memory)
st.globalIndex[name] = append(st.globalIndex[name], def)
+ key := symbolDefinitionKey{nodeID: nodeID, startByte: startByte}
+ st.definitionIndex[key] = append(st.definitionIndex[key], def)
// C. Add to range index if function-like (for findEnclosingFunctionIDFromByte)
if functionSymbolTypes[symType] {
@@ -299,4 +304,5 @@ func (st *SymbolTable) Clear() {
st.globalIndex = make(map[string][]*SymbolDefinition)
st.rangeIndex = make(map[string][]*SymbolDefinition)
st.ownerRangeIndex = make(map[string][]*SymbolDefinition)
+ st.definitionIndex = make(map[symbolDefinitionKey][]*SymbolDefinition)
}
diff --git a/internal/model/graph.go b/internal/model/graph.go
index 724874a..cd7bce8 100644
--- a/internal/model/graph.go
+++ b/internal/model/graph.go
@@ -190,6 +190,32 @@ func (g *KnowledgeGraph) RemoveNode(nodeID string) bool {
return true
}
+// RemoveNodes deletes every selected node and all incident relationships in
+// one graph scan. It avoids repeatedly walking the complete relationship map
+// when a family of derived nodes is rebuilt.
+func (g *KnowledgeGraph) RemoveNodes(predicate func(*GraphNode) bool) int {
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ removed := make(map[string]struct{})
+ for id, node := range g.nodeMap {
+ if predicate(node) {
+ removed[id] = struct{}{}
+ delete(g.nodeMap, id)
+ }
+ }
+ if len(removed) == 0 {
+ return 0
+ }
+ for id, relationship := range g.relationshipMap {
+ _, sourceRemoved := removed[relationship.SourceID]
+ _, targetRemoved := removed[relationship.TargetID]
+ if sourceRemoved || targetRemoved {
+ delete(g.relationshipMap, id)
+ }
+ }
+ return len(removed)
+}
+
// RemoveNodesByFile removes all nodes (and their relationships) belonging to a file path.
func (g *KnowledgeGraph) RemoveNodesByFile(filePath string) int {
g.mu.Lock()
diff --git a/internal/model/graph_test.go b/internal/model/graph_test.go
index 9b88cf7..d062c19 100644
--- a/internal/model/graph_test.go
+++ b/internal/model/graph_test.go
@@ -17,3 +17,20 @@ func TestRemoveDanglingRelationships(t *testing.T) {
t.Fatalf("remaining relationships = %#v", graph.Relationships())
}
}
+
+func TestRemoveNodesRemovesSelectedNodesAndIncidentRelationships(t *testing.T) {
+ graph := NewKnowledgeGraph()
+ graph.AddNode(&GraphNode{ID: "keep", Label: LabelFunction})
+ graph.AddNode(&GraphNode{ID: "drop-a", Label: LabelCommunity})
+ graph.AddNode(&GraphNode{ID: "drop-b", Label: LabelProcess})
+ graph.AddRelationship(&GraphRelationship{ID: "incident", SourceID: "keep", TargetID: "drop-a"})
+ graph.AddRelationship(&GraphRelationship{ID: "removed", SourceID: "drop-a", TargetID: "drop-b"})
+ if got := graph.RemoveNodes(func(node *GraphNode) bool {
+ return node.Label == LabelCommunity || node.Label == LabelProcess
+ }); got != 2 {
+ t.Fatalf("removed=%d, want 2", got)
+ }
+ if graph.NodeCount() != 1 || graph.RelationshipCount() != 0 {
+ t.Fatalf("nodes=%d relationships=%d", graph.NodeCount(), graph.RelationshipCount())
+ }
+}
diff --git a/internal/search/semantic/delta.go b/internal/search/semantic/delta.go
new file mode 100644
index 0000000..4e77f97
--- /dev/null
+++ b/internal/search/semantic/delta.go
@@ -0,0 +1,189 @@
+package semantic
+
+import (
+ "fmt"
+ "sort"
+
+ "github.com/cockroachdb/pebble/v2"
+ "github.com/coder/hnsw"
+ "github.com/mengshi02/codetrip/internal/graph"
+ "github.com/mengshi02/codetrip/internal/util"
+)
+
+type VectorChange struct {
+ NodeID string
+ Modality string
+ Chunk int
+ Payload []byte
+}
+
+type VectorDelete struct {
+ NodeID string
+ Modality string
+ Chunk int
+}
+
+// ApplyDelta persists changed vectors and modality membership in one Pebble
+// batch, then updates already-loaded ANN indexes in memory. It never embeds or
+// scans unchanged nodes.
+func (s *VectorSearch) ApplyDelta(puts []VectorChange, deletes []VectorDelete) error {
+ if len(puts) == 0 && len(deletes) == 0 {
+ return nil
+ }
+ repo := s.graph.Repo()
+ descIDs, err := s.vectorIDSet(graph.EmbDescIdxKey(repo))
+ if err != nil {
+ return err
+ }
+ codeIDs, err := s.vectorIDSet(graph.EmbCodeIdxKey(repo))
+ if err != nil {
+ return err
+ }
+ keyFor := func(modality, nodeID string, chunk int) (string, error) {
+ switch modality {
+ case "desc":
+ if chunk < 0 {
+ return graph.EmbDescKey(repo, nodeID), nil
+ }
+ return graph.EmbDescChunkKey(repo, nodeID, chunk), nil
+ case "code":
+ if chunk < 0 {
+ return graph.EmbCodeKey(repo, nodeID), nil
+ }
+ return graph.EmbCodeChunkKey(repo, nodeID, chunk), nil
+ default:
+ return "", fmt.Errorf("unknown vector modality %q", modality)
+ }
+ }
+ deleteKeys := make([]string, 0, len(deletes))
+ for _, item := range deletes {
+ if item.NodeID == "" {
+ return fmt.Errorf("vector delete has empty node ID")
+ }
+ if item.Chunk < -2 {
+ return fmt.Errorf("invalid vector chunk %d", item.Chunk)
+ }
+ key, err := keyFor(item.Modality, item.NodeID, item.Chunk)
+ if err != nil {
+ return err
+ }
+ deleteKeys = append(deleteKeys, key)
+ if item.Chunk == -2 {
+ prefix := []byte(key + ":")
+ if err := s.store.ScanPrefix(prefix, func(storedKey, _ []byte) error {
+ deleteKeys = append(deleteKeys, string(storedKey))
+ return nil
+ }); err != nil {
+ return err
+ }
+ }
+ if item.Chunk < 0 {
+ if item.Modality == "desc" {
+ delete(descIDs, item.NodeID)
+ } else {
+ delete(codeIDs, item.NodeID)
+ }
+ }
+ }
+ for _, item := range puts {
+ if item.NodeID == "" || len(item.Payload) == 0 {
+ return fmt.Errorf("invalid vector put for node %q", item.NodeID)
+ }
+ if _, err := keyFor(item.Modality, item.NodeID, item.Chunk); err != nil {
+ return err
+ }
+ if item.Chunk < 0 {
+ if item.Modality == "desc" {
+ descIDs[item.NodeID] = true
+ } else {
+ codeIDs[item.NodeID] = true
+ }
+ }
+ }
+ if err := s.store.BatchNoSync(func(batch *pebble.Batch) error {
+ for _, key := range deleteKeys {
+ if err := batch.Delete([]byte(key), nil); err != nil {
+ return err
+ }
+ }
+ for _, item := range puts {
+ key, _ := keyFor(item.Modality, item.NodeID, item.Chunk)
+ if err := batch.Set([]byte(key), item.Payload, nil); err != nil {
+ return err
+ }
+ }
+ if err := batch.Set([]byte(graph.EmbDescIdxKey(repo)), util.EncodeStringList(sortedVectorIDs(descIDs)), nil); err != nil {
+ return err
+ }
+ return batch.Set([]byte(graph.EmbCodeIdxKey(repo)), util.EncodeStringList(sortedVectorIDs(codeIDs)), nil)
+ }); err != nil {
+ return err
+ }
+
+ s.hnswMu.Lock()
+ defer s.hnswMu.Unlock()
+ for _, item := range deletes {
+ if item.Chunk >= 0 {
+ continue
+ }
+ if item.Modality == "desc" && s.descHnswIdx != nil {
+ s.descHnswIdx.Delete(item.NodeID)
+ }
+ if item.Modality == "code" && s.codeHnswIdx != nil {
+ s.codeHnswIdx.Delete(item.NodeID)
+ }
+ }
+ for _, item := range puts {
+ if item.Chunk >= 0 {
+ continue
+ }
+ vector := util.DecodeFloat32Vec(item.Payload)
+ if item.Modality == "desc" && s.descHnswIdx != nil {
+ s.descHnswIdx.Delete(item.NodeID)
+ if err := s.descHnswIdx.Add(hnsw.MakeNode(item.NodeID, vector)); err != nil {
+ return err
+ }
+ }
+ if item.Modality == "code" && s.codeHnswIdx != nil {
+ s.codeHnswIdx.Delete(item.NodeID)
+ if err := s.codeHnswIdx.Add(hnsw.MakeNode(item.NodeID, vector)); err != nil {
+ return err
+ }
+ }
+ }
+ // A previously loaded quantized snapshot is now stale; searches use the
+ // updated float ANN indexes until the next explicit vector compaction.
+ if s.vecFile != nil {
+ s.vecFile.Close()
+ s.vecFile = nil
+ }
+ return nil
+}
+
+func (s *VectorSearch) vectorIDSet(key string) (map[string]bool, error) {
+ result := make(map[string]bool)
+ encoded, err := s.store.Get([]byte(key))
+ if err == pebble.ErrNotFound {
+ return result, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ ids, err := util.DecodeStringList(encoded)
+ if err != nil {
+ return nil, err
+ }
+ for _, id := range ids {
+ result[id] = true
+ }
+ return result, nil
+}
+
+func sortedVectorIDs(ids map[string]bool) []string {
+ result := make([]string, 0, len(ids))
+ for id := range ids {
+ result = append(result, id)
+ }
+ sort.Strings(result)
+ return result
+}
diff --git a/internal/search/semantic/delta_test.go b/internal/search/semantic/delta_test.go
new file mode 100644
index 0000000..ba2604d
--- /dev/null
+++ b/internal/search/semantic/delta_test.go
@@ -0,0 +1,74 @@
+package semantic
+
+import (
+ "errors"
+ "testing"
+
+ "github.com/cockroachdb/pebble/v2"
+ "github.com/mengshi02/codetrip/internal/graph"
+ "github.com/mengshi02/codetrip/internal/store"
+ "github.com/mengshi02/codetrip/internal/util"
+)
+
+func TestVectorApplyDeltaUpdatesValuesAndMembership(t *testing.T) {
+ database, err := store.Open(store.DefaultConfig(t.TempDir()))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer database.Close()
+ graphStore := graph.NewGraphStore(database, "snapshot")
+ search := NewVectorSearch(nil, database, graphStore)
+ first := util.EncodeFloat32Vec([]float32{1, 0, 0})
+ chunk := util.EncodeFloat32Vec([]float32{0, 1, 0})
+ if err := search.ApplyDelta([]VectorChange{
+ {NodeID: "node-a", Modality: "desc", Chunk: -1, Payload: first},
+ {NodeID: "node-a", Modality: "code", Chunk: 0, Payload: chunk},
+ }, nil); err != nil {
+ t.Fatal(err)
+ }
+ if encoded, err := database.Get([]byte(graph.EmbDescKey("snapshot", "node-a"))); err != nil || string(encoded) != string(first) {
+ t.Fatalf("description=%v error=%v", encoded, err)
+ }
+ if encoded, err := database.Get([]byte(graph.EmbCodeChunkKey("snapshot", "node-a", 0))); err != nil || string(encoded) != string(chunk) {
+ t.Fatalf("chunk=%v error=%v", encoded, err)
+ }
+ encodedIDs, err := database.Get([]byte(graph.EmbDescIdxKey("snapshot")))
+ if err != nil {
+ t.Fatal(err)
+ }
+ ids, err := util.DecodeStringList(encodedIDs)
+ if err != nil || len(ids) != 1 || ids[0] != "node-a" {
+ t.Fatalf("ids=%#v error=%v", ids, err)
+ }
+ if err := search.ApplyDelta(nil, []VectorDelete{
+ {NodeID: "node-a", Modality: "desc", Chunk: -1},
+ {NodeID: "node-a", Modality: "code", Chunk: -2},
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := database.Get([]byte(graph.EmbDescKey("snapshot", "node-a"))); !errors.Is(err, pebble.ErrNotFound) {
+ t.Fatalf("description delete error=%v", err)
+ }
+ if _, err := database.Get([]byte(graph.EmbCodeChunkKey("snapshot", "node-a", 0))); !errors.Is(err, pebble.ErrNotFound) {
+ t.Fatalf("chunk delete error=%v", err)
+ }
+}
+
+func TestVectorApplyDeltaRejectsInvalidModalityBeforeWriting(t *testing.T) {
+ database, err := store.Open(store.DefaultConfig(t.TempDir()))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer database.Close()
+ search := NewVectorSearch(nil, database, graph.NewGraphStore(database, "snapshot"))
+ err = search.ApplyDelta([]VectorChange{
+ {NodeID: "valid", Modality: "desc", Chunk: -1, Payload: util.EncodeFloat32Vec([]float32{1})},
+ {NodeID: "invalid", Modality: "other", Chunk: -1, Payload: util.EncodeFloat32Vec([]float32{1})},
+ }, nil)
+ if err == nil {
+ t.Fatal("expected invalid modality error")
+ }
+ if _, err := database.Get([]byte(graph.EmbDescKey("snapshot", "valid"))); !errors.Is(err, pebble.ErrNotFound) {
+ t.Fatalf("partial vector write error=%v", err)
+ }
+}
diff --git a/internal/search/source/native.go b/internal/search/source/native.go
index b85f0a4..c514df2 100644
--- a/internal/search/source/native.go
+++ b/internal/search/source/native.go
@@ -23,6 +23,7 @@ type Index struct {
directory string
mu sync.RWMutex
searchers []engine.Searcher
+ updates []updateLayer
}
func New(dataDir, snapshot string) *Index {
@@ -94,6 +95,8 @@ func (idx *Index) Build(repositoryPath, snapshot string) error {
if err := os.Rename(buildDir, idx.directory); err != nil {
return err
}
+ // Only a successfully published full snapshot supersedes prior updates.
+ _ = os.RemoveAll(updateRoot(idx.directory))
return idx.Open()
}
@@ -127,9 +130,17 @@ func (idx *Index) Open() error {
}
searchers = append(searchers, searcher)
}
+ updates, err := loadUpdateLayers(idx.directory)
+ if err != nil {
+ closeSearchers(searchers)
+ return err
+ }
idx.mu.Lock()
+ oldUpdates := idx.updates
idx.searchers = searchers
+ idx.updates = updates
idx.mu.Unlock()
+ closeUpdateLayers(oldUpdates)
return nil
}
@@ -158,13 +169,21 @@ func (idx *Index) Search(ctx context.Context, queryText string, scope Scope, lim
if limit <= 0 {
limit = 20
}
- options := &engine.SearchOptions{TotalMaxMatchCount: limit, MaxDocDisplayCount: limit, MaxMatchDisplayCount: limit, NumContextLines: contextLines, MaxWallTime: 30 * time.Second}
idx.mu.RLock()
searchers := append([]engine.Searcher(nil), idx.searchers...)
+ updates := append([]updateLayer(nil), idx.updates...)
idx.mu.RUnlock()
if len(searchers) == 0 {
return nil, fmt.Errorf("content index is not open")
}
+ shadowed := make(map[string]struct{})
+ for _, layer := range updates {
+ for _, path := range layer.meta.Paths {
+ shadowed[path] = struct{}{}
+ }
+ }
+ baseLimit := limit + len(shadowed)
+ options := &engine.SearchOptions{TotalMaxMatchCount: baseLimit, MaxDocDisplayCount: baseLimit, MaxMatchDisplayCount: baseLimit, NumContextLines: contextLines, MaxWallTime: 30 * time.Second}
result := make([]Match, 0, limit)
for _, searcher := range searchers {
found, err := searcher.Search(ctx, query, options)
@@ -172,6 +191,9 @@ func (idx *Index) Search(ctx context.Context, queryText string, scope Scope, lim
return nil, err
}
for _, file := range found.Files {
+ if _, hidden := shadowed[file.FileName]; hidden {
+ continue
+ }
for _, line := range file.LineMatches {
result = append(result, Match{FilePath: file.FileName, Language: file.Language, Line: line.LineNumber, Content: string(line.Line), Before: string(line.Before), After: string(line.After), Score: file.Score + line.Score})
if len(result) >= limit {
@@ -186,6 +208,25 @@ func (idx *Index) Search(ctx context.Context, queryText string, scope Scope, lim
break
}
}
+ // Newest layer wins. Older layers are searched only for paths that have not
+ // been replaced or deleted by a later revision.
+ newer := make(map[string]struct{})
+ for layerIndex := len(updates) - 1; layerIndex >= 0; layerIndex-- {
+ layer := updates[layerIndex]
+ matches, searchErr := layer.index.Search(ctx, queryText, scope, limit, contextLines)
+ if searchErr != nil {
+ return nil, searchErr
+ }
+ for _, match := range matches {
+ if _, hidden := newer[match.FilePath]; hidden {
+ continue
+ }
+ result = append(result, match)
+ }
+ for _, path := range layer.meta.Paths {
+ newer[path] = struct{}{}
+ }
+ }
sort.SliceStable(result, func(i, j int) bool { return result[i].Score > result[j].Score })
if len(result) > limit {
result = result[:limit]
@@ -193,12 +234,59 @@ func (idx *Index) Search(ctx context.Context, queryText string, scope Scope, lim
return result, nil
}
+// ApplyDelta publishes an immutable source update layer. It indexes only
+// changed files; deleted and replaced paths shadow older layers and the full
+// snapshot after one atomic manifest rename.
+func (idx *Index) ApplyDelta(putFiles map[string][]byte, deletePaths []string) error {
+ return idx.applyRevision(0, putFiles, deletePaths)
+}
+
+// ApplyRevision is idempotent for a durable repository revision, allowing the
+// recovery protocol to replay a component after interruption.
+func (idx *Index) ApplyRevision(revision uint64, putFiles map[string][]byte, deletePaths []string) error {
+ return idx.applyRevision(revision, putFiles, deletePaths)
+}
+
+func (idx *Index) applyRevision(revision uint64, putFiles map[string][]byte, deletePaths []string) error {
+ if len(putFiles) == 0 && len(deletePaths) == 0 {
+ return nil
+ }
+ idx.mu.Lock()
+ defer idx.mu.Unlock()
+ layer, err := publishUpdateLayer(idx.directory, idx.updates, revision, putFiles, deletePaths)
+ if err != nil {
+ return err
+ }
+ for _, existing := range idx.updates {
+ if revision != 0 && existing.meta.Revision == revision {
+ return nil
+ }
+ }
+ idx.updates = append(idx.updates, layer)
+ if len(idx.updates) >= updateLayerCompactThreshold {
+ compacted, compactErr := compactUpdateLayers(context.Background(), idx.directory, idx.updates)
+ if compactErr != nil {
+ return compactErr
+ }
+ old := idx.updates
+ idx.updates = []updateLayer{compacted}
+ closeUpdateLayers(old)
+ for _, previous := range old {
+ _ = os.RemoveAll(previous.index.directory)
+ }
+ }
+ return nil
+}
+
func (idx *Index) Close() {
idx.mu.Lock()
current := idx.searchers
+ updates := idx.updates
idx.searchers = nil
+ idx.updates = nil
idx.mu.Unlock()
closeSearchers(current)
+ closeUpdateLayers(updates)
}
func closeSearchers(searchers []engine.Searcher) {
diff --git a/internal/search/source/native_test.go b/internal/search/source/native_test.go
new file mode 100644
index 0000000..201eaa0
--- /dev/null
+++ b/internal/search/source/native_test.go
@@ -0,0 +1,148 @@
+//go:build !windows
+
+package source
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestNativeIndexIncrementalLayersSurviveReopen(t *testing.T) {
+ repository := t.TempDir()
+ dataDir := t.TempDir()
+ write := func(name, content string) {
+ t.Helper()
+ if err := os.WriteFile(filepath.Join(repository, name), []byte(content), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ write("change.go", "package p\nfunc OriginalSymbol() {}\n")
+ write("delete.go", "package p\nfunc RemovedSymbol() {}\n")
+ index := New(dataDir, "snapshot")
+ if err := index.Build(repository, "snapshot"); err != nil {
+ t.Fatal(err)
+ }
+ if err := index.ApplyDelta(map[string][]byte{
+ "change.go": []byte("package p\nfunc FirstUpdate() {}\n"),
+ "added.ts": []byte("export function AddedSymbol() {}\n"),
+ }, []string{"delete.go"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := index.ApplyDelta(map[string][]byte{
+ "change.go": []byte("package p\nfunc FinalUpdate() {}\n"),
+ }, nil); err != nil {
+ t.Fatal(err)
+ }
+ assertSourceDelta(t, index)
+ index.Close()
+
+ reopened := New(dataDir, "snapshot")
+ if err := reopened.Open(); err != nil {
+ t.Fatal(err)
+ }
+ defer reopened.Close()
+ assertSourceDelta(t, reopened)
+}
+
+func TestNativeFullBuildSupersedesIncrementalLayers(t *testing.T) {
+ repository := t.TempDir()
+ dataDir := t.TempDir()
+ path := filepath.Join(repository, "main.go")
+ if err := os.WriteFile(path, []byte("package p\nfunc FullOne() {}\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ index := New(dataDir, "snapshot")
+ if err := index.Build(repository, "snapshot"); err != nil {
+ t.Fatal(err)
+ }
+ if err := index.ApplyDelta(map[string][]byte{"main.go": []byte("package p\nfunc IncrementalOnly() {}\n")}, nil); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte("package p\nfunc FullTwo() {}\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := index.Build(repository, "snapshot"); err != nil {
+ t.Fatal(err)
+ }
+ defer index.Close()
+ results, err := index.Search(context.Background(), "FullTwo", ScopeCode, 10, 0)
+ if err != nil || len(results) != 1 {
+ t.Fatalf("full result=%#v error=%v", results, err)
+ }
+ if results, err := index.Search(context.Background(), "IncrementalOnly", ScopeCode, 10, 0); err != nil || len(results) != 0 {
+ t.Fatalf("stale incremental result=%#v error=%v", results, err)
+ }
+}
+
+func TestNativeApplyRevisionIsIdempotent(t *testing.T) {
+ repository := t.TempDir()
+ dataDir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(repository, "main.go"), []byte("package p\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ index := New(dataDir, "snapshot")
+ if err := index.Build(repository, "snapshot"); err != nil {
+ t.Fatal(err)
+ }
+ defer index.Close()
+ files := map[string][]byte{"main.go": []byte("package p\nfunc Updated() {}\n")}
+ if err := index.ApplyRevision(7, files, nil); err != nil {
+ t.Fatal(err)
+ }
+ if err := index.ApplyRevision(7, files, nil); err != nil {
+ t.Fatal(err)
+ }
+ if len(index.updates) != 1 || index.updates[0].meta.Revision != 7 {
+ t.Fatalf("updates=%#v", index.updates)
+ }
+}
+
+func TestUpdateLayerCompactionPreservesNewestContentAndDeletions(t *testing.T) {
+ repository := t.TempDir()
+ dataDir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(repository, "base.go"), []byte("package p\nfunc BaseSymbol() {}\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ index := New(dataDir, "snapshot")
+ if err := index.Build(repository, "snapshot"); err != nil {
+ t.Fatal(err)
+ }
+ defer index.Close()
+ if err := index.ApplyRevision(2, map[string][]byte{"base.go": []byte("package p\nfunc FirstSymbol() {}\n")}, nil); err != nil {
+ t.Fatal(err)
+ }
+ if err := index.ApplyRevision(3, map[string][]byte{"added.ts": []byte("export function AddedSymbol() {}\n")}, []string{"base.go"}); err != nil {
+ t.Fatal(err)
+ }
+ compacted, err := compactUpdateLayers(context.Background(), index.directory, index.updates)
+ if err != nil {
+ t.Fatal(err)
+ }
+ old := index.updates
+ index.updates = []updateLayer{compacted}
+ closeUpdateLayers(old)
+ for query, want := range map[string]int{"AddedSymbol": 1, "BaseSymbol": 0, "FirstSymbol": 0} {
+ results, err := index.Search(context.Background(), query, ScopeCode, 10, 0)
+ if err != nil || len(results) != want {
+ t.Fatalf("query=%q results=%#v error=%v", query, results, err)
+ }
+ }
+}
+
+func assertSourceDelta(t *testing.T, index *Index) {
+ t.Helper()
+ for query, wantPath := range map[string]string{"FinalUpdate": "change.go", "AddedSymbol": "added.ts"} {
+ results, err := index.Search(context.Background(), query, ScopeCode, 10, 0)
+ if err != nil || len(results) != 1 || results[0].FilePath != wantPath {
+ t.Fatalf("query=%q results=%#v error=%v", query, results, err)
+ }
+ }
+ for _, query := range []string{"OriginalSymbol", "FirstUpdate", "RemovedSymbol"} {
+ results, err := index.Search(context.Background(), query, ScopeCode, 10, 0)
+ if err != nil || len(results) != 0 {
+ t.Fatalf("stale query=%q results=%#v error=%v", query, results, err)
+ }
+ }
+}
diff --git a/internal/search/source/portable.go b/internal/search/source/portable.go
index 0cc86b9..1be1dd4 100644
--- a/internal/search/source/portable.go
+++ b/internal/search/source/portable.go
@@ -39,6 +39,54 @@ type portableIndex struct {
writer *bluge.Writer
}
+func newPortableIndexAt(directory string) *portableIndex {
+ return &portableIndex{directory: directory}
+}
+
+// BuildFiles creates an immutable small source segment from supplied files.
+// It is used by the generic incremental publication layer; full Build remains
+// the authoritative repository walk.
+func (idx *portableIndex) BuildFiles(files map[string][]byte) error {
+ _ = os.RemoveAll(idx.directory)
+ if err := os.MkdirAll(idx.directory, 0o755); err != nil {
+ return err
+ }
+ writer, err := bluge.OpenWriter(bluge.DefaultConfig(idx.directory))
+ if err != nil {
+ return err
+ }
+ batch := bluge.NewBatch()
+ for path, content := range files {
+ path = filepath.ToSlash(path)
+ kind := ClassifyFile(path, content)
+ if kind == "" {
+ continue
+ }
+ language := strings.ToLower(enry.GetLanguage(path, content))
+ document := bluge.NewDocument(path)
+ document.AddField(bluge.NewKeywordField(fieldPath, path).StoreValue())
+ document.AddField(bluge.NewKeywordField(fieldLanguage, language).StoreValue())
+ document.AddField(bluge.NewKeywordField(fieldKind, string(kind)).StoreValue())
+ document.AddField(bluge.NewTextField(fieldContent, string(content)).StoreValue())
+ batch.Update(document.ID(), document)
+ }
+ if err := writer.Batch(batch); err != nil {
+ writer.Close()
+ return err
+ }
+ if err := writer.Close(); err != nil {
+ return err
+ }
+ manifest, err := json.Marshal(portableManifest{SchemaVersion: portableSchemaVersion, Backend: "portable"})
+ if err != nil {
+ return err
+ }
+ if err := os.WriteFile(filepath.Join(idx.directory, portableManifestName), manifest, 0o644); err != nil {
+ return err
+ }
+ return idx.Open()
+}
+
func newPortableIndex(dataDir, snapshot string) *portableIndex {
return &portableIndex{directory: filepath.Join(dataDir, "content", snapshot)}
}
@@ -287,6 +335,92 @@ func (idx *portableIndex) Search(ctx context.Context, queryText string, scope Sc
return results, nil
}
+// ApplyDelta updates only changed source documents. Paths are stable document
+// IDs, making the operation language-independent and safe for renames
+// (represented as one delete plus one put).
+func (idx *portableIndex) ApplyDelta(putFiles map[string][]byte, deletePaths []string) error {
+ if len(putFiles) == 0 && len(deletePaths) == 0 {
+ return nil
+ }
+ idx.mu.Lock()
+ defer idx.mu.Unlock()
+ if idx.writer == nil {
+ return fmt.Errorf("portable content index is not open")
+ }
+ batch := bluge.NewBatch()
+ for _, path := range deletePaths {
+ path = filepath.ToSlash(path)
+ if path != "" {
+ batch.Delete(bluge.Identifier(path))
+ }
+ }
+ for path, content := range putFiles {
+ path = filepath.ToSlash(path)
+ kind := ClassifyFile(path, content)
+ if kind == "" {
+ // A file becoming binary or unsupported must remove its previous
+ // searchable representation.
+ batch.Delete(bluge.Identifier(path))
+ continue
+ }
+ language := strings.ToLower(enry.GetLanguage(path, content))
+ document := bluge.NewDocument(path)
+ document.AddField(bluge.NewKeywordField(fieldPath, path).StoreValue())
+ document.AddField(bluge.NewKeywordField(fieldLanguage, language).StoreValue())
+ document.AddField(bluge.NewKeywordField(fieldKind, string(kind)).StoreValue())
+ document.AddField(bluge.NewTextField(fieldContent, string(content)).StoreValue())
+ batch.Update(document.ID(), document)
+ }
+ return idx.writer.Batch(batch)
+}
+
+// Documents returns the latest stored document contents keyed by path. It is
+// used only for compacting small immutable update layers, never for querying
+// or rebuilding the full repository snapshot.
+func (idx *portableIndex) Documents(ctx context.Context) (map[string][]byte, error) {
+ idx.mu.RLock()
+ writer := idx.writer
+ idx.mu.RUnlock()
+ if writer == nil {
+ return nil, fmt.Errorf("portable content index is not open")
+ }
+ reader, err := writer.Reader()
+ if err != nil {
+ return nil, err
+ }
+ defer reader.Close()
+ iterator, err := reader.Search(ctx, bluge.NewAllMatches(bluge.NewMatchAllQuery()))
+ if err != nil {
+ return nil, err
+ }
+ result := make(map[string][]byte)
+ for {
+ document, err := iterator.Next()
+ if err != nil {
+ return nil, err
+ }
+ if document == nil {
+ return result, nil
+ }
+ var path string
+ var content []byte
+ if err := document.VisitStoredFields(func(field string, value []byte) bool {
+ switch field {
+ case fieldPath:
+ path = string(value)
+ case fieldContent:
+ content = append(content[:0], value...)
+ }
+ return true
+ }); err != nil {
+ return nil, err
+ }
+ if path != "" {
+ result[path] = content
+ }
+ }
+}
+
func matchPortableLines(path, language, content string, query portableQuery, contextLines, remaining int) []Match {
if remaining <= 0 {
return nil
diff --git a/internal/search/source/portable_test.go b/internal/search/source/portable_test.go
index 8f2586d..18e1be4 100644
--- a/internal/search/source/portable_test.go
+++ b/internal/search/source/portable_test.go
@@ -79,3 +79,39 @@ func TestPortableQueryRejectsInvalidRegularExpression(t *testing.T) {
t.Fatal("expected invalid regular expression error")
}
}
+
+func TestPortableIndexApplyDeltaUpdatesDeletesAndAdds(t *testing.T) {
+ repository := t.TempDir()
+ dataDir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(repository, "old.go"), []byte("package p\nfunc OldSymbol() {}\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(repository, "delete.go"), []byte("package p\nfunc DeleteSymbol() {}\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ index := newPortableIndex(dataDir, "snapshot")
+ if err := index.Build(repository, "snapshot"); err != nil {
+ t.Fatal(err)
+ }
+ defer index.Close()
+ if err := index.ApplyDelta(map[string][]byte{
+ "old.go": []byte("package p\nfunc NewSymbol() {}\n"),
+ "added.ts": []byte("export function AddedSymbol() {}\n"),
+ }, []string{"delete.go"}); err != nil {
+ t.Fatal(err)
+ }
+ assertPaths := func(query string, want string) {
+ t.Helper()
+ results, err := index.Search(context.Background(), query, ScopeCode, 10, 0)
+ if err != nil || len(results) != 1 || results[0].FilePath != want {
+ t.Fatalf("query=%q results=%#v error=%v", query, results, err)
+ }
+ }
+ assertPaths("NewSymbol", "old.go")
+ assertPaths("AddedSymbol", "added.ts")
+ for _, query := range []string{"OldSymbol", "DeleteSymbol"} {
+ if results, err := index.Search(context.Background(), query, ScopeCode, 10, 0); err != nil || len(results) != 0 {
+ t.Fatalf("query=%q results=%#v error=%v", query, results, err)
+ }
+ }
+}
diff --git a/internal/search/source/updates.go b/internal/search/source/updates.go
new file mode 100644
index 0000000..e2dbf46
--- /dev/null
+++ b/internal/search/source/updates.go
@@ -0,0 +1,201 @@
+//go:build !windows
+
+package source
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strconv"
+)
+
+const updateManifestVersion = 1
+const updateLayerCompactThreshold = 32
+
+type updateLayerMeta struct {
+ Sequence uint64 `json:"sequence"`
+ Revision uint64 `json:"revision,omitempty"`
+ Paths []string `json:"paths"`
+}
+
+type updateManifest struct {
+ Version int `json:"version"`
+ Layers []updateLayerMeta `json:"layers,omitempty"`
+}
+
+type updateLayer struct {
+ meta updateLayerMeta
+ index *portableIndex
+}
+
+func updateRoot(baseDirectory string) string { return baseDirectory + ".updates" }
+
+func loadUpdateLayers(baseDirectory string) ([]updateLayer, error) {
+ root := updateRoot(baseDirectory)
+ encoded, err := os.ReadFile(filepath.Join(root, "manifest.json"))
+ if os.IsNotExist(err) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ var manifest updateManifest
+ if err := json.Unmarshal(encoded, &manifest); err != nil || manifest.Version != updateManifestVersion {
+ return nil, fmt.Errorf("source update manifest is incompatible")
+ }
+ layers := make([]updateLayer, 0, len(manifest.Layers))
+ for _, meta := range manifest.Layers {
+ index := newPortableIndexAt(filepath.Join(root, "layer-"+strconv.FormatUint(meta.Sequence, 10)))
+ if err := index.Open(); err != nil {
+ closeUpdateLayers(layers)
+ return nil, err
+ }
+ layers = append(layers, updateLayer{meta: meta, index: index})
+ }
+ return layers, nil
+}
+
+func publishUpdateLayer(baseDirectory string, existing []updateLayer, revision uint64, putFiles map[string][]byte, deletePaths []string) (updateLayer, error) {
+ var sequence uint64 = 1
+ if len(existing) > 0 {
+ sequence = existing[len(existing)-1].meta.Sequence + 1
+ }
+ if revision != 0 {
+ for _, layer := range existing {
+ if layer.meta.Revision == revision {
+ return layer, nil
+ }
+ }
+ }
+ paths := make([]string, 0, len(putFiles)+len(deletePaths))
+ seen := make(map[string]struct{}, cap(paths))
+ for path := range putFiles {
+ path = filepath.ToSlash(path)
+ if _, ok := seen[path]; !ok {
+ seen[path] = struct{}{}
+ paths = append(paths, path)
+ }
+ }
+ for _, path := range deletePaths {
+ path = filepath.ToSlash(path)
+ if _, ok := seen[path]; !ok {
+ seen[path] = struct{}{}
+ paths = append(paths, path)
+ }
+ }
+ sort.Strings(paths)
+ root := updateRoot(baseDirectory)
+ if err := os.MkdirAll(root, 0o755); err != nil {
+ return updateLayer{}, err
+ }
+ finalDirectory := filepath.Join(root, "layer-"+strconv.FormatUint(sequence, 10))
+ pendingDirectory := finalDirectory + ".pending"
+ index := newPortableIndexAt(pendingDirectory)
+ if err := index.BuildFiles(putFiles); err != nil {
+ return updateLayer{}, err
+ }
+ if err := index.Close(); err != nil {
+ return updateLayer{}, err
+ }
+ if err := os.Rename(pendingDirectory, finalDirectory); err != nil {
+ return updateLayer{}, err
+ }
+ meta := updateLayerMeta{Sequence: sequence, Revision: revision, Paths: paths}
+ manifest := updateManifest{Version: updateManifestVersion, Layers: make([]updateLayerMeta, 0, len(existing)+1)}
+ for _, layer := range existing {
+ manifest.Layers = append(manifest.Layers, layer.meta)
+ }
+ manifest.Layers = append(manifest.Layers, meta)
+ encoded, err := json.Marshal(manifest)
+ if err != nil {
+ return updateLayer{}, err
+ }
+ pendingManifest := filepath.Join(root, "manifest.pending")
+ if err := os.WriteFile(pendingManifest, encoded, 0o644); err != nil {
+ return updateLayer{}, err
+ }
+ if err := os.Rename(pendingManifest, filepath.Join(root, "manifest.json")); err != nil {
+ return updateLayer{}, err
+ }
+ published := newPortableIndexAt(finalDirectory)
+ if err := published.Open(); err != nil {
+ return updateLayer{}, err
+ }
+ return updateLayer{meta: meta, index: published}, nil
+}
+
+func closeUpdateLayers(layers []updateLayer) {
+ for _, layer := range layers {
+ _ = layer.index.Close()
+ }
+}
+
+// compactUpdateLayers merges only the immutable update layers. It never walks
+// or rewrites the full source snapshot. Paths absent from the merged document
+// set remain in meta.Paths as deletion tombstones that continue to shadow the
+// base snapshot.
+func compactUpdateLayers(ctx context.Context, baseDirectory string, layers []updateLayer) (updateLayer, error) {
+ if len(layers) < 2 {
+ if len(layers) == 1 {
+ return layers[0], nil
+ }
+ return updateLayer{}, nil
+ }
+ seen := make(map[string]bool)
+ current := make(map[string][]byte)
+ for index := len(layers) - 1; index >= 0; index-- {
+ documents, err := layers[index].index.Documents(ctx)
+ if err != nil {
+ return updateLayer{}, err
+ }
+ for _, path := range layers[index].meta.Paths {
+ if seen[path] {
+ continue
+ }
+ seen[path] = true
+ if content, exists := documents[path]; exists {
+ current[path] = content
+ }
+ }
+ }
+ paths := make([]string, 0, len(seen))
+ for path := range seen {
+ paths = append(paths, path)
+ }
+ sort.Strings(paths)
+ root := updateRoot(baseDirectory)
+ sequence := layers[len(layers)-1].meta.Sequence + 1
+ revision := layers[len(layers)-1].meta.Revision
+ finalDirectory := filepath.Join(root, "layer-"+strconv.FormatUint(sequence, 10))
+ pendingDirectory := finalDirectory + ".pending"
+ index := newPortableIndexAt(pendingDirectory)
+ if err := index.BuildFiles(current); err != nil {
+ return updateLayer{}, err
+ }
+ if err := index.Close(); err != nil {
+ return updateLayer{}, err
+ }
+ if err := os.Rename(pendingDirectory, finalDirectory); err != nil {
+ return updateLayer{}, err
+ }
+ meta := updateLayerMeta{Sequence: sequence, Revision: revision, Paths: paths}
+ encoded, err := json.Marshal(updateManifest{Version: updateManifestVersion, Layers: []updateLayerMeta{meta}})
+ if err != nil {
+ return updateLayer{}, err
+ }
+ pendingManifest := filepath.Join(root, "manifest.pending")
+ if err := os.WriteFile(pendingManifest, encoded, 0o644); err != nil {
+ return updateLayer{}, err
+ }
+ if err := os.Rename(pendingManifest, filepath.Join(root, "manifest.json")); err != nil {
+ return updateLayer{}, err
+ }
+ published := newPortableIndexAt(finalDirectory)
+ if err := published.Open(); err != nil {
+ return updateLayer{}, err
+ }
+ return updateLayer{meta: meta, index: published}, nil
+}
diff --git a/internal/search/source/windows.go b/internal/search/source/windows.go
index 472cf6a..8708c25 100644
--- a/internal/search/source/windows.go
+++ b/internal/search/source/windows.go
@@ -9,3 +9,9 @@ type Index struct {
func New(dataDir, snapshot string) *Index {
return &Index{portableIndex: newPortableIndex(dataDir, snapshot)}
}
+
+// ApplyRevision is naturally idempotent because portable document IDs are
+// stable file paths and one batch replaces the same records.
+func (idx *Index) ApplyRevision(_ uint64, putFiles map[string][]byte, deletePaths []string) error {
+ return idx.ApplyDelta(putFiles, deletePaths)
+}
diff --git a/internal/search/symbol/lexical.go b/internal/search/symbol/lexical.go
index fbe2f43..f4dcfed 100644
--- a/internal/search/symbol/lexical.go
+++ b/internal/search/symbol/lexical.go
@@ -180,6 +180,33 @@ func (idx *LexicalIndex) BatchIndex(nodes []*graph.Node) error {
return nil
}
+// ApplyDelta updates and deletes symbol documents in one index commit. Stable
+// graph node IDs are also search document IDs, so the incremental coordinator
+// does not need to know anything about the underlying search implementation.
+// The existing full-build methods remain unchanged.
+func (idx *LexicalIndex) ApplyDelta(putNodes []*graph.Node, deleteNodeIDs []string) error {
+ if idx.blugeWriter == nil {
+ return fmt.Errorf("symbol writer not initialized")
+ }
+ if len(putNodes) == 0 && len(deleteNodeIDs) == 0 {
+ return nil
+ }
+ batch := bluge.NewBatch()
+ for _, id := range deleteNodeIDs {
+ if id != "" {
+ batch.Delete(bluge.Identifier(id))
+ }
+ }
+ for _, node := range putNodes {
+ if node == nil || node.ID == "" {
+ return fmt.Errorf("symbol delta contains node without a stable ID")
+ }
+ document := idx.documentToBluge(NewSearchDocument(node))
+ batch.Update(document.ID(), document)
+ }
+ return idx.blugeWriter.Batch(batch)
+}
+
// BatchIndexChunked performs batch indexing in chunks to limit memory usage.
// For 1M+ node repos, indexing all nodes in a single batch can consume excessive memory.
// Chunked indexing breaks the batch into smaller segments (default: 10000 nodes per chunk),
diff --git a/internal/search/symbol/lexical_test.go b/internal/search/symbol/lexical_test.go
index 1ab2fec..c65b48f 100644
--- a/internal/search/symbol/lexical_test.go
+++ b/internal/search/symbol/lexical_test.go
@@ -327,3 +327,36 @@ func TestLexicalBatchIndexEmpty(t *testing.T) {
t.Error("empty batch should not error")
}
}
+
+func TestLexicalApplyDeltaUpdatesAndDeletesInOneCommit(t *testing.T) {
+ idx, cleanup := openTestLexical(t)
+ defer cleanup()
+ one := graph.NewNode("testrepo", graph.LabelFunction, "OldName").WithID("one").WithFile("old.go")
+ two := graph.NewNode("testrepo", graph.LabelFunction, "DeleteMe").WithID("two").WithFile("delete.go")
+ if err := idx.BatchIndex([]*graph.Node{one, two}); err != nil {
+ t.Fatal(err)
+ }
+ updated := graph.NewNode("testrepo", graph.LabelFunction, "NewName").WithID("one").WithFile("new.go")
+ three := graph.NewNode("testrepo", graph.LabelMethod, "AddedName").WithID("three").WithFile("new.go")
+ if err := idx.ApplyDelta([]*graph.Node{updated, three}, []string{"two"}); err != nil {
+ t.Fatal(err)
+ }
+ if results, err := idx.Search("NewName", 10); err != nil || len(results) == 0 || results[0].NodeID != "one" || results[0].Name != "NewName" {
+ t.Fatalf("updated document results=%#v error=%v", results, err)
+ }
+ if results, err := idx.Search("AddedName", 10); err != nil || len(results) == 0 || results[0].NodeID != "three" || results[0].Name != "AddedName" {
+ t.Fatalf("added document results=%#v error=%v", results, err)
+ }
+ if count, err := idx.DocumentCount(); err != nil || count != 2 {
+ t.Fatalf("document count=%d error=%v", count, err)
+ }
+ if results, err := idx.Search("DeleteMe", 10); err != nil {
+ t.Fatal(err)
+ } else {
+ for _, result := range results {
+ if result.NodeID == "two" {
+ t.Fatalf("deleted document remains: %#v", result)
+ }
+ }
+ }
+}
diff --git a/options.go b/options.go
index 8a1a8e7..0bb6e7a 100644
--- a/options.go
+++ b/options.go
@@ -63,13 +63,33 @@ type indexOptions struct {
exportStrict bool
timeout time.Duration
replace bool
+ mode IndexMode
}
// IndexOption configures one repository indexing operation.
type IndexOption func(*indexOptions)
func defaultIndexOptions() indexOptions {
- return indexOptions{timeout: 30 * time.Minute}
+ return indexOptions{timeout: 30 * time.Minute, mode: IndexModeCreate}
+}
+
+// IndexMode controls how an existing repository is handled.
+type IndexMode string
+
+const (
+ // IndexModeCreate preserves the historical API behavior and rejects an
+ // existing repository unless another mode is selected explicitly.
+ IndexModeCreate IndexMode = "create"
+ // IndexModeAuto detects source changes, returns immediately when unchanged,
+ // and selects the safest available update strategy otherwise.
+ IndexModeAuto IndexMode = "auto"
+ // IndexModeFull always builds and atomically publishes a complete snapshot.
+ IndexModeFull IndexMode = "full"
+)
+
+// WithIndexMode selects create-only, automatic, or forced-full indexing.
+func WithIndexMode(mode IndexMode) IndexOption {
+ return func(o *indexOptions) { o.mode = mode }
}
func WithRepoName(name string) IndexOption {
@@ -92,5 +112,10 @@ func WithIndexTimeout(timeout time.Duration) IndexOption {
// WithReplaceExisting atomically replaces an existing logical repository.
func WithReplaceExisting(enabled bool) IndexOption {
- return func(o *indexOptions) { o.replace = enabled }
+ return func(o *indexOptions) {
+ o.replace = enabled
+ if enabled {
+ o.mode = IndexModeFull
+ }
+ }
}