Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions graph_enrich.py
Original file line number Diff line number Diff line change
Expand Up @@ -1589,8 +1589,11 @@ def detect_microservice_from_path(cwd: Path, source_root: Path) -> str | None:
if overrides and cwd_resolved.name in overrides:
return cwd_resolved.name

# Call existing microservice_for_path to detect microservice from build markers
ms = microservice_for_path(str(cwd_resolved), source_resolved)
# microservice_for_path walks _bounded_parents which excludes the path itself.
# For query-time detection we need cwd included in the walk, so pass a synthetic
# child path so that cwd appears as a parent in the build-marker scan.
synthetic = cwd_resolved / "__scope_probe__"
ms = microservice_for_path(str(synthetic), source_resolved)
return ms if ms else None


Expand Down
25 changes: 15 additions & 10 deletions java_codebase_rag/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,18 +229,23 @@ def _add_verbosity_flags(p: argparse.ArgumentParser) -> None:

def _cmd_init(args: argparse.Namespace) -> int:
cfg = _resolved_from_ns(args)
# Check for parent config
from java_codebase_rag.config import discover_project_root, YAML_CONFIG_FILENAMES
# Check for parent config or index
from java_codebase_rag.config import discover_project_root, find_yaml_config_file
parent_config_dir = discover_project_root(cfg.source_root.parent)
if parent_config_dir is not None:
parent_config = parent_config_dir / YAML_CONFIG_FILENAMES[0]
if not parent_config.is_file():
parent_config = parent_config_dir / YAML_CONFIG_FILENAMES[1]
print(
f"Warning: found existing config at {parent_config}. "
f"Creating a new project here will create a separate index.",
file=sys.stderr,
)
parent_config = find_yaml_config_file(parent_config_dir)
if parent_config is not None:
print(
f"Warning: found existing config at {parent_config}. "
f"Creating a new project here will create a separate index.",
file=sys.stderr,
)
else:
print(
f"Warning: found existing index at {parent_config_dir / '.java-codebase-rag'}. "
f"Creating a new project here will create a separate index.",
file=sys.stderr,
)
_startup_hints(cfg)
cfg.apply_to_os_environ()
occupied, paths = index_dir_has_existing_artifacts(cfg.index_dir)
Expand Down
21 changes: 17 additions & 4 deletions java_codebase_rag/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,20 +123,33 @@ def find_yaml_config_file(source_root: Path) -> Path | None:
return None


def _has_index_dir(directory: Path) -> bool:
"""True if *directory* contains a non-empty ``.java-codebase-rag/`` index directory."""
idx = directory / ".java-codebase-rag"
return idx.is_dir() and any(idx.iterdir())


def discover_project_root(start: Path) -> Path | None:
"""Walk up from start to find the directory containing a config file.
"""Walk up from start to find the directory containing a config file or index.

First match wins (closest to start). Stops at $HOME inclusive — checks $HOME
itself but does not walk past it. Returns None if no config found.
Looks for ``.java-codebase-rag.yml`` / ``.java-codebase-rag.yaml`` (preferred)
or the ``.java-codebase-rag/`` index directory as a project boundary marker.

First match wins (closest to start). Config file takes priority over index
directory at the same level. Stops at $HOME inclusive — checks $HOME itself
but does not walk past it. Returns None if no marker found.
"""
start = start.resolve()
home = Path.home().resolve()

current = start
while True:
# Check if current directory contains a config file
# Config file is the primary anchor
if find_yaml_config_file(current) is not None:
return current
# Index directory is the secondary anchor (supports indexes without config)
if _has_index_dir(current):
return current

# Stop if we've reached home (check home itself, but don't walk past it)
if current == home:
Expand Down
56 changes: 56 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,62 @@ def test_discover_project_root_first_match_wins(self, tmp_path):
# Should find the closest config (subdir), not the parent (tmp_path)
assert result == subdir

def test_discover_project_root_finds_nonempty_index_dir(self, tmp_path):
"""Non-empty .java-codebase-rag/ directory acts as project anchor."""
subdir = tmp_path / "microservice"
subdir.mkdir()
idx = tmp_path / ".java-codebase-rag"
idx.mkdir()
(idx / "code_graph.kuzu").write_bytes(b"\x00" * 16)

result = discover_project_root(subdir)
assert result == tmp_path

def test_discover_project_root_skips_empty_index_dir(self, tmp_path):
"""Empty .java-codebase-rag/ directory does not anchor the project."""
subdir = tmp_path / "microservice"
subdir.mkdir()
# Empty index dir at subdir level
empty_idx = subdir / ".java-codebase-rag"
empty_idx.mkdir()
# Real index at parent level
real_idx = tmp_path / ".java-codebase-rag"
real_idx.mkdir()
(real_idx / "code_graph.kuzu").write_bytes(b"\x00" * 16)

result = discover_project_root(subdir)
assert result == tmp_path

def test_discover_project_root_config_wins_over_index_dir(self, tmp_path):
"""Config file takes priority over index dir at the same level."""
subdir = tmp_path / "subdir"
subdir.mkdir()
# Index dir at tmp_path level
idx = tmp_path / ".java-codebase-rag"
idx.mkdir()
(idx / "code_graph.kuzu").write_bytes(b"\x00" * 16)
# Config at subdir level
config_file = subdir / YAML_CONFIG_FILENAMES[0]
config_file.write_text("# child config")

deep = subdir / "deep"
deep.mkdir()
result = discover_project_root(deep)
# Config at subdir is closer and wins
assert result == subdir

def test_discover_project_root_both_markers_same_level(self, tmp_path):
"""When both config and index dir exist at same dir, both resolve correctly."""
# Both markers in the same directory
config_file = tmp_path / YAML_CONFIG_FILENAMES[0]
config_file.write_text("# config")
idx = tmp_path / ".java-codebase-rag"
idx.mkdir()
(idx / "code_graph.kuzu").write_bytes(b"\x00" * 16)

result = discover_project_root(tmp_path)
assert result == tmp_path


class TestSourceRootFromYaml:
"""Tests for source_root YAML field parsing and resolution."""
Expand Down
22 changes: 16 additions & 6 deletions tests/test_microservice_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,20 +21,30 @@ def test_detect_microservice_deep_inside(self, tmp_path):
assert result == "microservice-a"

def test_detect_microservice_at_microservice_root(self, tmp_path):
"""At microservice root detects that microservice."""
"""At microservice root (cwd = the dir with pom.xml) detects that microservice."""
ms_dir = tmp_path / "microservice-b"
ms_dir.mkdir()

# Add a build marker
(ms_dir / "build.gradle").write_text("plugins { id 'java' }")

# Use a subdirectory inside the microservice (not the root itself)
sub_dir = ms_dir / "src"
sub_dir.mkdir()

result = detect_microservice_from_path(sub_dir, tmp_path)
# cwd IS the microservice root — the most common user scenario
result = detect_microservice_from_path(ms_dir, tmp_path)
assert result == "microservice-b"

def test_detect_microservice_nested_modules(self, tmp_path):
"""Nested build markers scope to outermost microservice, not inner module."""
ms_dir = tmp_path / "my-service"
ms_dir.mkdir()
(ms_dir / "pom.xml").write_text("<project></project>")
module_dir = ms_dir / "my-module"
module_dir.mkdir()
(module_dir / "pom.xml").write_text("<project></project>")

# From inside the module, should scope to the service, not the module
result = detect_microservice_from_path(module_dir, tmp_path)
assert result == "my-service"

def test_detect_microservice_at_system_root(self, tmp_path):
"""At system root returns None (no specific scope)."""
result = detect_microservice_from_path(tmp_path, tmp_path)
Expand Down
Loading