From f36da51d10c6fbaf5d68da1289f6ea2a4433119e Mon Sep 17 00:00:00 2001 From: Dmitry Teryaev Date: Sat, 6 Jun 2026 13:38:40 +0300 Subject: [PATCH] propose: resurrect and update TIER2 incremental rebuild + INDEX-AUTO-MODE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move both stale proposals to propose/active/ with updates for: - Ontology 7→16 (Client, Producer, OVERRIDES, UnresolvedCallSite nodes) - HTTP_CALLS/ASYNC_CALLS retargeted to Client→Route / Producer→Route - CLI integration via `java-codebase-rag increment` (not raw build_ast_graph.py) - New closure rules for OVERRIDES, Client, Producer (rules 7-8) - Extended symmetric delete table for all current node/edge types - Sidecar .deps.json for FileDeps (TBD-2 resolved) - Determinism test as prerequisite (not existing) - All 5 TBD items resolved Co-Authored-By: Claude Opus 4.7 --- propose/active/INDEX-AUTO-MODE-PROPOSE.md | 187 ++++++ .../TIER2-INCREMENTAL-REBUILD-PROPOSE.md | 560 ++++++++++++++++++ propose/stale/INDEX-AUTO-MODE-PROPOSE.md | 124 ---- .../TIER2-INCREMENTAL-REBUILD-PROPOSE.md | 520 ---------------- 4 files changed, 747 insertions(+), 644 deletions(-) create mode 100644 propose/active/INDEX-AUTO-MODE-PROPOSE.md create mode 100644 propose/active/TIER2-INCREMENTAL-REBUILD-PROPOSE.md delete mode 100644 propose/stale/INDEX-AUTO-MODE-PROPOSE.md delete mode 100644 propose/stale/TIER2-INCREMENTAL-REBUILD-PROPOSE.md diff --git a/propose/active/INDEX-AUTO-MODE-PROPOSE.md b/propose/active/INDEX-AUTO-MODE-PROPOSE.md new file mode 100644 index 00000000..9a33cd6c --- /dev/null +++ b/propose/active/INDEX-AUTO-MODE-PROPOSE.md @@ -0,0 +1,187 @@ +# Proposal: Smart `increment` / `reprocess` Mode Selection + +Status: **active — ready for planning**. +Companion proposal: [`propose/active/TIER2-INCREMENTAL-REBUILD-PROPOSE.md`](TIER2-INCREMENTAL-REBUILD-PROPOSE.md) +(Kuzu incremental rebuild implementation). + +## Goal + +Extend `java-codebase-rag increment` so it can choose incremental vs full +rebuild automatically for both Lance and Kuzu, while staying safe for +rename/delete/move and indexing-semantics changes. + +## Problem + +Current behavior: + +- `java-codebase-rag increment` updates Lance vectors incrementally + (via CocoIndex) but prints a warning that the Kuzu graph is stale. +- `java-codebase-rag reprocess` always does a full rebuild of both + Lance and Kuzu. + +The `increment` command is correct for Lance but incomplete for Kuzu. +The Kuzu incremental path is defined in +[`TIER2-INCREMENTAL-REBUILD-PROPOSE.md`](TIER2-INCREMENTAL-REBUILD-PROPOSE.md). +This proposal defines the decision engine that both commands use to +determine when incremental is safe. + +## Proposed API Changes + +### CLI + +No new commands. Existing commands gain automatic mode selection: + +- `java-codebase-rag increment` — incremental Lance + Kuzu when safe, + full Kuzu fallback when not. Lance is always incremental via CocoIndex. +- `java-codebase-rag reprocess` — full rebuild of both Lance + Kuzu + (unchanged). +- `java-codebase-rag reprocess --graph-only` — full Kuzu rebuild only. +- `java-codebase-rag reprocess --vectors-only` — full Lance rebuild only. + +### MCP tool (`refresh_code_index`) + +Add optional inputs: + +- `confirm: bool = false` (existing) +- `mode: "auto" | "incremental" | "full" = "auto"` +- `changed_paths: list[str] | null = null` +- `git_ref_base: str = "HEAD"` +- `reason: str | null = null` + +Backward compatibility: + +- Calls passing only `confirm=true` should still work. +- Default mode is `auto` (safe-by-default decisioning). + +## Decision Engine (`mode=auto`) + +Returns two independent mode choices — Lance and Kuzu may use different +modes: + +```python +@dataclass +class RefreshDecision: + lance_mode: Literal["incremental", "full"] + kuzu_mode: Literal["incremental", "full"] + reasons: list[str] + detected_changes: ChangeSet +``` + +### Choose `full` for Kuzu when any of the following is true + +- At least one file is deleted. +- At least one file is renamed or moved. +- `.java-codebase-rag.yml` or `.lancedb-mcp.yml` changes. +- Indexing pipeline/config files change (for example: + `java_index_flow_lancedb.py`, `build_ast_graph.py`, + `graph_enrich.py`, enrichment/chunking components). +- `@interface` definitions changed (meta-annotation fanout risk). +- `.deps.json` is missing, corrupt, or has wrong `ontology_version`. +- Change detection fails or is ambiguous. +- More than 50% of files are dirty (incremental would be slower). + +### Choose `full` for Lance when any of the following is true + +- Same config/indexing-pipeline triggers as Kuzu. +- `.java-codebase-rag.yml` changes. +- CocoIndex flow definition changes. + +### Choose `incremental` when all are true + +- Only in-place file content modifications/additions. +- No rename/delete/move events. +- No config/index/meta-annotation risk triggers. +- `.deps.json` exists and is current (Kuzu incremental prerequisite). + +## Change Detection Strategy + +1. Prefer git diff status: + - `git diff --name-status ...HEAD` + - optionally include working tree status (`git diff --name-status` + and `git diff --name-status --cached`) +2. If git signal is unavailable, use `changed_paths` when supplied. +3. If still uncertain, fall back to `full`. + +Represent results as: + +- `added[]`, `modified[]`, `deleted[]`, `renamed[]` +- plus boolean risk flags for config/index/meta-annotation changes + +## Execution Plan + +### Full mode + +- Lance: `cocoindex update ... --full-reprocess -f` +- Kuzu: `build_ast_graph.py --source-root ...` (full rebuild via + `_drop_all`) + +### Incremental mode + +- Lance: `cocoindex update ... -f` (without `--full-reprocess`) +- Kuzu: `build_ast_graph.py --source-root ... --changed-paths ...` + (incremental rebuild via TIER2 proposal) + +The decision engine returns two independent mode choices — Lance and +Kuzu may incrementally update independently. For example, if +`.deps.json` is missing but no config changed, Lance could be +incremental while Kuzu falls back to full. + +## Response Payload Enhancements + +Add decision transparency fields to `refresh_code_index` response: + +- `effective_mode: { lance: "incremental" | "full", kuzu: "incremental" | "full" }` +- `decision_reasons: list[str]` +- `detected_changes: { added, modified, deleted, renamed }` +- optional `warnings: list[str]` + +Keep existing stdout/stderr/exit_code fields intact. + +For the CLI (`increment` command), emit the mode decision to stderr +with `[graph]` / `[vectors]` prefixes consistent with existing +progress output (see `CLI-PROGRESS-OUTPUT-PROPOSE.md`). + +## Safety Policy + +- Default `auto`. +- On uncertainty, choose `full`. +- `mode=full` always respected. +- `mode=incremental` allowed, but return warnings when risk triggers + exist. +- Never silently downgrade explicit `full` to incremental. +- Kuzu incremental failure at runtime → roll back, fall back to full + Kuzu rebuild, log reason. + +## Test Plan + +1. `auto` + modified-only → incremental (both Lance + Kuzu). +2. `auto` + deleted file → full Kuzu, incremental Lance. +3. `auto` + renamed file → full Kuzu, incremental Lance. +4. `auto` + `.java-codebase-rag.yml` change → full (both). +5. `auto` + detection failure → full with reason. +6. explicit `mode=full` → full regardless of diffs. +7. explicit `mode=incremental` + risky changes → incremental + warning. +8. backward compatibility: `confirm=true` only call still succeeds. +9. `.deps.json` missing → full Kuzu, incremental Lance. +10. `.deps.json` stale `ontology_version` → full Kuzu, incremental Lance. + +## Implementation Notes + +- Keep mode-selection logic isolated in helper functions for testability: + - `_detect_repo_changes(...)` + - `_choose_refresh_mode(...)` +- Make logs/user-facing messages explicit about why full mode was + selected. +- Preserve current subprocess environment and project-root behavior. +- The decision engine lives in a shared module (e.g. `index_common.py` + or a new `refresh_decision.py`) usable by both `cli.py` and + `server.py`. + +## Rollout + +1. Implement decision engine with isolated helpers + tests. +2. Integrate into `cli.py`'s `_cmd_increment` — remove + `_emit_increment_kuzu_warning()`, dispatch to Kuzu incremental or + full based on decision. +3. Integrate into `server.py`'s `refresh_code_index` MCP tool. +4. Update `README.md` and `docs/JAVA-CODEBASE-RAG-CLI.md`. diff --git a/propose/active/TIER2-INCREMENTAL-REBUILD-PROPOSE.md b/propose/active/TIER2-INCREMENTAL-REBUILD-PROPOSE.md new file mode 100644 index 00000000..51548eb3 --- /dev/null +++ b/propose/active/TIER2-INCREMENTAL-REBUILD-PROPOSE.md @@ -0,0 +1,560 @@ +# Tier 2 — Incremental Kuzu rebuild + +Status: **active — ready for planning**. +User-facing tracking: GitHub issue **#73**. +Companion proposal: [`propose/active/INDEX-AUTO-MODE-PROPOSE.md`](INDEX-AUTO-MODE-PROPOSE.md) +(decision engine for `increment`/`reprocess`). + +This is a **proposal**, not an implementable plan. After review, +an implementable `plans/active/PLAN-TIER2-INCREMENTAL-REBUILD.md` will +be derived. + +## TL;DR + +`build_ast_graph.py` is a full rebuild on every run: `write_kuzu` calls +`_drop_all` then writes all nodes and edges from scratch. Every `.java` +file is re-parsed and every node/edge re-derived. That worked through +Tier 1B because every fixture is small and users run `reprocess` manually +after big changes. As the tool reaches more users running +`java-codebase-rag increment` frequently, the full-rebuild cost becomes +the dominant latency. This proposal introduces a **diff-driven Kuzu +rebuild path** that updates only what's changed at file granularity, +while keeping the full-rebuild path as the safe fallback. + +The diff-driven path is gated by the decision engine defined in +[`INDEX-AUTO-MODE-PROPOSE.md`](INDEX-AUTO-MODE-PROPOSE.md) — meaning +incremental is *only* used when the decision engine decides it's safe. +Full rebuild remains the default for renames, deletes, config changes, +and ambiguous cases. + +## Why now + +1. **The schema is stable.** Ontology 16 (with Client, Producer, + OVERRIDES, UnresolvedCallSite) is the surface that downstream + tooling depends on. Adding incremental rebuild now doesn't risk + schema churn. +2. **The decision engine is co-proposed.** `INDEX-AUTO-MODE-PROPOSE.md` + specifies when full vs incremental is safe for both Lance and Kuzu. +3. **Users need it.** The `increment` command updates Lance vectors + but prints a warning that the Kuzu graph is stale. Users running + edit-index-query loops get divergent results. +4. **Idempotency hooks are in place.** `pass6_match_edges` resets + its match-breakdown counters at the start of every run. Future + passes can adopt the same contract trivially. +5. **Single-developer pain point.** Edit-rebuild-query loops are the + inner loop. A sub-second incremental rebuild is the difference + between the tool feeling responsive and feeling batch-oriented. + +## Goals + +1. **File-level incremental rebuild** that touches only nodes/edges + derived from changed `.java` files (and their dependency closure — + see §2.3). +2. **Bit-for-bit equivalence** between incremental and full rebuild for + the same final source-tree state (verified by determinism test). +3. **Safe fallback to full rebuild** in any ambiguous case, never + silent partial state. +4. **No schema churn** — the surface (`graph_meta`, ontology 16, MCP + tools) is identical; incremental is a build-strategy optimisation. +5. **CLI integration via `increment`** — `java-codebase-rag increment` + updates both Lance and Kuzu incrementally. `reprocess` remains the + explicit full-rebuild command. `build_ast_graph.py` gains + `--changed-paths` as an internal flag (not user-facing). + +## Non-goals + +- Watch-mode (filesystem watcher running continuously). + This proposal lays the foundation; a future + `propose/WATCH-MODE-PROPOSE.md` can build on top. +- Multi-tenant/concurrent rebuilds. Single writer assumed. +- Distributed/sharded indexing. Out of scope. +- LanceDB incremental rebuild (already covered by CocoIndex's native + incremental in the `increment` command — this is Kuzu-only). +- Cross-repo or cross-source-root incremental. One source tree at a time. +- Schema migrations between ontology versions. Schema bumps still + require a full rebuild — flagged by the decision engine. + +## 1. Current state + +### 1.1 The full-rebuild pipeline + +``` +build_ast_graph.main() + └─ pass1_parse(root, tables) # parse every .java → JavaFileAst + └─ pass2_edges(tables, asts) # EXTENDS/IMPLEMENTS/INJECTS edges + └─ pass3_calls(tables, asts) # CALLS + UnresolvedCallSite edges + └─ pass4_routes(tables, asts) # Route extraction (4 strategies) + └─ pass5_imperative_edges(tables, asts) # Client/Producer nodes, HTTP_CALLS/ASYNC_CALLS + └─ pass6_match_edges(tables) # match resolution + cross-service + └─ write_kuzu(kuzu_path, tables) # _drop_all → _create_schema → write nodes/edges/meta + └─ _drop_all(conn) # all tables dropped + └─ _create_schema(conn) # recreate from scratch + └─ _write_nodes(conn, tables) # Symbol, Route, Client, Producer nodes + └─ _populate_declares_rows # DECLARES edges + └─ _populate_overrides_rows # OVERRIDES edges (subtype→supertype method) + └─ _write_edges(conn, tables) # EXTENDS/IMPLEMENTS/INJECTS/CALLS/OVERRIDES + └─ _write_routes_and_exposes # Route/EXPOSES, Client/DECLARES_CLIENT, Producer/DECLARES_PRODUCER + └─ _write_meta(conn, tables) # graph_meta JSON-blob columns +``` + +Every pass populates the in-memory `GraphTables` struct. `write_kuzu` +then drops the entire Kuzu DB and writes everything from scratch. + +### 1.2 What's already well-positioned for incremental + +- **Pass1 is per-file** — each `JavaFileAst` is independent. Parsing is + trivially incrementalisable. +- **Pass6 is idempotent** — match counters reset, then re-derive from + the edges in the DB. With pass5 edges intact for unchanged files, + `pass6` can re-run cheaply over the union of "edges from changed + files" + "all matched edges that *might* now have new candidates". +- **Pass4's route extraction** is per-file at extraction time but + cross-file at resolution time (3 strategies). Route hints from one + file can affect another file's resolution. +- **`GraphTables` is an in-memory accumulator** — passes add to it, + `write_kuzu` reads from it. An incremental path can construct a + partial `GraphTables` (only dirty files' data) and merge it into the + existing DB. + +### 1.3 What blocks naive incremental + +- **Pass2's edges (EXTENDS/IMPLEMENTS/INJECTS)** are computed across + files. If `Foo.java` extends `Bar.java`, both are needed to emit the + edge. Editing `Bar.java` invalidates the edge from `Foo`. +- **Pass3's CALLS edges** depend on resolution state across files + (a method call in `A.java` may resolve to a method in `B.java`). + Pass3 also emits `UnresolvedCallSite` + `UNRESOLVED_AT` edges. +- **OVERRIDES edges** span files — `Foo.method()` overrides + `Bar.method()`. Editing `Bar.java` invalidates the OVERRIDES edge. + These are computed in `_populate_overrides_rows` during the write + phase. +- **Brownfield Layer 4 + 5** (`@CodebaseClient`, `@CodebaseProducer`, + meta-annotation chains) propagate effects: editing the meta-annotation + hub silently changes every annotated method downstream. +- **Pass5 emits Client/Producer nodes** in addition to HTTP_CALLS/ + ASYNC_CALLS edges. These nodes have their own DECLARES_CLIENT/ + DECLARES_PRODUCER edges. HTTP_CALLS now goes Client→Route and + ASYNC_CALLS goes Producer→Route (not Symbol→Route as in ontology 7). +- **Pass6 cross-service matching** matches caller-side calls against + routes from *all* services. A new route in svc-b can flip an + unmatched HTTP_CALLS edge in svc-a to `cross_service`. + +These constraints mean we need **change-set closure**, not just +file-level dirty tracking. + +## 2. Design + +### 2.1 Two-tier change set + +The runtime computes a **change set** with two tiers: + +- **Tier 1 (direct):** files in `--changed-paths` (added, modified). +- **Tier 2 (closure):** files reachable via "depends on" links from + Tier 1 — see §2.3. + +Both tiers are re-parsed and their nodes/edges deleted from the DB +before pass1's incremental write. Files outside both tiers are left +untouched. + +### 2.2 The incremental main loop (sketch) + +``` +build_ast_graph_incremental(changed_paths): + if decision_engine.requires_full(changed_paths): + return run_full_rebuild() # safe fallback + + # Compute change set (Tier 1 + Tier 2 closure) + dirty = expand_to_closure(changed_paths) + + with conn: + delete_rows_for_files(conn, dirty) # symmetric deletes (see §2.5) + asts = pass1_parse_subset(root, dirty) # only re-parse dirty files + tables = GraphTables() # fresh accumulator — only dirty data + pass2_edges_subset(tables, asts, dirty) # re-emit cross-file edges + # for symbols touching dirty + pass3_calls_subset(tables, asts, dirty) + pass4_routes_subset(tables, asts, dirty) + pass5_imperative_edges_subset(tables, asts, dirty) + + # OVERRIDES for dirty files only + _populate_overrides_rows(tables) + + # Pass6 always reruns globally — its cost is proportional to total + # call edges, not changed files, and it computes match outcomes + # that span services. Cheap (~ms on bank-chat-system) and worth + # the simplicity. See §3.6. + pass6_match_edges(tables) + + # Write incremental rows to existing DB (no _drop_all) + _write_nodes_incremental(conn, tables) + _write_edges_incremental(conn, tables) + _write_routes_and_exposes_incremental(conn, tables) + _write_meta(conn, tables, source_root) # always rewritten + _write_dependency_index(deps_path, asts) # sidecar .deps.json (see §2.4) +``` + +### 2.3 Change-set closure rules + +For each file `F` in Tier 1, mark these files as Tier 2: + +1. **Inverse-INJECTS:** every file that `INJECTS` a symbol declared in `F`. +2. **Inverse-EXTENDS / Inverse-IMPLEMENTS:** every file whose symbol + extends or implements a symbol from `F`. (Class hierarchy + re-resolution may change brownfield-overrides resolution.) +3. **Inverse-CALLS:** every file that calls a symbol declared in `F`. + (CALLS resolution can flip targets when overload sets change.) +4. **Meta-annotation closure:** if `F` declares an `@interface` (a + meta-annotation), every file that uses that annotation is dirty. + (Brownfield Layer 5 can re-fanout.) +5. **Brownfield-override closure:** if `F` contains + `@CodebaseClient` / `@CodebaseProducer` / `@CodebaseEndpoint` / + role/capability override annotations, every file in the same + FQN scope (per Layer 4 rules) is potentially affected — fall + back to full rebuild (see TBD-1 resolution below). +6. **Route resolution closure:** if `F` contains route declarations + or `@RequestMapping`-class hints, every file with a method on + that class is dirty. +7. **Inverse-OVERRIDES:** if `F` declares a method that overrides a + supertype method, editing the supertype's file invalidates the + subtype's OVERRIDES edge — the subtype's file is dirty. +8. **Inverse-DECLARES_CLIENT / Inverse-DECLARES_PRODUCER:** if `F` + declares a Client or Producer, and a route change in another file + could affect the match outcome, the Client/Producer's file is + dirty. In practice, pass6 already reruns globally and handles + match resolution — this closure ensures the Client/Producer + *nodes* are current when their declaring method's file changes. + +The `_write_dependency_index` step (§2.4) caches the inverse maps so +closure is O(|dirty| × avg_inverse_degree), not O(|dirty| × |all-files|). + +### 2.4 Dependency index + +A sidecar file at `/.deps.json` stores per-file dependency +metadata. This is a build cache, not graph data — kept outside the Kuzu +DB for cheap Python-level inverse-map computation and no Cypher overhead. + +```json +{ + "version": 1, + "ontology_version": 16, + "files": { + "src/main/java/com/example/Foo.java": { + "ext_hash": "sha256:abc...", + "declares": ["com.example.Foo", "com.example.Foo.Bar"], + "injects": ["com.example.Service"], + "extends": ["com.example.Base"], + "calls": ["com.example.Service#run"], + "uses_anno": ["@RestController", "@CodebaseEndpoint"], + "overrides": ["com.example.Base#method()"], + "declares_clients": ["com.example.Foo#callApi()"], + "declares_producers": [] + } + } +} +``` + +Atomicity via write-temp-rename pattern. Version field for future +migrations. `ontology_version` field to detect stale index — mismatch +triggers full rebuild. + +### 2.5 Symmetric delete + +Every pass that *emits* nodes/edges keyed by source file must have a +symmetric `delete_*_for_file(path)` helper. Concretely: + +| Pass | Emits | Delete helper | +|------|-------|---------------| +| 1 | `Symbol`, `DECLARES` | `delete_symbols_for_file(p)` (cascades to DECLARES) | +| 2 | `EXTENDS`, `IMPLEMENTS`, `INJECTS` | per-edge filter on src-symbol's file | +| 3 | `CALLS`, `UnresolvedCallSite`, `UNRESOLVED_AT` | per-edge/site filter on caller-symbol's file | +| 4 | `Route`, `EXPOSES` | per-route filter on owner-symbol's file | +| 5 | `Client`, `DECLARES_CLIENT`, `Producer`, `DECLARES_PRODUCER` | per-node filter on declaring-method's file | +| 5 | `HTTP_CALLS` (Client→Route) | per-edge filter on Client's declaring-method's file | +| 5 | `ASYNC_CALLS` (Producer→Route) | per-edge filter on Producer's declaring-method's file | +| overrides | `OVERRIDES` | per-edge filter on subtype-method's file | + +Each helper takes a `Path` and a `kuzu.Connection`, executes the +DELETE Cypher, and returns a count for `--verbose` logging. The file +path is reachable via the source `Symbol`'s `path` field (or via +`Client.filename` / `Producer.filename` for those node types). + +### 2.6 Change-detection sources + +Three sources, in priority order: + +1. **`--changed-paths` flag** (or stdin list). Caller responsibility + to be accurate. This is what the INDEX-AUTO-MODE decision engine + would pass. +2. **Git diff** between `--git-ref-base` and `HEAD`. Same logic as + INDEX-AUTO-MODE §"Change Detection Strategy". +3. **Hash-based detection** using `.deps.json` `ext_hash` fields. Walk + the source tree, compute hashes, compare against cached. Slowest; + used as last-resort fallback. + +If all three fail or are ambiguous, fall back to full rebuild. + +## 3. Pass-by-pass incrementalisation notes + +### 3.1 Pass1 (parse) + +Trivial. Re-parse only files in the dirty set. No global state. + +### 3.2 Pass2 (cross-file edges) + +The edge `Foo EXTENDS Bar` lives in `pass2`. If only `Foo.java` +changed, the edge needs to be re-emitted with up-to-date metadata. +If only `Bar.java` changed, the edge target is unchanged but `Bar`'s +symbol ID may have new fields — re-emit. + +OVERRIDES edges (computed in `_populate_overrides_rows` during write) +follow the same pattern. If `Foo.method()` overrides `Bar.method()` +and `Bar.java` changes, `Foo`'s OVERRIDES edge is stale — covered by +closure rule 7. + +### 3.3 Pass3 (CALLS + UnresolvedCallSite) + +Same shape as pass2. CALLS edges re-emitted for caller-symbols in +dirty files. Closure rule 3 catches inverse-CALLS. `UnresolvedCallSite` +rows and their `UNRESOLVED_AT` edges are also deleted and re-emitted +for the dirty subset. + +### 3.4 Pass4 (Routes) + +Two sub-passes: + +- **Extraction** is per-file. Easy. +- **Resolution** (three-strategy ladder) reads class-level hints from + a possibly-different file. Closure rule 6 covers this. + +Phantom-route cleanup currently runs in `pass6`. That stays as-is — +phantoms are derived from the global graph state. + +### 3.5 Pass5 (Client/Producer + HTTP_CALLS/ASYNC_CALLS) + +Now emits Client and Producer *nodes* (not just edges) plus their +DECLARES_CLIENT/DECLARES_PRODUCER edges. HTTP_CALLS now originates from +Client→Route and ASYNC_CALLS from Producer→Route. + +Symmetric delete must clean up all of these per-file. Brownfield +Layer 4 (`@CodebaseClient` / `@CodebaseProducer`) overrides — closure +rule 5 currently forces full rebuild when an override file changes; +pessimistic but safe. Fine-grained brownfield closure deferred. + +### 3.6 Pass6 (match outcomes + cross-service) + +**Always reruns globally.** Three reasons: + +1. Cross-service matching can flip outcomes service-wide when one + route's path template changes. +2. Pass6 is fast: on `bank-chat-system` it runs in ~1ms. Even at 10× + scale we're still under the human-perception threshold. +3. Pass6's idempotency contract was already added with incremental + in mind. + +### 3.7 `_write_meta` + +Always rewritten — fields like `routes_total`, `cross_service_calls_total`, +`http_calls_match_breakdown`, `async_calls_match_breakdown` are aggregations +over the global graph state. Cost is negligible. + +## 4. CLI surface + +The incremental path integrates with the existing `java-codebase-rag` +CLI, not as a user-facing flag on `build_ast_graph.py`: + +```bash +# Existing — unchanged (full rebuild of Lance + Kuzu) +java-codebase-rag reprocess +java-codebase-rag reprocess --graph-only +java-codebase-rag reprocess --vectors-only + +# Updated — now also increments Kuzu graph (not just Lance) +java-codebase-rag increment + +# Internal — build_ast_graph.py gains --changed-paths (called by CLI, not users) +python build_ast_graph.py \ + --source-root --kuzu-path \ + --changed-paths \ + [--verbose] +``` + +Decision engine inside `cli.py`'s `_cmd_increment`: + +1. If `` doesn't exist or `.deps.json` is missing/stale → + fall back to full graph rebuild. +2. If `decision_engine.requires_full(changed)` → full graph rebuild + with a logged reason. +3. If incremental fails mid-flight → roll back transaction, log, + fall back to full graph rebuild. +4. On success → both Lance and Kuzu are incremental. + +The `_emit_increment_kuzu_warning()` call in `cli.py` is removed once +this ships. + +## 5. MCP integration + +`refresh_code_index` (MCP tool in `server.py`) gains the ability to +call the incremental Kuzu path via the CLI: + +```python +# Pseudocode inside server.py:refresh_code_index +decision = decide_mode(changed_paths, git_ref_base) +if decision.lance_mode == "incremental": + cocoindex_update(incremental=True, paths=decision.lance_paths) +else: + cocoindex_update(full=True) + +if decision.kuzu_mode == "incremental": + subprocess.run(["java-codebase-rag", "increment"]) +else: + subprocess.run(["java-codebase-rag", "reprocess", "--graph-only"]) +``` + +Decision engine returns two independent mode choices — LanceDB and +Kuzu may incrementally update independently. See +[`INDEX-AUTO-MODE-PROPOSE.md`](INDEX-AUTO-MODE-PROPOSE.md) for the +full decision engine specification. + +## 6. Determinism + correctness + +### 6.1 Determinism test + +Create `tests/test_incremental_equivalence.py` as a prerequisite: + +```python +def test_incremental_matches_full(tmp_path): + src = tmp_path / "src"; src.mkdir() + copy_fixture(src, "tests/fixtures/cross_service_smoke") + + # Full rebuild on initial state + run_full(src, kuzu_a := tmp_path / "a") + + # Mutate one file + (src / "svc-a/.../ClientA.java").write_text(modified_source) + + # Two paths to the same final state + run_full(src, kuzu_full := tmp_path / "full") + run_incremental(kuzu_a, changed=["svc-a/.../ClientA.java"]) + + assert all_sorted_ids(kuzu_full) == all_sorted_ids(kuzu_a) + assert meta(kuzu_full) == meta(kuzu_a) +``` + +This is the single most important test in the entire feature. +Without it, every incremental rebuild is a potential silent-divergence +risk. Run against every fixture. + +### 6.2 Failure modes + +- **Half-applied delete:** transaction wraps the entire incremental + pass; on any exception, ROLLBACK and fall back to full rebuild. +- **Corrupt `.deps.json`:** detected by checksum + `ontology_version` + field; treated as "incremental impossible, fall back to full". +- **Schema-version mismatch:** if `graph_meta.ontology_version` ≠ + current `ONTOLOGY_VERSION`, force full rebuild. + +## 7. Rollout + +1. **PR-T1 (foundation + determinism test):** Create + `tests/test_incremental_equivalence.py` with full-rebuild + determinism assertions. Add `.deps.json` writer + (`_write_dependency_index`) and reader, written but unused. + Establish performance baseline on fixtures. +2. **PR-T2 (delete helpers):** symmetric `delete_*_for_file` for each + pass and node/edge type (Symbol, DECLARES, EXTENDS, IMPLEMENTS, + INJECTS, CALLS, UnresolvedCallSite, UNRESOLVED_AT, Route, EXPOSES, + Client, DECLARES_CLIENT, Producer, DECLARES_PRODUCER, HTTP_CALLS, + ASYNC_CALLS, OVERRIDES). Unit-tested in isolation. Still no + incremental codepath. +3. **PR-T3 (incremental orchestrator):** `build_ast_graph_incremental` + function + `--changed-paths` flag on `build_ast_graph.py`. Pass-by- + pass incremental implementation including Client/Producer/OVERRIDES/ + UnresolvedCallSite. `test_incremental_matches_full` extended to + cover all fixtures. +4. **PR-T4 (CLI + decision engine):** Integrate INDEX-AUTO-MODE + decision engine into `cli.py`'s `_cmd_increment`. Remove + `_emit_increment_kuzu_warning()`. Wire `refresh_code_index` + MCP tool to dispatch. +5. **PR-T5 (brownfield closure refinement, optional):** Narrow the + pessimistic "any brownfield-override change → full" rule once + Layer-4/5 fanout is explicitly documented. + +PR-T1 through PR-T4 are the headline. PR-T5 is an optimisation +on top. + +## 8. Risk assessment + +| Risk | Severity | Mitigation | +|------|----------|------------| +| Silent divergence between full and incremental | **High** | `test_incremental_matches_full` mandatory per fixture; covered by §6.1 | +| Pessimistic full-fallback hides real incremental wins | Medium | Track full-fallback rate via `graph_meta.last_rebuild_mode` + reason; review monthly | +| `.deps.json` becomes stale | Medium | `ontology_version` field check; mismatch → full rebuild | +| Decision engine bugs cause unsafe incremental | High | Default-to-full on ambiguity; conservative initial rules; expand only after burn-in | +| Pass2/Pass3 closure misses an edge type | Medium | Determinism test surfaces this immediately | +| Performance regression (incremental slower than full for small repos) | Low | Heuristic: skip incremental when dirty set is >50% of files | + +## 9. Performance estimate + +On a hypothetical 1000-file Java repo: + +| Metric | Full rebuild | Incremental (1 file changed) | +|--------|--------------|-------------------------------| +| Pass1 parse | ~30s (1000 × 30ms) | <0.1s (1 × 30ms + 5-10 closure files) | +| Pass2-5 emit | ~20s | <0.2s | +| Pass6 match | ~1s | ~1s (always global) | +| `_write_meta` | <0.1s | <0.1s | +| **Total** | **~50s** | **~1.5s** | + +These are estimates — actual numbers depend on closure breadth and +Kuzu transaction overhead. PR-T1 establishes a measured baseline on +`bank-chat-system` and other fixtures. + +## 10. Resolved TBDs + +### TBD-1: Brownfield closure granularity — **pessimistic fallback** + +Layer-4 (`@CodebaseClient` / `@CodebaseProducer`) overrides operate +via FQN matching. Editing the override file conceptually invalidates +every matching method. The current proposal forces full rebuild in +that case (closure rule 5). Fine-grained closure is deferred to PR-T5 +or a follow-up proposal once Layer-4 fanout rules are formalised. + +### TBD-2: FileDeps storage — **sidecar JSON** + +Decision: **sidecar `/.deps.json`**. The file-level dependency +index is conceptually a build cache, not graph data. Benefits: cheap +inverse-map computation in Python, no Cypher overhead, no schema +evolution burden. Atomicity via write-temp-rename. `version` + +`ontology_version` fields for migration/staleness detection. + +### TBD-3: Incremental for the LanceDB chunks — **out of scope** + +Already covered by CocoIndex's native incremental support +(`java-codebase-rag increment` without `--full-reprocess`). + +### TBD-4: Concurrent / interleaved rebuilds — **document only** + +Single-writer assumed. If two runs race against the same `--kuzu-path`, +behaviour is undefined. Document; do not guard against. + +### TBD-5: Watch-mode — **out of scope, future proposal** + +A follow-on proposal should layer a filesystem watcher on top of this +incremental path. It needs debouncing, batched-change semantics, and +its own state machine. + +## 11. Done definition (proposal-level) + +This proposal is "ready for plan derivation" when: + +- [x] All TBD items have a decision. +- [ ] §3 closure rules have been validated against a real + multi-service fixture (`cross_service_smoke` is a candidate). +- [ ] §9 performance estimates have a measured baseline on at least + one fixture. +- [ ] Reviewer approves §2.5 (symmetric delete) as the right model. +- [ ] Decision engine pseudocode in §4 + §5 is consistent with + `INDEX-AUTO-MODE-PROPOSE.md`. + +When approved, derive `plans/active/PLAN-TIER2-INCREMENTAL-REBUILD.md` +with PR-T1 through PR-T5 broken out, and a per-PR agent task prompt set. diff --git a/propose/stale/INDEX-AUTO-MODE-PROPOSE.md b/propose/stale/INDEX-AUTO-MODE-PROPOSE.md deleted file mode 100644 index 0caa6a45..00000000 --- a/propose/stale/INDEX-AUTO-MODE-PROPOSE.md +++ /dev/null @@ -1,124 +0,0 @@ -# Proposal: Smart `refresh_code_index` Mode Selection - -> **Note (2026):** the operator CLI’s full rebuild verb is now `java-codebase-rag reprocess` (see `propose/completed/CLI-SCENARIOS-PROPOSE.md`); this MCP-side proposal still refers to `refresh_code_index` by name. -> -> For embeddings or search over raw `propose/` text, expect both that historical MCP name and current CLI wording in the same document until this draft is implemented or archived under `propose/completed/`. - -## Goal - -Extend `refresh_code_index` so it can choose incremental vs full rebuild automatically, while staying safe for rename/delete/move and indexing-semantics changes. - -## Problem - -Current behavior always runs: - -- `cocoindex update ... --full-reprocess -f` -- followed by full Kuzu rebuild - -This is correct but expensive for small edit-only iterations. At the same time, a naive incremental flow can leave stale entries after file deletes/renames or when metadata semantics change. - -## Proposed API Changes - -Add optional inputs to `refresh_code_index`: - -- `confirm: bool = false` (existing) -- `mode: "auto" | "incremental" | "full" = "auto"` -- `changed_paths: list[str] | null = null` -- `git_ref_base: str = "HEAD"` -- `reason: str | null = null` - -Backward compatibility: - -- Calls passing only `confirm=true` should still work. -- Default mode should become `auto` (safe-by-default decisioning). - -## Decision Engine (`mode=auto`) - -### Choose `full` when any of the following is true - -- At least one file is deleted. -- At least one file is renamed or moved. -- `.lancedb-mcp.yml` changes. -- Indexing pipeline/config files change (for example: `java_index_flow_lancedb.py`, enrichment/chunking components). -- `@interface` definitions changed (meta-annotation fanout risk). -- Change detection fails or is ambiguous. - -### Choose `incremental` when all are true - -- Only in-place file content modifications/additions. -- No rename/delete/move events. -- No index/config/meta-annotation risk triggers. - -## Change Detection Strategy - -1. Prefer git diff status: - - `git diff --name-status ...HEAD` - - optionally include working tree status (`git diff --name-status` and `git diff --name-status --cached`) -2. If git signal is unavailable, use `changed_paths` when supplied. -3. If still uncertain, fall back to `full`. - -Represent results as: - -- `added[]`, `modified[]`, `deleted[]`, `renamed[]` -- plus boolean risk flags for config/index/meta-annotation changes - -## Execution Plan - -### Full mode - -- Keep current behavior: - - `cocoindex update ... --full-reprocess -f` - - run `build_ast_graph.py --source-root ...` - -### Incremental mode - -- Run `cocoindex update ... -f` (without `--full-reprocess`) -- Graph handling in two phases: - - Phase A: still run full graph rebuild for correctness. - - Phase B (future): incremental graph updates scoped to changed files. - -## Response Payload Enhancements - -Add decision transparency fields: - -- `effective_mode: "incremental" | "full"` -- `decision_reasons: list[str]` -- `detected_changes: { added, modified, deleted, renamed }` -- optional `warnings: list[str]` (for forced incremental under risky conditions) - -Keep existing stdout/stderr/exit_code fields intact. - -## Safety Policy - -- Default `auto`. -- On uncertainty, choose `full`. -- `mode=full` always respected. -- `mode=incremental` allowed, but return warnings when risk triggers exist. -- Never silently downgrade explicit `full` to incremental. - -## Test Plan - -Add/extend tests in `tests/test_mcp_tools.py`: - -1. `auto` + modified-only -> incremental chosen. -2. `auto` + deleted file -> full chosen. -3. `auto` + renamed file -> full chosen. -4. `auto` + `.lancedb-mcp.yml` change -> full chosen. -5. `auto` + detection failure -> full chosen with reason. -6. explicit `mode=full` -> full regardless of diffs. -7. explicit `mode=incremental` + risky changes -> incremental + warning. -8. backward compatibility: `confirm=true` only call still succeeds. - -## Implementation Notes - -- Keep mode-selection logic isolated in helper functions for testability: - - `_detect_repo_changes(...)` - - `_choose_refresh_mode(...)` -- Make logs/user-facing messages explicit about why full mode was selected. -- Preserve current subprocess environment and project-root behavior. - -## Rollout - -1. Implement API and auto decisioning with Phase A graph behavior. -2. Add test coverage and docs update in `README.md`. -3. Later: implement true incremental Kuzu graph updates (Phase B). diff --git a/propose/stale/TIER2-INCREMENTAL-REBUILD-PROPOSE.md b/propose/stale/TIER2-INCREMENTAL-REBUILD-PROPOSE.md deleted file mode 100644 index 73101047..00000000 --- a/propose/stale/TIER2-INCREMENTAL-REBUILD-PROPOSE.md +++ /dev/null @@ -1,520 +0,0 @@ -# Tier 2 — Incremental Kuzu rebuild - -Status: **active — ready for planning**. -User-facing tracking for graph-side incremental work: GitHub issue **#73** (linked from the `increment` stderr warning in `java_codebase_rag/cli.py`). -Pairs with the focused MCP-tool proposal -[`propose/INDEX-AUTO-MODE-PROPOSE.md`](INDEX-AUTO-MODE-PROPOSE.md) -(decision engine for `refresh_code_index`) and supersedes its -"future Kuzu work" footnote in [`docs/PRODUCT-VISION.md`](../docs/PRODUCT-VISION.md) §99. - -This is a **proposal**, not an implementable plan. After review and -scoping decisions (the §11 [TBD] list), an implementable -`plans/PLAN-TIER2-INCREMENTAL-REBUILD.md` will be derived. - -## TL;DR - -`build_ast_graph.py` is a full rebuild on every run: `_drop_all` plus -`pass1`–`pass6` re-parse every `.java` file in the source tree and -re-derive every node and edge. That worked through Tier 1B because -every fixture is small and the production user runs an indexing job -manually after big changes. As we move toward continuous indexing -(`refresh_code_index` with `mode=incremental`, future watch-mode, or -post-merge CI hooks), the full-rebuild cost becomes the dominant -latency. This proposal introduces a **diff-driven Kuzu rebuild path** -that updates only what's changed at file granularity, while keeping -the full-rebuild path as the safe fallback. - -The diff-driven path is gated by the same decision engine that the -REFRESH proposal already specifies for LanceDB — meaning incremental -is *only* used when the decision engine decides it's safe. Full -rebuild remains the default for renames, deletes, config changes, and -ambiguous cases. - -## Why now (and not earlier) - -1. **Tier 1B closed the schema.** Ontology 7 (with `pass6_match_edges`) - is the surface that downstream tooling depends on. Adding incremental - rebuild now doesn't risk schema churn. -2. **The decision engine already exists in proposal form.** REFRESH - proposal §"Decision Engine" specifies exactly when full vs incremental - is safe. The current Kuzu path can't take advantage because there is - no incremental option to dispatch to — this proposal closes that gap. -3. **Idempotency hooks are already added.** `pass6_match_edges` resets - its match-breakdown counters at the start of every run (PR-D3 review - obs 3, addressed in PR-E1 follow-up plan). Future passes can adopt - the same idempotency contract trivially. -4. **Single-developer pain point.** As the user maintains real codebases - with the AMA agent, edit-rebuild-query loops are the inner loop. A - sub-second incremental rebuild is the difference between the agent - feeling responsive and feeling batch-oriented. - -## Goals - -1. **File-level incremental rebuild** that touches only nodes/edges - derived from changed `.java` files (and their meta-annotation / - brownfield-overrides closure — see §3.2). -2. **Bit-for-bit equivalence** between incremental and full rebuild for - the same final source-tree state (verified by determinism test). -3. **Safe fallback to full rebuild** in any ambiguous case, never - silent partial state. -4. **No schema churn** — the surface (`graph_meta`, ontology 7, MCP - tools) is identical; incremental is a build-strategy optimisation. -5. **Backwards-compatible CLI** — `python build_ast_graph.py - --source-root … --kuzu-path …` keeps its current full-rebuild - semantics. A new `--changed-paths …` (or equivalent) opt-in gates - the incremental path. - -## Non-goals - -- Watch-mode (filesystem watcher running continuously). - This proposal lays the foundation; a future `propose/WATCH-MODE-PROPOSE.md` - can build on top. -- Multi-tenant/concurrent rebuilds. Single writer assumed. -- Distributed/sharded indexing. Out of scope. -- LanceDB incremental rebuild (already covered by CocoIndex's native - incremental in the REFRESH proposal — this is Kuzu-only). -- Cross-repo or cross-source-root incremental. One source tree at a time. -- Schema migrations between ontology versions. Schema bumps still - require a full rebuild — flagged by the decision engine. - -## 1. Current state - -### 1.1 The full-rebuild pipeline - -``` -build_ast_graph.main() - └─ _drop_all(conn) # 11 tables dropped - └─ pass1_parse(root) # parse every .java → JavaFileAst - └─ pass2_edges # EXTENDS/IMPLEMENTS/INJECTS edges - └─ pass3_calls # CALLS edges (intra-service) - └─ pass4_routes # Route extraction (4 strategies) - └─ pass5_imperative_edges # HTTP_CALLS / ASYNC_CALLS caller edges - └─ pass6_match_edges # 5-outcome match resolution + cross-service - └─ _write_meta # graph_meta JSON-blob columns -``` - -Every pass either holds rows in `GraphTables` or writes them after the -parse phase. Storage is single-file Kuzu DB at `--kuzu-path`. Phase 1 -*always* drops and recreates. - -### 1.2 What's already well-positioned for incremental - -- **Pass1 is per-file** — each `JavaFileAst` is independent. Parsing is - trivially incrementalisable. -- **Pass6 is idempotent** — match counters reset, then re-derive from - edges in the DB. With `pass5` edges intact for unchanged files, - `pass6` can re-run cheaply over the union of "edges from changed - files" + "all matched edges that *might* now have new candidates". -- **Pass4's route extraction** is per-file at extraction time but - cross-file at resolution time (3 strategies in PR-A2). Route hints - from one file can affect another file's resolution. -- **Determinism test (`tests/test_determinism.py`)** exists and - enforces that two runs on the same input produce identical sorted - IDs. This is the single most important property for incremental - rebuild — we'll extend it to `incremental(input) == full(input)`. - -### 1.3 What blocks naive incremental - -- **Pass2's edges (EXTENDS/IMPLEMENTS/INJECTS)** are computed across - files. If `Foo.java` extends `Bar.java`, both are needed to emit the - edge. Editing `Bar.java` invalidates the edge from `Foo`. -- **Pass3's CALLS edges** depend on resolution state across files - (a method call in `A.java` may resolve to a method in `B.java`). -- **Brownfield Layer 4 + 5** (`@CodebaseClient`, `@CodebaseProducer`, - meta-annotation chains) propagate effects: editing the meta-annotation - hub silently changes every annotated method downstream. -- **Pass5/Pass6 cross-service matching** matches caller-side calls - against routes from *all* services. A new route in svc-b can flip - an unmatched HTTP_CALLS edge in svc-a to `cross_service`. - -These constraints mean we need **change-set closure**, not just -file-level dirty tracking. - -## 2. Design - -### 2.1 Two-tier change set - -The runtime computes a **change set** with two tiers: - -- **Tier 1 (direct):** files in `--changed-paths` (added, modified). -- **Tier 2 (closure):** files reachable via "depends on" links from - Tier 1 — see §3.2. - -Both tiers are re-parsed and their nodes/edges deleted from the DB -before pass1's incremental write. Files outside both tiers are left -untouched. - -### 2.2 The incremental main loop (sketch) - -``` -build_ast_graph_incremental(changed_paths): - if decision_engine.requires_full(changed_paths): - return run_full_rebuild() # safe fallback - - # Compute change set (Tier 1 + Tier 2 closure) - dirty = expand_to_closure(changed_paths) - - with conn: - delete_rows_for_files(dirty) # symmetric to pass1's emits - asts = pass1_parse_subset(dirty) # only re-parse dirty files - pass2_edges_subset(asts, dirty) # re-emit cross-file edges - # for symbols touching dirty - pass3_calls_subset(asts, dirty) - pass4_routes_subset(asts, dirty) - pass5_imperative_edges_subset(asts, dirty) - - # Pass6 always reruns globally — its cost is proportional to total - # call edges, not changed files, and it computes match outcomes - # that span services. Cheap (~ms on bank-chat-system) and worth - # the simplicity. See §3.4. - pass6_match_edges() - - _write_meta(conn, tables, source_root) # always rewritten - _write_dependency_index(conn, asts) # NEW (see §2.4) -``` - -### 2.3 Change-set closure rules - -For each file `F` in Tier 1, mark these files as Tier 2: - -1. **Inverse-INJECTS:** every file that `INJECTS` a symbol declared in `F`. -2. **Inverse-EXTENDS / Inverse-IMPLEMENTS:** every file whose symbol - extends or implements a symbol from `F`. (Class hierarchy - re-resolution may change brownfield-overrides resolution.) -3. **Inverse-CALLS:** every file that calls a symbol declared in `F`. - (CALLS resolution can flip targets when overload sets change.) -4. **Meta-annotation closure:** if `F` declares an `@interface` (a - meta-annotation), every file that uses that annotation is dirty. - (Brownfield Layer 5 can re-fanout.) -5. **Brownfield-override closure:** if `F` contains - `@CodebaseClient` / `@CodebaseProducer` / `@CodebaseEndpoint` / - role/capability override annotations, every file in the same - FQN scope (per Layer 4 rules) is potentially affected — fall - back to full rebuild for now (see §11 [TBD-1]). -6. **Route resolution closure:** if `F` contains route declarations - or `@RequestMapping`-class hints, every file with a method on - that class is dirty (PR-A2's three-strategy ladder reads class-level - `@RequestMapping` from a possibly-different file). - -The `_write_dependency_index` step (§2.4) caches the inverse maps so -closure is O(|dirty| × avg_inverse_degree), not O(|dirty| × |all-files|). - -### 2.4 Dependency index - -A new node table `FileDeps` (or a sidecar JSON file at -`/.deps.json` — see §11 [TBD-2]) stores per-file: - -```json -{ - "src/main/java/com/example/Foo.java": { - "ext_hash": "sha256:abc...", // for change detection w/o git - "declares": ["com.example.Foo", "com.example.Foo.Bar"], - "injects": ["com.example.Service"], - "extends": ["com.example.Base"], - "calls": ["com.example.Service#run", ...], - "uses_anno": ["@RestController", "@CodebaseEndpoint"] - } -} -``` - -Inverse lookups are computed on demand or cached at write time — -implementation detail, not in this proposal's scope. - -### 2.5 Symmetric delete - -Every pass that *emits* nodes/edges keyed by source file must have a -symmetric `delete_*_for_file(path)` helper. Concretely: - -| Pass | Emits | Delete helper | -|------|-------|---------------| -| 1 | `Symbol`, `DECLARES` | `delete_symbols_for_file(p)` (cascades to DECLARES) | -| 2 | `EXTENDS`, `IMPLEMENTS`, `INJECTS` | per-edge filter on src-symbol's file | -| 3 | `CALLS` | per-edge filter | -| 4 | `Route`, `EXPOSES` | per-route filter on owner-symbol's file | -| 5 | `HTTP_CALLS`, `ASYNC_CALLS` (rows) | per-edge filter on src-symbol's file | -| 6 | (only mutates pass5 rows in place) | n/a — always reruns globally | - -Each helper takes a `Path` and a `kuzu.Connection`, executes the -DELETE Cypher, and returns a count for `--verbose` logging. None of -the existing tables need new columns — the file path is reachable -via the source `Symbol`'s `path` field. - -### 2.6 Change-detection sources - -Three sources, in priority order: - -1. **`--changed-paths` flag** (or stdin list). Caller responsibility - to be accurate. This is what the REFRESH proposal's decision engine - would pass. -2. **Git diff** between `--git-ref-base` and `HEAD`. Same logic as - REFRESH proposal §"Change Detection Strategy". -3. **Hash-based detection** using `FileDeps.ext_hash`. Walk the source - tree, compute hashes, compare against cached. Slowest of the three; - used as last-resort fallback. - -If all three fail or are ambiguous, fall back to full rebuild. - -## 3. Pass-by-pass incrementalisation notes - -### 3.1 Pass1 (parse) - -Trivial. Re-parse only files in the dirty set. No global state. - -### 3.2 Pass2 (cross-file edges) - -The edge `Foo EXTENDS Bar` lives in `pass2`. If only `Foo.java` -changed, the edge needs to be re-emitted with up-to-date metadata -(`Foo` may have changed which class it extends). If only `Bar.java` -changed, the edge target is unchanged but `Bar`'s symbol ID may have -new fields — re-emit. - -Concretely: pass2 re-runs over the dirty set, but only for *outgoing* -edges from symbols in dirty files. Inverse-EXTENDS lookup ensures -that when `Bar` changes, `Foo`'s edge is also refreshed — captured by -closure §2.3 rule 2. - -### 3.3 Pass3 (CALLS) - -Same shape as pass2. CALLS edges re-emitted for caller-symbols in -dirty files. Closure rule 3 catches inverse-CALLS. - -### 3.4 Pass4 (Routes) - -Two sub-passes: - -- **Extraction** is per-file. Easy. -- **Resolution** (PR-A2's three-strategy ladder) reads class-level - hints from a possibly-different file. Closure rule 6 covers this. - -Phantom-route cleanup currently runs in `pass6` (see PR-D3 review). -That stays as-is — phantoms are derived from the global graph state. - -### 3.5 Pass5 (HTTP_CALLS / ASYNC_CALLS caller-side) - -Per-file emit. Brownfield Layer 4 (`@CodebaseClient` / -`@CodebaseProducer`) overrides — closure rule 5 currently forces full -rebuild when an override file changes; that's pessimistic but safe. -A future PR-F or similar could implement file-scoped brownfield -closure to avoid this, once the Layer-4/5 fanout rules are explicitly -documented (see [TBD-1]). - -### 3.6 Pass6 (match outcomes + cross-service) - -**Always reruns globally.** Three reasons: - -1. Cross-service matching can flip outcomes service-wide when one - route's path template changes. Computing the closure of "which - pass5 rows might re-resolve" is O(|all routes|) anyway — might - as well rerun pass6. -2. Pass6 is fast: on `bank-chat-system` (15 files / 8 routes / 7 - call edges) it runs in ~1ms. Even at 10× scale we're still under - the human-perception threshold. -3. Pass6's idempotency contract (PR-E1 inline comment) was already - added with this proposal in mind. - -### 3.7 `_write_meta` - -Always rewritten — fields like `routes_total`, `cross_service_calls_total`, -`http_calls_match_breakdown`, `async_calls_match_breakdown` are aggregations -over the global graph state. Cost is negligible. - -## 4. CLI surface - -```bash -# Existing — unchanged -python build_ast_graph.py --source-root --kuzu-path [--verbose] - -# New — opt-in incremental -python build_ast_graph.py \ - --source-root --kuzu-path \ - --changed-paths \ - [--git-ref-base HEAD~1] \ - [--verbose] -``` - -Decision engine inside the script: - -1. If `--changed-paths` is given and `` exists with a - readable `FileDeps` index → attempt incremental. -2. If `` is empty/missing → full rebuild (no other choice). -3. If decision_engine.requires_full(changed) → full rebuild with a - logged reason. -4. If incremental fails mid-flight → roll back transaction, log, - fall back to full rebuild. - -## 5. MCP integration - -`refresh_code_index` (already in scope of the REFRESH proposal) gains -the ability to call the new incremental Kuzu path: - -```python -# Pseudocode inside server.py:refresh_code_index -decision = decide_mode(changed_paths, git_ref_base) -if decision.lance_mode == "incremental": - cocoindex_update(incremental=True, paths=decision.lance_paths) -else: - cocoindex_update(full=True) - -if decision.kuzu_mode == "incremental": - subprocess.run([..., "--changed-paths", decision.kuzu_paths_file]) -else: - subprocess.run([...]) # full rebuild -``` - -Decision engine returns two independent mode choices — LanceDB and -Kuzu may incrementally update independently. - -## 6. Determinism + correctness - -### 6.1 Determinism test extension - -Extend `tests/test_determinism.py` (or add `tests/test_incremental_equivalence.py`) -to assert: - -```python -def test_incremental_matches_full(tmp_path): - src = tmp_path / "src"; src.mkdir() - copy_fixture(src, "tests/fixtures/cross_service_smoke") - - # Full rebuild on initial state - run_full(src, kuzu_a := tmp_path / "a") - - # Mutate one file - (src / "svc-a/.../ClientA.java").write_text(modified_source) - - # Two paths to the same final state - run_full(src, kuzu_full := tmp_path / "full") - run_incremental(src, kuzu_inc := kuzu_a, changed=["svc-a/.../ClientA.java"]) - - assert all_sorted_ids(kuzu_full) == all_sorted_ids(kuzu_inc) - assert meta(kuzu_full) == meta(kuzu_inc) -``` - -This is the single most important test in the entire feature. -Without it, every incremental rebuild is a potential silent-divergence -risk. - -### 6.2 Failure modes - -- **Half-applied delete:** transaction wraps the entire incremental - pass; on any exception, ROLLBACK and fall back to full rebuild. -- **Corrupt FileDeps index:** detected by checksum; treated as - "incremental impossible, fall back to full". -- **Schema-version mismatch:** if `graph_meta.ontology_version` ≠ - current `ONTOLOGY_VERSION`, force full rebuild. - -## 7. Rollout - -1. **PR-T1 (foundation):** `FileDeps` table + `_write_dependency_index` - helper, written but unused. Determinism test extended (full - rebuild only, asserts FileDeps round-trip). -2. **PR-T2 (delete helpers):** symmetric `delete_*_for_file` for each - pass. Unit-tested in isolation. Still no incremental codepath. -3. **PR-T3 (incremental orchestrator):** `build_ast_graph_incremental` - function + `--changed-paths` flag. Pass-by-pass incremental - implementation. `test_incremental_matches_full` lit up here — must - pass for every fixture. -4. **PR-T4 (decision engine + CLI):** integrate the REFRESH proposal's - decision engine; wire `refresh_code_index` to dispatch. -5. **PR-T5 (brownfield closure refinement, optional):** narrow the - pessimistic "any brownfield-override change → full" rule once - Layer-4/5 fanout is explicitly documented. - -PR-T1 through PR-T4 are the headline. PR-T5 is an optimisation -on top. - -## 8. Risk assessment - -| Risk | Severity | Mitigation | -|------|----------|------------| -| Silent divergence between full and incremental | **High** | `test_incremental_matches_full` mandatory per fixture; covered by §6.1 | -| Pessimistic full-fallback hides real incremental wins | Medium | Track full-fallback rate via `graph_meta.last_rebuild_mode` + reason; review monthly | -| `FileDeps` index becomes stale | Medium | Stored in same DB transaction as graph; mismatch → full rebuild | -| Decision engine bugs cause unsafe incremental | High | Default-to-full on ambiguity; conservative initial rules; expand only after burn-in | -| Pass2/Pass3 closure misses an edge type | Medium | Determinism test surfaces this immediately | -| Performance regression (incremental slower than full for small repos) | Low | Heuristic: skip incremental when dirty set is >50% of files | - -## 9. Performance estimate - -On a hypothetical 1000-file Java repo: - -| Metric | Full rebuild | Incremental (1 file changed) | -|--------|--------------|-------------------------------| -| Pass1 parse | ~30s (1000 × 30ms) | <0.1s (1 × 30ms + 5-10 closure files) | -| Pass2-5 emit | ~20s | <0.2s | -| Pass6 match | ~1s | ~1s (always global) | -| `_write_meta` | <0.1s | <0.1s | -| **Total** | **~50s** | **~1.5s** | - -These are estimates — actual numbers depend on closure breadth and -Kuzu transaction overhead. Worth establishing a baseline benchmark -in PR-T1 (run full rebuild on `bank-chat-system` 10 times, record -mean/p95) and re-running after each PR. - -## 10. Open questions / explicit out-of-scope - -### [TBD-1] Brownfield closure granularity - -Layer-4 (`@CodebaseClient` / `@CodebaseProducer`) overrides today -operate via FQN matching. Editing the override file conceptually -invalidates every method that matches the override's FQN pattern. -The current proposal forces full rebuild in that case (closure rule -5). A more aggressive closure could parse the override and compute -exact dirty set. Defer to PR-T5 or a follow-up proposal once Layer-4 -fanout rules are formalised. - -### [TBD-2] FileDeps storage: in Kuzu vs sidecar JSON - -Two options: - -- **(a) New node table `FileDeps`** in the same Kuzu DB. Pros: - transactional with the graph, single artifact. Cons: Cypher - query overhead for inverse lookups; another schema column to - evolve. -- **(b) Sidecar `/.deps.json`** file. Pros: cheap - inverse-map computation in Python, no Cypher overhead. Cons: needs - manual atomicity (write-temp-rename pattern), separate version - field for migrations. - -Recommendation: **(b) sidecar JSON** — the file-level dependency -index is conceptually a build cache, not graph data. Decide in -PR-T1. - -### [TBD-3] Incremental for the LanceDB chunks? - -Already covered by CocoIndex's native incremental support -(`cocoindex update` without `--full-reprocess`). The REFRESH -proposal handles this. Out of scope here. - -### [TBD-4] Concurrent / interleaved rebuilds - -Single-writer assumed. If two `build_ast_graph.py` runs race against -the same `--kuzu-path`, behaviour is undefined. Document; do not -guard against. (Standard Kuzu single-writer constraint.) - -### [TBD-5] Watch-mode - -Out of scope. A follow-on proposal should layer a filesystem watcher -on top of this incremental path; it's not a free add-on because the -watcher needs debouncing, batched-change semantics, and its own state -machine. - -## 11. Done definition (proposal-level) - -This proposal is "ready for plan derivation" when: - -- [ ] All [TBD] items in §10 have either a decision or an explicit - "deferred to PR-T5+" tag. -- [ ] §3 closure rules have been validated against a real - multi-service fixture (`cross_service_smoke` is a candidate). -- [ ] §9 performance estimates have a measured baseline on at least - one fixture. -- [ ] Reviewer (you, principal engineer) approves §2.5 (symmetric - delete) as the right model — alternative is "wipe-and-rebuild - the dirty closure", which has similar correctness but worse - performance. -- [ ] Decision engine pseudocode in §4 + §5 is consistent with the - REFRESH proposal. - -When approved, derive `plans/PLAN-TIER2-INCREMENTAL-REBUILD.md` with -PR-T1 through PR-T5 broken out, and a per-PR Cursor task prompt set.