diff --git a/README.md b/README.md index 3707696..1f2b68a 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,8 @@ java-codebase-rag install --non-interactive --agent claude-code After `pip install --upgrade java-codebase-rag`, run `java-codebase-rag update` to refresh shipped artifacts and catch up the index (Lance + graph). +All indexing lifecycle commands (`init`, `increment`, `reprocess`, `install`, `update`) show a unified `Vectors → Optimize → Graph` progress bar on stderr during the index build (powered by `rich`); pass `--quiet` to suppress it. + ### Manual registration If you prefer manual configuration, see [`docs/JAVA-CODEBASE-RAG-CLI.md`](./docs/JAVA-CODEBASE-RAG-CLI.md) for the full CLI reference. diff --git a/docs/JAVA-CODEBASE-RAG-CLI.md b/docs/JAVA-CODEBASE-RAG-CLI.md index c761adc..06a6dd3 100644 --- a/docs/JAVA-CODEBASE-RAG-CLI.md +++ b/docs/JAVA-CODEBASE-RAG-CLI.md @@ -43,6 +43,8 @@ java-codebase-rag install --scope user - `--agent {claude-code,qwen-code,gigacode}` — Agent host to configure (can be passed multiple times). - `--scope {project,user}` — Installation scope (default: `project`). Project scope writes to `./` in the project repo; user scope writes to `~/./` (globally available). - `--model MODEL` — Embedding model path or `auto` (default: `auto`, downloads `sentence-transformers/all-MiniLM-L6-v2` on first run). +- `--quiet` / `-q` — Suppress the indexing progress stream on stderr (wizard prompts unchanged). +- `--verbose` / `-v` — Raw-relay subprocess output during the indexing sub-step (no progress bar). **Exit codes:** - `0` — Success (all stages completed). @@ -55,7 +57,7 @@ java-codebase-rag install --scope user 3. Agent host selection — Claude Code, Qwen Code, GigaCode (multi-select). 4. Install scope — project or user. 5. MCP entrypoint resolution + artifact deployment — config, skill, agent files. -6. Index + finish — YAML generation, `.gitignore` update, `init`. +6. Index + finish — YAML generation, `.gitignore` update, `init`. Stage 6's indexing sub-step renders the unified `Vectors → Optimize → Graph` progress on **stderr** (see [Indexing progress](#indexing-progress-stderr)); the wizard's conversational stdout is unchanged. **Re-running `install`:** If `.java-codebase-rag.yml` exists, the installer shows current values and offers "Update" (pre-filled) or "Start fresh". Existing MCP entries are updated in-place (merged, not duplicated). Skill/agent files trigger overwrite confirmation. @@ -78,13 +80,14 @@ java-codebase-rag update --force **Flags:** - `--force` — Overwrite all artifacts even if content matches. - `--dry-run` — Print changes without writing files. +- `--quiet` / `-q` — Suppress the indexing progress stream on stderr (wizard stdout unchanged). +- `--verbose` / `-v` — Raw-relay subprocess output during the indexing sub-step (no progress bar). **Behavior:** - Detects previously configured agent hosts (scans both project-level and user-level config files). - Refreshes skill and agent files (versioned assets from the package). - Updates MCP entrypoint path if `java-codebase-rag-mcp` has moved. -- Runs an incremental index update (Lance + graph) if an index exists — same as `java-codebase-rag increment`. -- Skips MCP config if the entry already exists and is correct. +- Runs an incremental index update (Lance + graph) if an index exists — same as `java-codebase-rag increment`. The indexing sub-step renders the unified `Vectors → Optimize → Graph` progress on **stderr** (see [Indexing progress](#indexing-progress-stderr)); it no longer runs silently. **Exit codes:** - `0` — Success. @@ -95,7 +98,7 @@ java-codebase-rag update --force - **TTY:** human-readable `pprint` of the payload on stdout (except **successful selective `reprocess`** with `--vectors-only` / `--graph-only`, which prints `Rebuilt:` / `Skipped:` lines instead of dumping the full dict). - **Piped / non-TTY:** **single JSON object** per invocation on stdout (no trailing noise). Use this in scripts and CI. -- **Lifecycle stderr:** `init`, `increment`, `reprocess`, and `erase` stream subprocess progress (and relayed child stdout) to **stderr**; pass **`--quiet`** to suppress that stream. **stdout** stays the JSON/pprint payload only. +- **Lifecycle stderr:** `init`, `increment`, `reprocess`, `install`, `update`, and `erase` stream subprocess progress (and relayed child stdout) to **stderr**; pass **`--quiet`** to suppress that stream. **stdout** stays the JSON/pprint payload (`init`/`increment`/`reprocess`) or the wizard conversational text (`install`/`update`) only. Example: @@ -103,6 +106,37 @@ Example: java-codebase-rag meta --source-root /path/to/java/repo --index-dir /path/to/.java-codebase-rag | jq .ontology_version ``` +### Indexing progress (stderr) + +All five lifecycle commands that build the index (`init`, `increment`, `reprocess`, `install`, `update`) render the **same unified progress** on **stderr** during indexing: a header line, a three-phase list `Vectors → Optimize → Graph`, and a footer line. The phase list is the single source of truth for "what's happening right now": + +- **Vectors** — the `cocoindex update` Lance catch-up / full reprocess. +- **Optimize** — the serialized Lance table compaction that runs after a successful vectors phase. +- **Graph** — the `build_ast_graph.py` Kuzu/LadybugDB build (full or incremental). + +**Determinate vs indeterminate per command:** + +| Phase | Determinate? | +| ----- | ------------ | +| `Vectors` (full `init` / `reprocess`) | Approximately determinate — a pre-walk estimates the file count; the bar **clamps to 100% on completion** (the pre-walk overstates by ignored/empty files). | +| `Vectors` (incremental `increment` / `update`) | Indeterminate — CocoIndex's `memo=True` cache only calls the per-file function for changed files, so no denominator is known up front. A pulsing bar plus a "files touched: N" counter. | +| `Optimize` | Always indeterminate (no item count exposed by Lance compaction). | +| `Graph` (full `init` / `reprocess`) | Determinate — pass 1 does a count-first filtered walk for an exact total; passes 2–6 are six known steps. | +| `Graph` (incremental `increment` / `update`) | Determinate when it runs; falls back to a full rebuild on schema change. | + +**Flags, TTY, and failure:** + +| Mode | Behaviour | +| ---- | --------- | +| TTY (default) | `rich` `Live` region — the multi-line phase display (spinner + bar + `%` + ETA). | +| Non-TTY / CI | `rich` auto-disables; concise throttled stderr lines (~every 5 s per phase + a terminal line) so CI logs still show progress. | +| `--quiet` / `-q` | Suppresses the entire progress stream (no header, phases, or footer). The stdout payload is unchanged. | +| `--verbose` / `-v` | Bypasses parsing; relays raw subprocess output verbatim (Lance warnings, brownfield events, the raw `JCIRAG_PROGRESS` protocol lines). No `Live` region. | +| Phase failure | The failing phase renders a red `✗`; the footer carries `(exit=N)`. The `rich` `Live` region is torn down cleanly so the error stays visible. | +| Missing `cocoindex` / builder binary | The pre-spawn stub emits a `status=failed` line; no phase is left hung at `running`. | + +> **Behaviour change (this release).** `install` and `update` now emit their indexing progress on **stderr** (previously `install` printed indexing chatter to stdout, and `update` ran the whole indexing step with `quiet=True` — completely silent). The wizard conversational stdout for both commands is otherwise unchanged. `update`'s previously-ignored `--quiet` / `--verbose` flags, and `install`'s previously-ignored `--verbose` flag, are now wired through (`install` already honored `--quiet`). + ## Environment variables (summary) | Variable | Role | diff --git a/java_codebase_rag/cli.py b/java_codebase_rag/cli.py index 7981c84..58a44b2 100644 --- a/java_codebase_rag/cli.py +++ b/java_codebase_rag/cli.py @@ -144,7 +144,7 @@ def _run_with_pipeline_progress( """ if quiet or verbose: return int(work(None)) - from java_codebase_rag.progress import IndexProgressRenderer, ProgressEvent + from java_codebase_rag.progress import build_index_progress_context # PR-3 owns all three tasks in order: Vectors → Optimize → Graph. The vectors # task is fed by the cocoindex child's per-file ticks + approximate total @@ -152,15 +152,10 @@ def _run_with_pipeline_progress( # in-process by lance_optimize; the graph task is fed by the build_ast_graph # child (subprocess transport). A task only becomes visible/running once its # first event arrives. - phases = ["vectors", "optimize", "graph"] - renderer = IndexProgressRenderer(phases) + renderer, on_progress, console = build_index_progress_context() progress = PipelineProgress(renderer=renderer) - - def on_progress(ev: ProgressEvent) -> None: - renderer.apply(ev) - progress.on_progress = on_progress - progress.console = renderer._console # noqa: SLF001 — shared with the drain for Live-safe routing + progress.console = console _pipeline_header(subcommand, cfg) t0 = time.perf_counter() @@ -570,6 +565,7 @@ def _cmd_install(args: argparse.Namespace) -> int: model=args.model, source_root=None, # None means cwd; installer confirms interactively quiet=bool(args.quiet), + verbose=bool(args.verbose), ) @@ -579,6 +575,8 @@ def _cmd_update(args: argparse.Namespace) -> int: return run_update( force=bool(args.force), dry_run=bool(args.dry_run), + quiet=bool(args.quiet), + verbose=bool(args.verbose), ) diff --git a/java_codebase_rag/installer.py b/java_codebase_rag/installer.py index 0dfcdfd..3ea835d 100644 --- a/java_codebase_rag/installer.py +++ b/java_codebase_rag/installer.py @@ -14,6 +14,7 @@ import shutil import sys import tempfile +import time from dataclasses import dataclass from pathlib import Path from typing import Literal, NamedTuple @@ -801,6 +802,38 @@ def update_gitignore(cwd: Path) -> None: gitignore_path.write_text("\n".join(lines), encoding="utf-8") +def _index_progress_header(subcommand: str, source_root: Path, index_dir: Path) -> None: + """Print the stderr header framing the indexing sub-step (install/update). + + Mirrors the operator commands' ``_pipeline_header`` but lives in the + installer because the wizard's stdout framing differs. This brackets ONLY + the indexing sub-step — the wizard's prompts stay outside it on stdout. + """ + from java_codebase_rag.cli_format import bold + + print( + bold( + f"java-codebase-rag {subcommand} · source={source_root.resolve()} " + f"· index={index_dir.resolve()}" + ), + file=sys.stderr, + flush=True, + ) + + +def _index_progress_footer(subcommand: str, started: float, *, ok: bool) -> None: + """Print the stderr footer closing the indexing sub-step framing.""" + from java_codebase_rag.cli_format import bold, styled_check, styled_cross + + elapsed = time.perf_counter() - started + marker = styled_check() if ok else styled_cross() + print( + f"{marker} {bold(f'java-codebase-rag {subcommand} · finished in {elapsed:.2f}s')}", + file=sys.stderr, + flush=True, + ) + + def run_init_if_needed( source_root: Path, index_dir: Path, @@ -808,15 +841,25 @@ def run_init_if_needed( *, non_interactive: bool, quiet: bool, + verbose: bool = False, ) -> bool: """Run init if index directory has no artifacts. Return True if init was run. + The indexing sub-step (CocoIndex update + AST graph build) renders the + unified ``Vectors → Optimize → Graph`` progress on **stderr** in default + mode (same renderer the operator commands use); the wizard's conversational + stdout is untouched by this function. ``--quiet`` is silent; ``--verbose`` + raw-relays subprocess output. The indexing chatter that used to print to + stdout (``Creating index…`` / ``Index created successfully.``) now lives + on stderr framing so stdout stays the wizard payload. + Args: source_root: Source root directory index_dir: Index directory path model: Embedding model path or "auto" non_interactive: If True, suppress prompts - quiet: If True, suppress output + quiet: If True, suppress progress output + verbose: If True, raw-relay subprocess output (no Live region) Returns: True if init was run, False if skipped @@ -832,36 +875,71 @@ def run_init_if_needed( print("Index already exists. Run `java-codebase-rag reprocess` to rebuild.") return False - print("Creating index...") cfg = resolve_operator_config( source_root=source_root, cli_index_dir=None, # use default (/.java-codebase-rag) cli_embedding_model=model if model != "auto" else None, ) cfg.apply_to_os_environ() - env = cfg.subprocess_env() - # Run CocoIndex update - coco = run_cocoindex_update(env, full_reprocess=False, quiet=quiet) - if coco.returncode != 0: - print(f"Error: CocoIndex update failed with code {coco.returncode}") - return False - - # Run AST graph build - g = run_build_ast_graph( - source_root=cfg.source_root, - ladybug_path=cfg.ladybug_path, - verbose=not quiet, - quiet=quiet, - env=env, - ) - if g.returncode != 0: - print(f"Error: AST graph build failed with code {g.returncode}") - return False - - print("Index created successfully.") - return True + # Indexing sub-step: render unified progress on stderr in default mode only + # (quiet = silent; verbose = raw relay, no Live region). The renderer wraps + # just this sub-step, not the surrounding wizard. + on_progress, on_progress_console = None, None + renderer = None + if not quiet and not verbose: + from java_codebase_rag.progress import build_index_progress_context + + renderer, on_progress, on_progress_console = build_index_progress_context() + + started = time.perf_counter() + if renderer is not None: + _index_progress_header("install", cfg.source_root, cfg.index_dir) + renderer.start() + index_ok = True + try: + coco = run_cocoindex_update( + env, + full_reprocess=False, + quiet=quiet, + verbose=verbose, + on_progress=on_progress, + on_progress_console=on_progress_console, + ) + if coco.returncode != 0: + print( + f"Error: CocoIndex update failed with code {coco.returncode}", + file=sys.stderr, + ) + index_ok = False + else: + g = run_build_ast_graph( + source_root=cfg.source_root, + ladybug_path=cfg.ladybug_path, + verbose=verbose, + quiet=quiet, + env=env, + on_progress=on_progress, + on_progress_console=on_progress_console, + ) + if g.returncode != 0: + print( + f"Error: AST graph build failed with code {g.returncode}", + file=sys.stderr, + ) + index_ok = False + except BaseException: + # An exception from cocoindex/graph means the index did not succeed; + # flip the footer marker before re-raising so it renders a red cross + # (mirrors cli._run_with_pipeline_progress's BaseException handler). + index_ok = False + raise + finally: + if renderer is not None: + renderer.stop() + _index_progress_footer("install", started, ok=index_ok) + return index_ok def handle_rerun(cwd: Path, *, non_interactive: bool) -> dict | None: @@ -1196,13 +1274,25 @@ def run_update( force: bool, dry_run: bool, cwd: Path | None = None, + quiet: bool = False, + verbose: bool = False, ) -> int: """Run the update pipeline. Returns exit code. + The indexing sub-step (Lance catch-up + incremental graph) renders the + unified ``Vectors → Optimize → Graph`` progress on **stderr** in default + mode and no longer runs with ``quiet=True`` (the reason ``update`` was + silent). ``--quiet`` is silent; ``--verbose`` raw-relays subprocess output. + The wizard's host-detection / refresh / summary stdout is preserved; only + the indexing chatter that used to print to stdout moves onto the stderr + renderer framing. + Args: force: If True, overwrite all artifacts even if matching dry_run: If True, print changes without writing cwd: Current working directory (defaults to Path.cwd()) + quiet: If True, suppress progress output + verbose: If True, raw-relay subprocess output (no Live region) Returns: Exit code (0=success, 1=partial, 2=fatal) @@ -1277,30 +1367,74 @@ def run_update( # The "graph not implemented" warning belongs only on the vectors-only path # (increment --vectors-only), where the graph step is deliberately skipped. if not dry_run: - print("\nUpdating index (Lance + graph)...") cfg.apply_to_os_environ() env = cfg.subprocess_env() - coco = run_cocoindex_update(env, full_reprocess=False, quiet=True) - if coco.returncode != 0: - print(f"Error: Lance index update failed with code {coco.returncode}") - return 1 - - g = run_incremental_graph( - source_root=cfg.source_root, - ladybug_path=cfg.ladybug_path, - verbose=False, - quiet=True, - env=env, - ) - if g.returncode != 0: - # Artifacts above already refreshed; the graph catch-up is best-effort - # here. Surface a truthful, actionable message instead of leaving the - # graph silently stale or claiming the feature is unimplemented. - print( - f"\nWarning: incremental graph update failed (exit {g.returncode}). " - "Run `java-codebase-rag reprocess` for a full rebuild." + # Indexing sub-step: render unified progress on stderr in default mode + # only (quiet = silent; verbose = raw relay). No longer runs quiet=True + # — that was why `update` was silent. The renderer wraps just this + # sub-step; the wizard's summary stdout below is outside it. + on_progress, on_progress_console = None, None + renderer = None + if not quiet and not verbose: + from java_codebase_rag.progress import build_index_progress_context + + renderer, on_progress, on_progress_console = build_index_progress_context() + + started = time.perf_counter() + if renderer is not None: + _index_progress_header("update", cfg.source_root, cfg.index_dir) + renderer.start() + index_ok = True + try: + coco = run_cocoindex_update( + env, + full_reprocess=False, + quiet=quiet, + verbose=verbose, + on_progress=on_progress, + on_progress_console=on_progress_console, ) + if coco.returncode != 0: + print( + f"Error: Lance index update failed with code {coco.returncode}", + file=sys.stderr, + ) + index_ok = False + else: + g = run_incremental_graph( + source_root=cfg.source_root, + ladybug_path=cfg.ladybug_path, + verbose=verbose, + quiet=quiet, + env=env, + on_progress=on_progress, + on_progress_console=on_progress_console, + ) + if g.returncode != 0: + # The graph catch-up is best-effort: `update`'s primary job + # is refreshing shipped artifacts + vectors (cocoindex). A + # graph failure surfaces a truthful, actionable Warning on + # stderr but does NOT flip index_ok (which drives both the + # footer marker and the return code) — exit 0 with a green + # check + the Warning line carrying the graph caveat. + print( + f"\nWarning: incremental graph update failed (exit {g.returncode}). " + "Run `java-codebase-rag reprocess` for a full rebuild.", + file=sys.stderr, + ) + except BaseException: + # An exception from cocoindex/graph means the index did not succeed; + # flip the footer marker before re-raising so it renders a red cross + # (mirrors cli._run_with_pipeline_progress's BaseException handler). + index_ok = False + raise + finally: + if renderer is not None: + renderer.stop() + _index_progress_footer("update", started, ok=index_ok) + if not index_ok: + return 1 else: print("\nWould run incremental index update (Lance + graph).") @@ -1320,6 +1454,7 @@ def run_install( model: str | None, source_root: Path | None = None, quiet: bool = False, + verbose: bool = False, ) -> int: """Run the install pipeline. Returns exit code. @@ -1330,6 +1465,7 @@ def run_install( model: Model from CLI flag source_root: Source root path (defaults to cwd if None) quiet: If True, suppress output + verbose: If True, raw-relay subprocess indexing output (no Live region) Returns: Exit code (0=success, 1=partial, 2=fatal) @@ -1428,6 +1564,7 @@ def run_install( resolved_model, non_interactive=non_interactive, quiet=quiet, + verbose=verbose, ) return 0 diff --git a/java_codebase_rag/progress.py b/java_codebase_rag/progress.py index 62c8a45..c07df5f 100644 --- a/java_codebase_rag/progress.py +++ b/java_codebase_rag/progress.py @@ -57,6 +57,7 @@ "ProgressRelay", "CallbackRenderer", "make_relay", + "build_index_progress_context", ] ProgressKind = Literal["vectors", "graph", "optimize"] @@ -433,6 +434,38 @@ def make_relay( ) +# The canonical phase order shared by every lifecycle command that renders +# progress. The operator commands (init/increment/reprocess) and the installer +# sub-steps (install/update indexing) all render this same list so the +# Vectors → Optimize → Graph shape is uniform across the CLI. +_INDEX_PHASES = ["vectors", "optimize", "graph"] + + +def build_index_progress_context( + phases: list[str] | None = None, +) -> tuple["IndexProgressRenderer", Callable[[ProgressEvent], None], "Console"]: + """Construct the shared ``(renderer, on_progress, console)`` triple. + + Both ``cli._run_with_pipeline_progress`` (operator commands, default TTY + mode) and the installer's indexing sub-step (``installer.run_init_if_needed`` + / ``run_update``) use this so the phase list, the callback wiring, and the + single-writer console are defined in exactly one place. The returned + ``on_progress`` forwards each event to ``renderer.apply``; ``console`` is + the renderer's stderr ``rich.Console`` so the subprocess drain routes + non-progress lines through ``console.print`` while a Live region is up. + + The caller owns ``renderer.start()``/``stop()`` lifecycle. In ``--quiet`` + or ``--verbose`` mode the caller simply does not call this helper (quiet + is silent; verbose raw-relays). + """ + renderer = IndexProgressRenderer(phases if phases is not None else _INDEX_PHASES) + + def on_progress(ev: ProgressEvent) -> None: + renderer.apply(ev) + + return renderer, on_progress, renderer._console # noqa: SLF001 — shared console for the drain + + # --------------------------------------------------------------------------- # Relay # --------------------------------------------------------------------------- diff --git a/tests/test_installer.py b/tests/test_installer.py index 4d45e43..bf858f7 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -1232,7 +1232,8 @@ def test_update_honors_yaml_source_root_for_nested_config_dir( # resolved JAVA_CODEBASE_RAG_SOURCE_ROOT / _INDEX_DIR. captured: dict = {} - def capture_coco(env, *, full_reprocess, quiet, verbose=True, lance_project_root=None): + def capture_coco(env, *, full_reprocess, quiet, verbose=True, lance_project_root=None, + on_progress=None, on_progress_console=None): captured["env"] = env return CompletedProcess(["cocoindex"], 0) @@ -1362,3 +1363,294 @@ def test_update_missing_mcp_binary_returns_partial_failure(self, tmp_path, monke result = run_update(force=False, dry_run=False, cwd=tmp_path) # Should return partial failure (1) because artifact refresh failed assert result == 1 + + +# --------------------------------------------------------------------------- +# PR-4 — install/update unified index progress (stderr renderer) +# --------------------------------------------------------------------------- + + +def _patch_pipeline_for_progress(monkeypatch, *, emit: bool = True) -> dict: + """Patch the three pipeline helpers the installer uses to emit progress. + + Records the ``quiet``/``verbose`` kwargs each was called with so tests can + assert the installer no longer forces ``quiet=True``. Returns the call log. + """ + import subprocess + from java_codebase_rag import pipeline as _pipeline + + calls: dict = {"coco": [], "graph": [], "incremental": []} + + def _coco(env, *, full_reprocess, quiet, verbose=True, lance_project_root=None, + on_progress=None, on_progress_console=None): + calls["coco"].append({"quiet": quiet, "verbose": verbose}) + if emit and on_progress is not None: + from java_codebase_rag.progress import ProgressEvent + on_progress(ProgressEvent( + kind="vectors", phase=None, pass_=None, done=1, total=10, + status="running", elapsed_s=None)) + return subprocess.CompletedProcess(args=["stub"], returncode=0, stdout="", stderr="") + + def _graph(*, source_root, ladybug_path, verbose, quiet=False, env=None, + on_progress=None, on_progress_console=None): + calls["graph"].append({"quiet": quiet, "verbose": verbose}) + if emit and on_progress is not None: + from java_codebase_rag.progress import ProgressEvent + on_progress(ProgressEvent( + kind="graph", phase=None, pass_="1/6", done=1, total=10, + status="running", elapsed_s=None)) + return subprocess.CompletedProcess(args=["stub"], returncode=0, stdout="", stderr="") + + def _incremental(*, source_root, ladybug_path, verbose, quiet=False, env=None, + on_progress=None, on_progress_console=None): + calls["incremental"].append({"quiet": quiet, "verbose": verbose}) + if emit and on_progress is not None: + from java_codebase_rag.progress import ProgressEvent + on_progress(ProgressEvent( + kind="graph", phase=None, pass_="1/6", done=1, total=10, + status="running", elapsed_s=None)) + return subprocess.CompletedProcess(args=["stub"], returncode=0, stdout="", stderr="") + + monkeypatch.setattr(_pipeline, "run_cocoindex_update", _coco) + monkeypatch.setattr(_pipeline, "run_build_ast_graph", _graph) + monkeypatch.setattr(_pipeline, "run_incremental_graph", _incremental) + return calls + + +class TestPR4IndexProgress: + """PR-4: install/update emit unified index progress on stderr.""" + + def _setup_repo(self, tmp_path, monkeypatch): + """Copy the bank-chat fixture and stub MCP discovery for install/update. + + Also writes a configured ``.mcp.json`` so ``update`` (which requires a + prior ``install`` per its docstring) detects a configured host and + reaches its indexing sub-step. + """ + import shutil + bank_chat = Path("tests/bank-chat-system") + if not bank_chat.is_dir(): + pytest.skip("bank-chat-system fixture not found") + shutil.copytree(bank_chat, tmp_path / "bank-chat") + cwd = tmp_path / "bank-chat" + (cwd / ".git").mkdir() + # A configured host entry — the state `update` expects post-install. + (cwd / ".mcp.json").write_text( + json.dumps( + { + "mcpServers": { + "java-codebase-rag": { + "command": "/fake/bin/java-codebase-rag-mcp", + "type": "stdio", + } + } + } + ), + encoding="utf-8", + ) + monkeypatch.setattr(shutil, "which", lambda x: "/fake/bin/java-codebase-rag-mcp") + monkeypatch.setattr( + "java_codebase_rag.installer._read_package_artifact", + lambda path: "PACKAGE CONTENT", + ) + monkeypatch.chdir(cwd) + return cwd + + def test_install_emits_indexing_progress_on_stderr(self, tmp_path, monkeypatch): + """install drives the renderer from the patched pipeline helpers; the + JCIRAG_PROGRESS event is consumed by the parser and surfaces as a + rendered progress line on stderr. Wizard stdout prompts remain on + stdout.""" + import io + import contextlib + from java_codebase_rag.installer import run_install + + cwd = self._setup_repo(tmp_path, monkeypatch) + _patch_pipeline_for_progress(monkeypatch, emit=True) + + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + rc = run_install( + non_interactive=True, + agents=["claude-code"], + scope="project", + model="auto", + source_root=cwd, + quiet=False, + ) + assert rc == 0 + err_text = err.getvalue() + out_text = out.getvalue() + # The raw structured protocol line is parsed, never raw-relayed. + assert "JCIRAG_PROGRESS kind=vectors" not in err_text + # But indexing progress IS rendered on stderr (non-TTY concise fallback + # prints a "vectors ..." line; the patched coco helper emitted a vectors + # event). A graph event is emitted by the patched graph helper too. + assert "vectors" in err_text.lower() + # The wizard's conversational stdout is preserved (it writes the YAML + # config path when not quiet). + assert "Configuration written" in out_text or ".java-codebase-rag.yml" in out_text + + def test_update_emits_indexing_progress_on_stderr(self, tmp_path, monkeypatch): + """update is no longer silent: the patched cocoindex + incremental + graph helpers drive the renderer, and progress surfaces on stderr.""" + import io + import contextlib + from java_codebase_rag.installer import run_update + + cwd = self._setup_repo(tmp_path, monkeypatch) + # A configured host + a real-looking index so run_update reaches indexing. + index_dir = cwd / ".java-codebase-rag" + index_dir.mkdir(exist_ok=True) + (index_dir / "code_graph.lbug").write_text("", encoding="utf-8") + + _patch_pipeline_for_progress(monkeypatch, emit=True) + monkeypatch.delenv("JAVA_CODEBASE_RAG_SOURCE_ROOT", raising=False) + monkeypatch.delenv("JAVA_CODEBASE_RAG_INDEX_DIR", raising=False) + + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + rc = run_update(force=False, dry_run=False, cwd=cwd) + assert rc in (0, 1) + err_text = err.getvalue() + # Progress reached the renderer (coco + incremental both emitted). + assert "JCIRAG_PROGRESS kind=vectors" not in err_text + assert "vectors" in err_text.lower() + + def test_update_runs_indexing_without_quiet_true(self, tmp_path, monkeypatch): + """Regression: update no longer forces quiet=True on the indexing + helpers (the reason it was silent today). In the default path both + helpers are called with quiet=False.""" + from java_codebase_rag.installer import run_update + + cwd = self._setup_repo(tmp_path, monkeypatch) + index_dir = cwd / ".java-codebase-rag" + index_dir.mkdir(exist_ok=True) + (index_dir / "code_graph.lbug").write_text("", encoding="utf-8") + + calls = _patch_pipeline_for_progress(monkeypatch, emit=False) + monkeypatch.delenv("JAVA_CODEBASE_RAG_SOURCE_ROOT", raising=False) + monkeypatch.delenv("JAVA_CODEBASE_RAG_INDEX_DIR", raising=False) + + rc = run_update(force=False, dry_run=False, cwd=cwd) + assert rc in (0, 1) + # Both indexing helpers ran and were NOT silenced. + assert calls["coco"], "run_cocoindex_update was not called" + assert calls["incremental"], "run_incremental_graph was not called" + assert calls["coco"][-1]["quiet"] is False + assert calls["incremental"][-1]["quiet"] is False + + def test_install_update_stdout_contract_preserved(self, tmp_path, monkeypatch): + """The wizard's human-readable stdout shape is unchanged: NO + JCIRAG_PROGRESS line leaks to stdout, and the indexing chatter that + used to live on stdout ("Creating index..." / "Updating index...") + no longer appears there.""" + import io + import contextlib + from java_codebase_rag.installer import run_install, run_update + + cwd = self._setup_repo(tmp_path, monkeypatch) + _patch_pipeline_for_progress(monkeypatch, emit=True) + + # --- install --- + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + run_install( + non_interactive=True, agents=["claude-code"], scope="project", + model="auto", source_root=cwd, quiet=False, + ) + install_out = out.getvalue() + # No structured progress line on stdout (stdout is the wizard payload). + assert "JCIRAG_PROGRESS" not in install_out + # The old stdout indexing chatter is gone (moved to stderr framing). + assert "Creating index..." not in install_out + assert "Index created successfully." not in install_out + + # --- update --- + index_dir = cwd / ".java-codebase-rag" + index_dir.mkdir(exist_ok=True) + (index_dir / "code_graph.lbug").write_text("", encoding="utf-8") + _patch_pipeline_for_progress(monkeypatch, emit=True) + monkeypatch.delenv("JAVA_CODEBASE_RAG_SOURCE_ROOT", raising=False) + monkeypatch.delenv("JAVA_CODEBASE_RAG_INDEX_DIR", raising=False) + + out2, err2 = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out2), contextlib.redirect_stderr(err2): + run_update(force=False, dry_run=False, cwd=cwd) + update_out = out2.getvalue() + assert "JCIRAG_PROGRESS" not in update_out + # The old stdout indexing chatter moved off stdout. + assert "Updating index (Lance + graph)..." not in update_out + + def test_update_graph_catchup_failure_is_best_effort_exit_0(self, tmp_path, monkeypatch): + """run_update's graph catch-up is best-effort: a graph-only failure must + NOT flip the exit code. Vectors (cocoindex) succeeded, so exit 0 with a + Warning on stderr carrying the graph caveat — matches the original + semantics and the output/UX-only scope of PR-4.""" + import io + import contextlib + import subprocess + from java_codebase_rag.installer import run_update + + cwd = self._setup_repo(tmp_path, monkeypatch) + index_dir = cwd / ".java-codebase-rag" + index_dir.mkdir(exist_ok=True) + (index_dir / "code_graph.lbug").write_text("", encoding="utf-8") + monkeypatch.delenv("JAVA_CODEBASE_RAG_SOURCE_ROOT", raising=False) + monkeypatch.delenv("JAVA_CODEBASE_RAG_INDEX_DIR", raising=False) + + # Patch at the installer import site (java_codebase_rag.pipeline). + # cocoindex succeeds; the incremental graph returns a non-zero exit. + def coco_ok(env, *, full_reprocess, quiet, verbose=True, + lance_project_root=None, on_progress=None, on_progress_console=None): + return subprocess.CompletedProcess(args=["stub"], returncode=0, stdout="", stderr="") + + def graph_fail(**kwargs): + return subprocess.CompletedProcess(args=["stub"], returncode=3, stdout="", stderr="") + + monkeypatch.setattr("java_codebase_rag.pipeline.run_cocoindex_update", coco_ok) + monkeypatch.setattr("java_codebase_rag.pipeline.run_incremental_graph", graph_fail) + + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + rc = run_update(force=False, dry_run=False, cwd=cwd) + + assert rc == 0, f"graph-only failure must be best-effort (exit 0), got {rc}" + err_text = err.getvalue() + assert "Warning:" in err_text + assert "incremental graph update failed" in err_text + + def test_install_indexing_exception_renders_failed_footer(self, tmp_path, monkeypatch): + """If run_cocoindex_update raises during install's indexing sub-step, + the renderer bracket must render a failed (red cross) footer before the + exception propagates — not a green check right before the traceback. + Mirrors cli._run_with_pipeline_progress's BaseException handler.""" + import io + import contextlib + from java_codebase_rag import cli_format + from java_codebase_rag.installer import run_install + + cwd = self._setup_repo(tmp_path, monkeypatch) + + def boom(env, *, full_reprocess, quiet, verbose=True, + lance_project_root=None, on_progress=None, on_progress_console=None): + raise RuntimeError("boom from cocoindex") + + monkeypatch.setattr("java_codebase_rag.pipeline.run_cocoindex_update", boom) + + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + with pytest.raises(RuntimeError, match="boom from cocoindex"): + run_install( + non_interactive=True, + agents=["claude-code"], + scope="project", + model="auto", + source_root=cwd, + quiet=False, + ) + + err_text = err.getvalue() + # The footer rendered the failure marker (red cross), not the green check. + assert cli_format.styled_cross() in err_text + assert cli_format.styled_check() not in err_text diff --git a/tests/test_java_codebase_rag_cli.py b/tests/test_java_codebase_rag_cli.py index aa8f3b9..cbac80d 100644 --- a/tests/test_java_codebase_rag_cli.py +++ b/tests/test_java_codebase_rag_cli.py @@ -1361,3 +1361,96 @@ def test_cli_graph_progress_absent_when_quiet( # In quiet mode there is no header/footer framing either. assert "java-codebase-rag init" not in err + +# --------------------------------------------------------------------------- +# PR-4 — wire --quiet/--verbose through update / install +# --------------------------------------------------------------------------- + + +def test_cmd_update_forwards_quiet_flag( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """`_cmd_update --quiet` forwards quiet=True to run_update. + + Until PR-4 _cmd_update ignored both --quiet and --verbose entirely. + """ + import java_codebase_rag.installer as _installer + + captured: dict = {} + + def _fake_run_update(*, force=False, dry_run=False, cwd=None, + quiet=False, verbose=False): + captured["quiet"] = quiet + captured["verbose"] = verbose + captured["force"] = force + captured["dry_run"] = dry_run + return 0 + + monkeypatch.setattr(_installer, "run_update", _fake_run_update) + monkeypatch.chdir(tmp_path) + + rc = cli_mod.main(["update", "--quiet"]) + assert rc == 0 + assert captured["quiet"] is True + + +def test_cmd_update_forwards_verbose_flag( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """`_cmd_update --verbose` forwards verbose=True to run_update.""" + import java_codebase_rag.installer as _installer + + captured: dict = {} + + def _fake_run_update(*, force=False, dry_run=False, cwd=None, + quiet=False, verbose=False): + captured["quiet"] = quiet + captured["verbose"] = verbose + return 0 + + monkeypatch.setattr(_installer, "run_update", _fake_run_update) + monkeypatch.chdir(tmp_path) + + rc = cli_mod.main(["update", "--verbose"]) + assert rc == 0 + assert captured["verbose"] is True + # And the default path (no flag) forwards both as False. + rc2 = cli_mod.main(["update"]) + assert rc2 == 0 + assert captured["quiet"] is False + assert captured["verbose"] is False + + +def test_cmd_install_forwards_verbose_flag( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """`_cmd_install --verbose` forwards verbose=True to run_install. + + Until PR-4 _cmd_install wired only --quiet through. + """ + import java_codebase_rag.installer as _installer + + captured: dict = {} + + def _fake_run_install(*, non_interactive, agents, scope, model, + source_root=None, quiet=False, verbose=False): + captured["quiet"] = quiet + captured["verbose"] = verbose + captured["non_interactive"] = non_interactive + return 0 + + monkeypatch.setattr(_installer, "run_install", _fake_run_install) + monkeypatch.chdir(tmp_path) + + rc = cli_mod.main( + ["install", "--non-interactive", "--agent", "claude-code", "--verbose"] + ) + assert rc == 0 + assert captured["verbose"] is True + # quiet still flows through too. + rc2 = cli_mod.main( + ["install", "--non-interactive", "--agent", "claude-code", "--quiet"] + ) + assert rc2 == 0 + assert captured["quiet"] is True +