Skip to content

Commit 4abbce4

Browse files
HumanBean17claude
andcommitted
add CLI + decision engine integration for incremental Kuzu rebuild (PR-T4)
- Add refresh_decision.py: decision engine that chooses incremental vs full rebuild for Lance and Kuzu based on change detection (git diff or explicit changed_paths). Full rebuild triggered on deletes, renames, config changes, pipeline changes, meta-annotation changes, missing/stale .deps.json, or >50% dirty set. - Update _cmd_increment in CLI: remove stale Kuzu warning, dispatch to incremental or full Kuzu rebuild based on decision engine output. Respect lance_mode for Lance full reprocess. - Add run_build_ast_graph_incremental to pipeline.py: writes changed paths to temp file, calls build_ast_graph.py --changed-paths. - Create refresh_code_index MCP tool in server.py: executes Lance + Kuzu rebuild based on decision engine, returns decision transparency fields. - Add 15 tests: 12 decision engine tests + 3 CLI integration tests. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent f25f63a commit 4abbce4

6 files changed

Lines changed: 863 additions & 30 deletions

File tree

java_codebase_rag/cli.py

Lines changed: 83 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -21,26 +21,10 @@
2121
index_dir_has_existing_artifacts,
2222
resolve_operator_config,
2323
)
24-
from java_codebase_rag.pipeline import clip, run_build_ast_graph, run_cocoindex_drop, run_cocoindex_update
24+
from java_codebase_rag.pipeline import clip, run_build_ast_graph, run_build_ast_graph_incremental, run_cocoindex_drop, run_cocoindex_update
25+
from refresh_decision import choose_refresh_mode
2526
from java_ontology import VALID_UNRESOLVED_CALL_REASONS
2627

27-
KUZU_INCREMENTAL_TRACKING_ISSUE_URL = "https://github.com/HumanBean17/java-codebase-rag/issues/73"
28-
29-
_INCREMENT_WARNING_LINES = (
30-
"WARNING: AST graph (Kuzu) incremental rebuild is not yet implemented.",
31-
"The graph reflects the index state from the last `init` or `reprocess`,",
32-
"which means `find`, `neighbors`, and `describe` may return stale results",
33-
"for files changed since then.",
34-
"",
35-
"Lance vector index has been updated incrementally and is current.",
36-
"",
37-
"For an up-to-date graph, run:",
38-
" java-codebase-rag reprocess",
39-
"",
40-
"Track progress on Kuzu incremental rebuild:",
41-
f" {KUZU_INCREMENTAL_TRACKING_ISSUE_URL}",
42-
)
43-
4428
_REFRESH_DEPRECATION = (
4529
"WARN: 'refresh' is deprecated; use 'reprocess'. "
4630
"This alias will be removed in the next release."
@@ -178,11 +162,6 @@ def _emit(value: Any) -> None:
178162
print(json.dumps(payload, default=_jsonable, sort_keys=True, indent=None))
179163

180164

181-
def _emit_increment_kuzu_warning() -> None:
182-
for line in _INCREMENT_WARNING_LINES:
183-
print(line, file=sys.stderr)
184-
185-
186165
def _parse_source_root(ns: argparse.Namespace) -> Path | None:
187166
if ns.source_root:
188167
return Path(ns.source_root).expanduser().resolve()
@@ -298,15 +277,25 @@ def _cmd_increment(args: argparse.Namespace) -> int:
298277
cfg = _resolved_from_ns(args)
299278
_startup_hints(cfg)
300279
cfg.apply_to_os_environ()
301-
_emit_increment_kuzu_warning()
302280

303281
def work() -> int:
304282
env = cfg.subprocess_env()
283+
verbose = bool(args.verbose)
284+
285+
# Decide refresh mode first so Lance mode is known
286+
decision = choose_refresh_mode(
287+
cfg.source_root,
288+
cfg.kuzu_path,
289+
mode="auto",
290+
)
291+
292+
# Lance update — full when decision engine says so (config/pipeline change)
293+
lance_full = decision.lance_mode == "full"
305294
coco = run_cocoindex_update(
306295
env,
307-
full_reprocess=False,
296+
full_reprocess=lance_full,
308297
quiet=bool(args.quiet),
309-
verbose=bool(args.verbose),
298+
verbose=verbose,
310299
lance_project_root=None if args.quiet else cfg.source_root,
311300
)
312301
if coco.returncode != 0:
@@ -320,7 +309,72 @@ def work() -> int:
320309
}
321310
)
322311
return 1
323-
_emit({"success": True, "message": "increment completed (Lance only; graph may be stale — see stderr)"})
312+
313+
# Kuzu rebuild based on decision
314+
if decision.kuzu_mode == "incremental" and decision.detected_changes.modified:
315+
changed = set(decision.detected_changes.modified + decision.detected_changes.added)
316+
if not args.quiet and verbose:
317+
for r in decision.reasons:
318+
print(f" [graph] {r}", file=sys.stderr)
319+
g = run_build_ast_graph_incremental(
320+
source_root=cfg.source_root,
321+
kuzu_path=cfg.kuzu_path,
322+
changed_paths=changed,
323+
verbose=verbose,
324+
quiet=bool(args.quiet),
325+
env=env,
326+
)
327+
if g.returncode != 0:
328+
# Incremental failed — fall back to full
329+
print(
330+
f"[graph] incremental failed (exit {g.returncode}), falling back to full rebuild",
331+
file=sys.stderr,
332+
)
333+
g = run_build_ast_graph(
334+
source_root=cfg.source_root,
335+
kuzu_path=cfg.kuzu_path,
336+
verbose=verbose,
337+
quiet=bool(args.quiet),
338+
env=env,
339+
)
340+
if g.returncode != 0:
341+
_emit(
342+
{
343+
"success": False,
344+
"exit_code": g.returncode,
345+
"stdout": clip(g.stdout, 4000),
346+
"stderr": clip(g.stderr, 4000),
347+
"message": f"graph builder exit {g.returncode}",
348+
}
349+
)
350+
return 1
351+
else:
352+
# Full Kuzu rebuild
353+
if not args.quiet:
354+
for r in decision.reasons:
355+
print(f" [graph] {r}", file=sys.stderr)
356+
if decision.reasons:
357+
print(" [graph] falling back to full Kuzu rebuild", file=sys.stderr)
358+
g = run_build_ast_graph(
359+
source_root=cfg.source_root,
360+
kuzu_path=cfg.kuzu_path,
361+
verbose=verbose,
362+
quiet=bool(args.quiet),
363+
env=env,
364+
)
365+
if g.returncode != 0:
366+
_emit(
367+
{
368+
"success": False,
369+
"exit_code": g.returncode,
370+
"stdout": clip(g.stdout, 4000),
371+
"stderr": clip(g.stderr, 4000),
372+
"message": f"graph builder exit {g.returncode}",
373+
}
374+
)
375+
return 1
376+
377+
_emit({"success": True, "message": "increment completed"})
324378
return 0
325379

326380
return _run_with_pipeline_progress("increment", cfg, quiet=bool(args.quiet), work=work)
@@ -615,7 +669,7 @@ def build_parser() -> argparse.ArgumentParser:
615669
"--quiet suppresses that stream; stdout remains the machine-readable payload.\n\n"
616670
"Lifecycle (manage the index):\n"
617671
" init Create a fresh index from a Java repository.\n"
618-
" increment Pick up changes since the last index update (Lance only).\n"
672+
" increment Pick up changes since the last index update (Lance + Kuzu incremental).\n"
619673
" reprocess Full vector + graph rebuild (default); optional --vectors-only / --graph-only.\n"
620674
" erase Delete the index from disk.\n\n"
621675
"Introspection (inspect the index):\n"
@@ -650,7 +704,7 @@ def build_parser() -> argparse.ArgumentParser:
650704
increment = subparsers.add_parser(
651705
"increment",
652706
help="Pick up changes since the last index update.",
653-
description="Runs cocoindex catch-up (no full reprocess). Does not rebuild Kuzu; see stderr warning.",
707+
description="Runs cocoindex catch-up (no full reprocess). Kuzu graph updated incrementally when safe; full rebuild as fallback.",
654708
)
655709
_add_index_embedding_flags(increment)
656710
_add_verbosity_flags(increment)

java_codebase_rag/pipeline.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import shutil
66
import subprocess
77
import sys
8+
import tempfile
89
import threading
910
import time
1011
from pathlib import Path
@@ -247,5 +248,73 @@ def run_build_ast_graph(
247248
return subprocess.CompletedProcess(args=cmd, returncode=code, stdout=out_s, stderr=err_s)
248249

249250

251+
def run_build_ast_graph_incremental(
252+
*,
253+
source_root: Path,
254+
kuzu_path: Path,
255+
changed_paths: set[str],
256+
verbose: bool,
257+
quiet: bool = False,
258+
env: dict[str, str] | None = None,
259+
) -> subprocess.CompletedProcess[str]:
260+
"""Run build_ast_graph.py in incremental mode with --changed-paths."""
261+
builder = bundle_dir() / "build_ast_graph.py"
262+
if not builder.is_file():
263+
return subprocess.CompletedProcess(
264+
args=[],
265+
returncode=126,
266+
stdout="",
267+
stderr=f"build_ast_graph.py not found under {builder.parent}",
268+
)
269+
# Write changed paths to a temp file
270+
tmp = tempfile.NamedTemporaryFile(
271+
mode="w", suffix=".paths", delete=False, prefix="changed-",
272+
)
273+
try:
274+
for p in sorted(changed_paths):
275+
tmp.write(p + "\n")
276+
tmp.close()
277+
278+
cmd: list[str] = [
279+
sys.executable,
280+
str(builder),
281+
"--source-root",
282+
str(source_root),
283+
"--kuzu-path",
284+
str(kuzu_path),
285+
"--changed-paths",
286+
tmp.name,
287+
]
288+
if verbose or not quiet:
289+
cmd.append("--verbose")
290+
if quiet:
291+
return subprocess.run(
292+
cmd,
293+
cwd=str(source_root),
294+
env=env or os.environ.copy(),
295+
capture_output=True,
296+
text=True,
297+
)
298+
proc = subprocess.Popen(
299+
cmd,
300+
cwd=str(source_root),
301+
env=env or os.environ.copy(),
302+
stdout=subprocess.PIPE,
303+
stderr=subprocess.PIPE,
304+
bufsize=0,
305+
)
306+
out_s, err_s, code = _popen_capturing_stderr(proc, verbose=verbose)
307+
if not verbose:
308+
from java_codebase_rag.cli_format import bold_cyan, styled_check, styled_cross
309+
marker = styled_check() if code == 0 else styled_cross()
310+
print(f"{marker} {bold_cyan('[graph]')} incremental done", file=sys.stderr, flush=True)
311+
return subprocess.CompletedProcess(args=cmd, returncode=code, stdout=out_s, stderr=err_s)
312+
finally:
313+
try:
314+
Path(tmp.name).unlink()
315+
except OSError:
316+
pass
317+
318+
250319
def clip(s: str, n: int) -> str:
251320
return s[-n:] if len(s) > n else s

0 commit comments

Comments
 (0)