From 9be6485aad84bd57762ed0d8d314fad3d34bb25c Mon Sep 17 00:00:00 2001 From: Dmitry Teryaev Date: Mon, 6 Jul 2026 09:21:09 +0300 Subject: [PATCH] feat(config): index-dir config_source pointer for sibling-config discovery Walk-up discovery is ancestor-only (like git), so a config living in a sibling dir of the Java tree (e.g. project-context/ beside the microservices and .java-codebase-rag/) is invisible when the agent's cwd is inside a microservice: discovery anchors on the index dir and silently falls back to defaults, dropping microservice_roots / role_overrides / embedding / hints. The index dir now remembers which YAML built it. init / reprocess / increment (and install / update) write /config_source (one absolute path). When discovery anchors on the index dir but finds no YAML beside it, _effective_config_dir follows the pointer and rebases config_dir to the YAML's directory -- so relative source_root / index_dir / embedding.model resolve against the YAML's home, not the index anchor (which would overshoot to $HOME/.java-codebase-rag). A direct YAML at the anchor always wins; a stale pointer (target moved/deleted) is ignored with a one-line stderr hint and falls back to defaults. The rebase touches config_dir only, never source_root, so the CLI/env "explicit source_root wins / YAML source_root field skipped" contract is preserved -- covering all four doors (jrag, operator CLI, MCP, installer) through one seam in resolve_operator_config. erase removes the pointer via a new OPERATOR_OWNED_INDEX_FILES constant, kept separate from the builder-owned set. Co-Authored-By: Claude --- docs/CONFIGURATION.md | 4 +- java_codebase_rag/cli.py | 35 ++++- java_codebase_rag/config.py | 108 +++++++++++++- java_codebase_rag/installer.py | 13 ++ java_codebase_rag/jrag.py | 4 + tests/test_config.py | 199 +++++++++++++++++++++++++- tests/test_java_codebase_rag_cli.py | 71 ++++++++- tests/test_mcp_server_project_root.py | 47 +++++- 8 files changed, 472 insertions(+), 9 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 8d440462..c3975db5 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -39,6 +39,8 @@ The tool automatically walks up the directory tree from the current working dire 4. Walk-up discovery result (config directory itself) 5. Current working directory (fallback) +**Config separate from the index — `.java-codebase-rag/config_source`.** Walk-up only checks *ancestor* directories (like Git), so a config living in a *sibling* directory of the Java tree is invisible when your working directory is inside a microservice — e.g. with `project-context/.java-codebase-rag.yml` beside `microservice-a/`, `microservice-b/`, and `.java-codebase-rag/`, an agent working from `microservice-a/` cannot reach the config by walking up. To bridge this, `init` / `reprocess` / `increment` (and `install` / `update`) write a one-line pointer `.java-codebase-rag/config_source` inside the index dir recording the absolute path of the YAML they used. When discovery anchors on the index dir but finds no YAML beside it, it follows that pointer and resolves the config — and its relative `source_root` / `index_dir` / `embedding.model` — as if you were in the config's own directory. A direct YAML at the anchor always wins; a stale pointer (target moved or deleted) is ignored with a one-line stderr hint and falls back to defaults. The pointer holds a machine-local absolute path and is not meant to be committed (the index dir is gitignored anyway). + This walk-up behavior means you no longer need to set environment variables or pass flags when working from within a project — the tool finds the config automatically. | Variable | Purpose | @@ -233,7 +235,7 @@ async_producer_overrides: **Tips & gotchas:** -- **The config file may live anywhere under your project, including a subdirectory of the Java tree.** Both the CLI (`init` / `increment` / `reprocess`) and the MCP server walk up from cwd to find `.java-codebase-rag.yml`, then resolve `source_root` and `index_dir` relative to the config file's directory. So a config living in `my-context/` next to `source_root: ../` and `index_dir: ../.java-codebase-rag` resolves identically for the CLI and the MCP server. Keep the file under your project (not `$HOME`); set `JAVA_CODEBASE_RAG_SOURCE_ROOT` (MCP) or `--source-root` (CLI) only to override the discovered location. +- **The config file may live anywhere under your project, including a subdirectory of the Java tree.** Both the CLI (`init` / `increment` / `reprocess`) and the MCP server walk up from cwd to find `.java-codebase-rag.yml`, then resolve `source_root` and `index_dir` relative to the config file's directory. So a config living in `my-context/` next to `source_root: ../` and `index_dir: ../.java-codebase-rag` resolves identically for the CLI and the MCP server. If the config lives in a *sibling* dir of the Java tree (not an ancestor of where the agent works), the index remembers its location via `.java-codebase-rag/config_source` — see [Config file discovery (walk-up)](#config-file-discovery-walk-up). Keep the file under your project (not `$HOME`); set `JAVA_CODEBASE_RAG_SOURCE_ROOT` (MCP) or `--source-root` (CLI) only to override the discovered location. - **Don't commit secrets** into this YAML — it sits next to your source tree and is read by every operator who clones it. - **Rebuild after editing brownfield overrides.** Run a full `java-codebase-rag reprocess` (no flags) so Lance and LadybugDB stay coherent, or use `--graph-only` / `--vectors-only` when you know only one store needs invalidation. Editing `embedding.model` requires a vector rebuild (`reprocess` or `--vectors-only`). - **Diagnose what's loaded.** `java-codebase-rag meta` prints the resolved config and each value's `*_source` (`cli` / `env` / `yaml` / `default`) — see `embedding_model_source`, `embedding_device_source`, `index_dir_source`. diff --git a/java_codebase_rag/cli.py b/java_codebase_rag/cli.py index bebe90b4..fdbb3225 100644 --- a/java_codebase_rag/cli.py +++ b/java_codebase_rag/cli.py @@ -15,12 +15,14 @@ from typing import Any, Callable from java_codebase_rag.config import ( + OPERATOR_OWNED_INDEX_FILES, ResolvedOperatorConfig, describe_path_sizes, emit_legacy_env_hints_if_present, emit_legacy_yaml_hint_if_needed, index_dir_has_existing_artifacts, resolve_operator_config, + write_config_source_pointer, ) from java_codebase_rag._fdlimit import raise_fd_limit from java_codebase_rag.pipeline import clip, run_build_ast_graph, run_cocoindex_drop, run_cocoindex_update, run_incremental_graph @@ -126,6 +128,21 @@ def _pipeline_footer(subcommand: str, started: float, exit_code: int) -> None: ) +# Subcommands that build/refresh an index and therefore should record which YAML +# was used (so a later discovery run from a sibling/cwd can relocate it). +_CONFIG_SOURCE_RECORDING_SUBCOMMANDS = frozenset({"init", "increment", "reprocess"}) + + +def _maybe_record_config_source( + subcommand: str, cfg: ResolvedOperatorConfig, code: int +) -> None: + """On a successful index build, remember the YAML path inside the index dir.""" + if code == 0 and subcommand in _CONFIG_SOURCE_RECORDING_SUBCOMMANDS: + write_config_source_pointer( + index_dir=cfg.index_dir, yaml_config_path=cfg.yaml_config_path + ) + + def _run_with_pipeline_progress( subcommand: str, cfg: ResolvedOperatorConfig, @@ -143,7 +160,9 @@ def _run_with_pipeline_progress( raw-relays subprocess output). """ if quiet or verbose: - return int(work(None)) + code = int(work(None)) + _maybe_record_config_source(subcommand, cfg, code) + return code from java_codebase_rag.progress import build_index_progress_context # PR-3 owns all three tasks in order: Vectors → Optimize → Graph. The vectors @@ -183,6 +202,7 @@ def _run_with_pipeline_progress( finally: renderer.stop() _pipeline_footer(subcommand, t0, code) + _maybe_record_config_source(subcommand, cfg, code) class PipelineProgress: @@ -612,7 +632,13 @@ def _cmd_erase(args: argparse.Namespace) -> int: # every other command) stay fast -- see the lazy-import invariant atop this file. from build_ast_graph import BUILDER_OWNED_INDEX_FILES builder_paths = [cfg.ladybug_path.parent / name for name in BUILDER_OWNED_INDEX_FILES] - to_describe: list[Path] = [cfg.ladybug_path, cfg.cocoindex_db, *builder_paths] + operator_paths = [cfg.index_dir / name for name in OPERATOR_OWNED_INDEX_FILES] + to_describe: list[Path] = [ + cfg.ladybug_path, + cfg.cocoindex_db, + *builder_paths, + *operator_paths, + ] if cfg.index_dir.is_dir(): try: import lancedb @@ -671,6 +697,11 @@ def work(progress: "PipelineProgress | None") -> int: _rm_any(cfg.cocoindex_db) for builder_path in builder_paths: _rm_any(builder_path) + # Operator-owned index files (the config_source pointer recording which + # YAML built this index) — owned by the CLI/installer, not the graph + # builder, so removed here rather than via BUILDER_OWNED_INDEX_FILES. + for operator_path in operator_paths: + _rm_any(operator_path) if cfg.index_dir.is_dir(): try: import lancedb diff --git a/java_codebase_rag/config.py b/java_codebase_rag/config.py index 3d244d64..7f294c1b 100644 --- a/java_codebase_rag/config.py +++ b/java_codebase_rag/config.py @@ -18,6 +18,18 @@ YAML_CONFIG_FILENAMES = (".java-codebase-rag.yml", ".java-codebase-rag.yaml") LEGACY_YAML_FILENAMES = (".lancedb-mcp.yml", ".lancedb-mcp.yaml") +# Pointer file written into the index dir at index time so discovery can locate +# a YAML that does not sit beside the index-dir anchor — e.g. a config living in +# a sibling ``project-context/`` dir when the agent's cwd is inside a +# microservice (a descendant of the index anchor, a sibling of the config). +# Contains one line: the absolute path of the YAML used to build the index. A +# direct YAML at the anchor always wins; the pointer only fires when the anchor +# has no YAML beside it (see ``_effective_config_dir``). +CONFIG_SOURCE_FILENAME = "config_source" +# Operator-owned files inside the index dir that ``erase`` removes. Kept separate +# from ``build_ast_graph.BUILDER_OWNED_INDEX_FILES`` (builder-owned artifacts). +OPERATOR_OWNED_INDEX_FILES = (CONFIG_SOURCE_FILENAME,) + ENV_INDEX_DIR = "JAVA_CODEBASE_RAG_INDEX_DIR" # Public operator contract is six names: INDEX_DIR, DEBUG_CONTEXT, RUN_HEAVY, SBERT_MODEL, SBERT_DEVICE, HINTS_ENABLED. # SOURCE_ROOT is still required for MCP / subprocess Java tree resolution (see mcp.json.example); it is not folded into the headline "5". @@ -234,6 +246,61 @@ def discover_project_root(start: Path) -> Path | None: current = parent +_stale_pointer_seen: set[str] = set() + + +def _config_dir_from_pointer(anchor: Path) -> Path | None: + """Return the YAML config dir recorded in the index dir's ``config_source`` pointer. + + Reads ``/.java-codebase-rag/config_source`` (one absolute path). If it + names an existing ``.java-codebase-rag.yml`` / ``.yaml``, returns that file's + parent directory; otherwise (missing/blank/stale) returns ``None``. Used only + when the anchor has no direct YAML — see :func:`_effective_config_dir`. + """ + pointer = anchor / ".java-codebase-rag" / CONFIG_SOURCE_FILENAME + if not pointer.is_file(): + return None + try: + raw = pointer.read_text(encoding="utf-8").strip() + except OSError: + return None + if not raw: + return None + target = Path(raw).expanduser() + if not target.is_absolute(): + # Relative to the anchor (the index-dir parent), not the pointer file. + target = (anchor / target).resolve() + if not target.is_file() or target.name not in YAML_CONFIG_FILENAMES: + key = str(pointer.resolve()) + if key not in _stale_pointer_seen: + _stale_pointer_seen.add(key) + print( + "java-codebase-rag: ignoring stale index pointer " + f"{pointer} -> {raw} (target missing or not a config file).", + file=sys.stderr, + ) + return None + return target.parent + + +def _effective_config_dir(config_dir: Path) -> Path: + """Resolve the directory YAML config fields are relative to. + + A direct ``.java-codebase-rag.yml`` / ``.yaml`` in ``config_dir`` always wins. + Otherwise, if ``config_dir`` hosts the ``.java-codebase-rag/`` index dir and + that index remembers its config via a ``config_source`` pointer, follow it to + the YAML's directory. This lets a config in a sibling dir (e.g. + ``project-context/`` beside the Java tree) be found when discovery anchors on + the index dir from inside a microservice — without an env var or flag, and + with YAML-relative fields (``index_dir``, ``source_root``, ``embedding.model``) + resolving against the YAML's home rather than the index anchor. Falls back to + ``config_dir`` unchanged when neither applies. + """ + if find_yaml_config_file(config_dir) is not None: + return config_dir + return _config_dir_from_pointer(config_dir) or config_dir + + def load_yaml_mapping(source_root: Path) -> dict[str, Any]: path = find_yaml_config_file(source_root) if path is None: @@ -272,6 +339,10 @@ class ResolvedOperatorConfig: embedding_model_source: SettingSource embedding_device_source: SettingSource hints_enabled_source: SettingSource + # Absolute path of the YAML actually loaded (None when built-in defaults were + # used with no config file). Recorded into the index dir at index time so a + # later discovery run from a sibling/cwd can relocate this config. + yaml_config_path: Path | None = None def apply_to_os_environ(self) -> None: """Make downstream modules (server, ladybug_queries, flows) see a consistent environment. @@ -411,21 +482,26 @@ def resolve_operator_config( # Phase 1: Find the config file directory if source_root is not None: # CLI flag provided: use it as both config_dir and effective source_root - # (skip YAML source_root check - CLI wins) + # (skip YAML source_root check - CLI wins). ``_effective_config_dir`` may + # rebase config_dir to a YAML reached via the index-dir pointer; root is + # untouched (explicit source_root wins). root = source_root.expanduser().resolve() - config_dir = root + config_dir = _effective_config_dir(root) yaml_dict = load_yaml_mapping(config_dir) else: # Check env var first env_raw = os.environ.get(ENV_SOURCE_ROOT, "").strip() if env_raw: root = Path(env_raw).expanduser().resolve() - config_dir = root + config_dir = _effective_config_dir(root) yaml_dict = load_yaml_mapping(config_dir) else: # Walk up to find config dir discovered = discover_project_root(Path.cwd()) config_dir = discovered if discovered is not None else Path.cwd().resolve() + # Follow an index-dir pointer to the real config dir when the anchor + # has no YAML beside it (e.g. config in a sibling dir). + config_dir = _effective_config_dir(config_dir) # Load YAML from config dir yaml_dict = load_yaml_mapping(config_dir) @@ -479,9 +555,35 @@ def resolve_operator_config( embedding_model_source=model_src, embedding_device_source=device_src, hints_enabled_source=hints_src, + yaml_config_path=find_yaml_config_file(config_dir), ) +def write_config_source_pointer( + *, index_dir: Path, yaml_config_path: Path | None +) -> None: + """Record the YAML config path inside the index dir (best-effort). + + Writes ``/config_source`` with the YAML's absolute path so a later + discovery run that anchors on the index dir (but has no YAML beside it) can + relocate the config via :func:`_effective_config_dir`. No-op when + ``yaml_config_path`` is None (pure-default build — nothing to remember). Never + raises: the pointer is an optimization, not a correctness requirement — a + missing/unreadable pointer just falls back to built-in defaults. + """ + if yaml_config_path is None: + return + try: + index_dir.mkdir(parents=True, exist_ok=True) + content = str(yaml_config_path.resolve()) + "\n" + target = index_dir / CONFIG_SOURCE_FILENAME + tmp = index_dir / (CONFIG_SOURCE_FILENAME + ".tmp") + tmp.write_text(content, encoding="utf-8") + os.replace(tmp, target) + except OSError: + pass + + def index_dir_has_existing_artifacts(index_dir: Path) -> tuple[bool, list[str]]: """True if graph dir or any Lance table already exists under index_dir.""" paths: list[str] = [] diff --git a/java_codebase_rag/installer.py b/java_codebase_rag/installer.py index 26fbebab..a15cba70 100644 --- a/java_codebase_rag/installer.py +++ b/java_codebase_rag/installer.py @@ -1021,6 +1021,7 @@ def run_init_if_needed( from java_codebase_rag.config import ( index_dir_has_existing_artifacts, resolve_operator_config, + write_config_source_pointer, ) from java_codebase_rag.pipeline import run_build_ast_graph, run_cocoindex_update @@ -1093,6 +1094,12 @@ def run_init_if_needed( if renderer is not None: renderer.stop() _index_progress_footer("install", started, ok=index_ok) + if index_ok: + # Remember which YAML built this index so discovery from a sibling/cwd + # can relocate the config (e.g. a config beside, not inside, the tree). + write_config_source_pointer( + index_dir=cfg.index_dir, yaml_config_path=cfg.yaml_config_path + ) return index_ok @@ -1595,6 +1602,7 @@ def run_update( discover_project_root, index_dir_has_existing_artifacts, resolve_operator_config, + write_config_source_pointer, ) from java_codebase_rag.pipeline import run_cocoindex_update, run_incremental_graph @@ -1699,6 +1707,11 @@ def run_update( _index_progress_footer("update", started, ok=index_ok) if not index_ok: return 1 + # Refresh the config pointer so a config moved/renamed since the last + # index is relocated correctly by discovery from a sibling/cwd. + write_config_source_pointer( + index_dir=cfg.index_dir, yaml_config_path=cfg.yaml_config_path + ) else: print("\nWould run incremental index update (Lance + graph).") diff --git a/java_codebase_rag/jrag.py b/java_codebase_rag/jrag.py index 8b01e91d..67d63584 100644 --- a/java_codebase_rag/jrag.py +++ b/java_codebase_rag/jrag.py @@ -1101,6 +1101,10 @@ def _resolve_cfg(args: argparse.Namespace): # type: ignore[no-untyped-def] applies CLI ``--index-dir`` if given, and calls ``apply_to_os_environ`` so downstream modules see a consistent env (critically: SBERT_MODEL for ``jrag search`` in PR-JRAG-4). + + When the anchor is an index dir with no YAML beside it, resolution follows + that index's ``config_source`` pointer (see ``config._effective_config_dir``) + so a config living in a sibling dir is still found from inside a microservice. """ from java_codebase_rag.config import discover_project_root, resolve_operator_config diff --git a/tests/test_config.py b/tests/test_config.py index f4b37625..716bb09e 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2,9 +2,13 @@ from pathlib import Path from java_codebase_rag.config import ( - discover_project_root, + CONFIG_SOURCE_FILENAME, YAML_CONFIG_FILENAMES, + _config_dir_from_pointer, + _effective_config_dir, + discover_project_root, resolve_operator_config, + write_config_source_pointer, ) @@ -502,3 +506,196 @@ def test_cocoindex_subprocess_env_defaults_uses_real_inflight_env_var() -> None: assert defaults["COCOINDEX_MAX_INFLIGHT_COMPONENTS"] == "256" # The bogus name from the broken #293 fix must NOT leak back in. assert "COCOINDEX_SOURCE_MAX_INFLIGHT_ROWS" not in defaults + + +class TestConfigSourcePointer: + """Index-dir ``config_source`` pointer lets a YAML in a sibling dir be found. + + Walk-up discovery is ancestor-only, so a config in a sibling dir (e.g. + ``project-context/`` beside the Java tree) is invisible from inside a + microservice. The index dir remembers its YAML via ``config_source``; + ``_effective_config_dir`` follows it and rebases ``config_dir`` so YAML- + relative fields (``index_dir``, ``source_root``, ``embedding.model``) + resolve against the YAML's home, not the index-dir anchor. + """ + + @staticmethod + def _sibling_layout(tmp_path: Path) -> dict: + """The user's monorepo shape: config beside, not inside, the Java tree.""" + root = tmp_path + ctx = root / "project-context" + ctx.mkdir() + (ctx / YAML_CONFIG_FILENAMES[0]).write_text( + "source_root: ../\nindex_dir: ../.java-codebase-rag\n" + "microservice_roots: [microservice-a]\n" + ) + idx = root / ".java-codebase-rag" + idx.mkdir() + (idx / "code_graph.lbug").write_bytes(b"\x00" * 16) + micro = root / "microservice-a" / "src" / "main" / "java" + micro.mkdir(parents=True) + return { + "root": root, + "ctx": ctx, + "yaml": ctx / YAML_CONFIG_FILENAMES[0], + "idx": idx, + "micro": micro, + } + + # --- _config_dir_from_pointer unit behaviour --- + + def test_pointer_returns_yaml_dir(self, tmp_path): + idx = tmp_path / ".java-codebase-rag" + idx.mkdir() + (idx / "code_graph.lbug").write_bytes(b"\x00") + sibling = tmp_path / "ctx" + sibling.mkdir() + yaml = sibling / YAML_CONFIG_FILENAMES[0] + yaml.write_text("source_root: ../\n") + write_config_source_pointer(index_dir=idx, yaml_config_path=yaml) + assert _config_dir_from_pointer(tmp_path) == sibling.resolve() + + def test_pointer_missing_returns_none(self, tmp_path): + idx = tmp_path / ".java-codebase-rag" + idx.mkdir() + (idx / "code_graph.lbug").write_bytes(b"\x00") + assert _config_dir_from_pointer(tmp_path) is None + + def test_pointer_stale_target_returns_none(self, tmp_path): + idx = tmp_path / ".java-codebase-rag" + idx.mkdir() + (idx / "code_graph.lbug").write_bytes(b"\x00") + (idx / CONFIG_SOURCE_FILENAME).write_text(str(tmp_path / "gone.yml") + "\n") + assert _config_dir_from_pointer(tmp_path) is None + + def test_pointer_wrong_target_name_returns_none(self, tmp_path): + idx = tmp_path / ".java-codebase-rag" + idx.mkdir() + (idx / "code_graph.lbug").write_bytes(b"\x00") + wrong = tmp_path / "not-a-config.txt" + wrong.write_text("nope") + (idx / CONFIG_SOURCE_FILENAME).write_text(str(wrong) + "\n") + assert _config_dir_from_pointer(tmp_path) is None + + # --- _effective_config_dir precedence --- + + def test_direct_yaml_wins_over_pointer(self, tmp_path): + (tmp_path / YAML_CONFIG_FILENAMES[0]).write_text("source_root: .\n") + idx = tmp_path / ".java-codebase-rag" + idx.mkdir() + (idx / "code_graph.lbug").write_bytes(b"\x00") + sibling = tmp_path / "ctx" + sibling.mkdir() + other = sibling / YAML_CONFIG_FILENAMES[0] + other.write_text("source_root: .\n") + write_config_source_pointer(index_dir=idx, yaml_config_path=other) + # Direct YAML at tmp_path wins; the sibling pointer is ignored. + assert _effective_config_dir(tmp_path) == tmp_path + + def test_effective_dir_falls_back_to_anchor_when_no_yaml_or_pointer(self, tmp_path): + assert _effective_config_dir(tmp_path) == tmp_path + + # --- end-to-end via resolve_operator_config --- + + def test_resolve_via_pointer_index_dir_is_tree_index(self, tmp_path, monkeypatch): + """REGRESSION: index_dir resolves to /.java-codebase-rag, not + .parent/.java-codebase-rag. Without the config_dir rebase, the YAML's + ``index_dir: ../.java-codebase-rag`` would resolve against the index-dir + anchor and overshoot by one level — a silent wrong-store bug.""" + monkeypatch.delenv("JAVA_CODEBASE_RAG_INDEX_DIR", raising=False) + monkeypatch.delenv("JAVA_CODEBASE_RAG_SOURCE_ROOT", raising=False) + lay = self._sibling_layout(tmp_path) + write_config_source_pointer(index_dir=lay["idx"], yaml_config_path=lay["yaml"]) + monkeypatch.chdir(lay["micro"]) + # jrag path: explicit source_root = the discovered index-dir anchor. + cfg = resolve_operator_config(source_root=discover_project_root(Path.cwd())) + assert cfg.source_root == lay["root"].resolve() + assert cfg.index_dir == lay["idx"].resolve() + # The exact overshoot the rebase prevents: + assert cfg.index_dir != (lay["root"].parent / ".java-codebase-rag").resolve() + assert cfg.yaml_config_path == lay["yaml"].resolve() + + def test_discovery_via_pointer_from_microservice_cwd(self, tmp_path, monkeypatch): + monkeypatch.delenv("JAVA_CODEBASE_RAG_INDEX_DIR", raising=False) + monkeypatch.delenv("JAVA_CODEBASE_RAG_SOURCE_ROOT", raising=False) + lay = self._sibling_layout(tmp_path) + write_config_source_pointer(index_dir=lay["idx"], yaml_config_path=lay["yaml"]) + monkeypatch.chdir(lay["micro"]) + cfg = resolve_operator_config(source_root=None) + assert cfg.source_root == lay["root"].resolve() + assert cfg.index_dir == lay["idx"].resolve() + assert cfg.yaml_config_path == lay["yaml"].resolve() + + def test_no_pointer_falls_back_to_defaults(self, tmp_path, monkeypatch): + monkeypatch.delenv("JAVA_CODEBASE_RAG_INDEX_DIR", raising=False) + monkeypatch.delenv("JAVA_CODEBASE_RAG_SOURCE_ROOT", raising=False) + lay = self._sibling_layout(tmp_path) + monkeypatch.chdir(lay["micro"]) + cfg = resolve_operator_config(source_root=None) + assert cfg.yaml_config_path is None + # Default /.java-codebase-rag still lands on the anchor's + # index by coincidence (the pre-feature behaviour) — no crash. + assert cfg.index_dir == lay["idx"].resolve() + + def test_round_trip_write_then_resolve(self, tmp_path, monkeypatch): + monkeypatch.delenv("JAVA_CODEBASE_RAG_INDEX_DIR", raising=False) + monkeypatch.delenv("JAVA_CODEBASE_RAG_SOURCE_ROOT", raising=False) + lay = self._sibling_layout(tmp_path) + # init-style resolve from the config dir, then record the pointer. + monkeypatch.chdir(lay["ctx"]) + cfg = resolve_operator_config(source_root=None) + write_config_source_pointer( + index_dir=cfg.index_dir, yaml_config_path=cfg.yaml_config_path + ) + # Resolving from the microservice cwd lands identically. + monkeypatch.chdir(lay["micro"]) + cfg2 = resolve_operator_config(source_root=discover_project_root(Path.cwd())) + assert cfg2.index_dir == cfg.index_dir + assert cfg2.source_root == cfg.source_root + assert cfg2.yaml_config_path == cfg.yaml_config_path + + # --- issue #357: a stray pointer at $HOME must not hijack discovery --- + + def test_stray_pointer_at_home_does_not_hijack(self, tmp_path, monkeypatch): + fake_home = tmp_path / "fake-home" + fake_home.mkdir() + stray_idx = fake_home / ".java-codebase-rag" + stray_idx.mkdir() + (stray_idx / "code_graph.lbug").write_bytes(b"\x00") + sibling = fake_home / "ctx" + sibling.mkdir() + (sibling / YAML_CONFIG_FILENAMES[0]).write_text("source_root: .\n") + write_config_source_pointer( + index_dir=stray_idx, yaml_config_path=sibling / YAML_CONFIG_FILENAMES[0] + ) + monkeypatch.setenv("HOME", str(fake_home)) + monkeypatch.setenv("USERPROFILE", str(fake_home)) + proj = tmp_path / "proj" + proj.mkdir() + (proj / YAML_CONFIG_FILENAMES[0]).write_text("source_root: .\n") + monkeypatch.chdir(proj) + # Discovery anchors on the real project, never on the fake $HOME index. + assert discover_project_root(Path.cwd()) == proj.resolve() + assert _effective_config_dir(proj.resolve()) == proj.resolve() + + # --- write helper --- + + def test_write_pointer_noop_when_no_yaml(self, tmp_path): + idx = tmp_path / ".java-codebase-rag" + idx.mkdir() + write_config_source_pointer(index_dir=idx, yaml_config_path=None) + assert not (idx / CONFIG_SOURCE_FILENAME).exists() + + def test_write_pointer_creates_index_dir_writes_absolute_path(self, tmp_path): + idx = tmp_path / ".java-codebase-rag" + yaml = tmp_path / YAML_CONFIG_FILENAMES[0] + yaml.write_text("source_root: .\n") + write_config_source_pointer(index_dir=idx, yaml_config_path=yaml) + # index_dir was created; content is an absolute path + newline. + content = (idx / CONFIG_SOURCE_FILENAME).read_text() + assert content.endswith("\n") + written = Path(content.strip()) + assert written.is_absolute() + assert written == yaml.resolve() + # Atomic write left no .tmp behind. + assert not (idx / (CONFIG_SOURCE_FILENAME + ".tmp")).exists() diff --git a/tests/test_java_codebase_rag_cli.py b/tests/test_java_codebase_rag_cli.py index 85e641e5..817f73b8 100644 --- a/tests/test_java_codebase_rag_cli.py +++ b/tests/test_java_codebase_rag_cli.py @@ -12,7 +12,11 @@ import pytest from java_codebase_rag import cli as cli_mod -from java_codebase_rag.config import emit_legacy_env_hints_if_present, resolve_operator_config +from java_codebase_rag.config import ( + YAML_CONFIG_FILENAMES, + emit_legacy_env_hints_if_present, + resolve_operator_config, +) @pytest.fixture(scope="session", autouse=True) @@ -174,6 +178,71 @@ def test_erase_removes_increment_marker_and_hash_store_tmp(tmp_path: Path) -> No ) +def test_erase_removes_config_source_pointer(tmp_path: Path) -> None: + """erase must clear the operator-owned ``config_source`` pointer. + + The pointer is written by init/reprocess/increment/install/update (not the + graph builder), so erase removes it via ``OPERATOR_OWNED_INDEX_FILES`` rather + than ``BUILDER_OWNED_INDEX_FILES``. A stale pointer surviving erase would let + a later discovery run from a sibling cwd relocate a YAML for an index that no + longer exists — defeating erase's "clean slate". + """ + idx = tmp_path / "erase_pointer" + idx.mkdir() + (idx / "code_graph.lbug").write_bytes(b"fake-kuzu-db") + (idx / "config_source").write_text( + str(tmp_path / ".java-codebase-rag.yml") + "\n", encoding="utf-8" + ) + env = os.environ.copy() + env["JAVA_CODEBASE_RAG_INDEX_DIR"] = str(idx) + env["JAVA_CODEBASE_RAG_SOURCE_ROOT"] = str(tmp_path) + proc = _run_cli( + ["erase", "--source-root", str(tmp_path), "--index-dir", str(idx), "--yes"], + env=env, + ) + assert proc.returncode == 0, proc.stderr + proc.stdout + assert not (idx / "config_source").exists(), ( + "erase left config_source pointer; discovery could relocate a stale YAML" + ) + + +def test_pipeline_progress_writes_config_source_on_success_skips_on_failure( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """_run_with_pipeline_progress records the pointer on code==0, skips on failure. + + Covers the actual CLI write hook (not just the helper). Uses the non-TTY + (--quiet) branch so no Live region is needed; the TTY branch calls the same + ``_maybe_record_config_source`` helper from its ``finally``. + """ + monkeypatch.delenv("JAVA_CODEBASE_RAG_INDEX_DIR", raising=False) + monkeypatch.delenv("JAVA_CODEBASE_RAG_SOURCE_ROOT", raising=False) + (tmp_path / YAML_CONFIG_FILENAMES[0]).write_text("source_root: .\n", encoding="utf-8") + cfg = resolve_operator_config(source_root=tmp_path) + pointer = cfg.index_dir / "config_source" + + # Success -> pointer written with the YAML's absolute path. + code = cli_mod._run_with_pipeline_progress( + "init", cfg, quiet=True, verbose=False, work=lambda progress: 0 + ) + assert code == 0 + assert pointer.exists() + assert Path(pointer.read_text().strip()) == (tmp_path / YAML_CONFIG_FILENAMES[0]).resolve() + + # Failure -> pointer not (re)written; the success pointer is left in place + # (erase owns removal), but a fresh failure on a clean dir writes nothing. + pointer.unlink() + fresh_idx = tmp_path / "fresh" + fresh_idx.mkdir() + fresh_cfg = resolve_operator_config(source_root=tmp_path, cli_index_dir=str(fresh_idx)) + fresh_pointer = fresh_cfg.index_dir / "config_source" + code = cli_mod._run_with_pipeline_progress( + "init", fresh_cfg, quiet=True, verbose=False, work=lambda progress: 1 + ) + assert code == 1 + assert not fresh_pointer.exists(), "failure must not record a config pointer" + + def test_embedding_model_precedence_cli_over_env_over_yaml_over_default( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/test_mcp_server_project_root.py b/tests/test_mcp_server_project_root.py index df3b45c1..fb35bc80 100644 --- a/tests/test_mcp_server_project_root.py +++ b/tests/test_mcp_server_project_root.py @@ -1,6 +1,10 @@ """Tests for server.py _project_root() function in the MCP server context.""" -from java_codebase_rag.config import YAML_CONFIG_FILENAMES, resolve_operator_config +from java_codebase_rag.config import ( + YAML_CONFIG_FILENAMES, + resolve_operator_config, + write_config_source_pointer, +) class TestProjectRoot: @@ -79,3 +83,44 @@ def test_mcp_and_init_resolve_identically_for_nested_config(self, tmp_path, monk assert mcp.source_root == cli.source_root assert mcp.index_dir == cli.index_dir + def test_mcp_and_init_resolve_identically_via_pointer(self, tmp_path, monkeypatch): + """MCP/CLI parity when the config is reached via the index-dir pointer. + + Config lives in a SIBLING dir (``my-project-context/``) of the Java tree; + the agent's cwd is inside a microservice. Walk-up anchors on the index dir + at ``tmp_path``; the ``config_source`` pointer rebases config_dir to the + sibling YAML. The MCP server (env unset) and the CLI must still resolve the + SAME source_root and index_dir — and that index_dir is the real tree index, + not ``tmp_path.parent/.java-codebase-rag`` (the overshoot the rebase fixes). + """ + monkeypatch.delenv("JAVA_CODEBASE_RAG_SOURCE_ROOT", raising=False) + monkeypatch.delenv("JAVA_CODEBASE_RAG_INDEX_DIR", raising=False) + + config_dir = tmp_path / "my-project-context" + config_dir.mkdir() + (config_dir / YAML_CONFIG_FILENAMES[0]).write_text( + "source_root: ../\nindex_dir: ../.java-codebase-rag\n" + ) + idx = tmp_path / ".java-codebase-rag" + idx.mkdir() + (idx / "code_graph.lbug").write_bytes(b"\x00" * 16) + write_config_source_pointer( + index_dir=idx, yaml_config_path=config_dir / YAML_CONFIG_FILENAMES[0] + ) + microservice = tmp_path / "microservice-a" / "src" + microservice.mkdir(parents=True) + monkeypatch.chdir(microservice) + + from server import _source_root_for_operator_config + + mcp = resolve_operator_config(source_root=_source_root_for_operator_config()) + cli = resolve_operator_config(source_root=None) + + assert mcp.source_root == tmp_path.resolve() + assert mcp.index_dir == idx.resolve() + # The rebase prevents overshooting to tmp_path.parent. + assert mcp.index_dir != (tmp_path.parent / ".java-codebase-rag").resolve() + assert mcp.source_root == cli.source_root + assert mcp.index_dir == cli.index_dir + assert mcp.yaml_config_path == (config_dir / YAML_CONFIG_FILENAMES[0]).resolve() +