Files
Claude-Code-Workflow/codex-lens/tests/test_global_graph_expander.py
catlog22 6054a01b8f feat: add CLI fallback for MCP calls in team commands
- Implemented CLI fallback using `ccw team` for various team command operations in `execute.md`, `plan.md`, `review.md`, `spec-analyst.md`, `spec-coordinate.md`, `spec-discuss.md`, `spec-reviewer.md`, `spec-writer.md`, and `test.md`.
- Updated command generation templates to include CLI fallback examples.
- Enhanced validation checks to ensure CLI fallback sections are present.
- Added quality standards for CLI fallback in team command design.
- Introduced a new `GlobalGraphExpander` class for expanding search results with cross-directory relationships.
- Added tests for `GlobalGraphExpander` to verify functionality and score decay factors.
2026-02-13 12:05:48 +08:00

324 lines
10 KiB
Python

"""Tests for GlobalGraphExpander."""
import tempfile
from pathlib import Path
import pytest
from codexlens.entities import (
CodeRelationship,
RelationshipType,
SearchResult,
Symbol,
)
from codexlens.search.global_graph_expander import (
DECAY_FACTORS,
DEFAULT_DECAY,
GlobalGraphExpander,
)
from codexlens.storage.global_index import GlobalSymbolIndex
@pytest.fixture()
def temp_dir():
tmpdir = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
yield Path(tmpdir.name)
try:
tmpdir.cleanup()
except (PermissionError, OSError):
pass
def _setup_global_index(root: Path) -> GlobalSymbolIndex:
"""Create a GlobalSymbolIndex with test symbols and relationships."""
db_path = root / "test_global.db"
gsi = GlobalSymbolIndex(db_path, project_id=1)
gsi.initialize()
# Files in different directories (cross-directory scenario)
file_a = str((root / "pkg_a" / "module_a.py").resolve())
file_b = str((root / "pkg_b" / "module_b.py").resolve())
file_c = str((root / "pkg_c" / "module_c.py").resolve())
index_path = str((root / "indexes" / "_index.db").resolve())
symbols_a = [
Symbol(name="ClassA", kind="class", range=(1, 20), file=file_a),
Symbol(name="func_a", kind="function", range=(22, 30), file=file_a),
]
symbols_b = [
Symbol(name="ClassB", kind="class", range=(1, 15), file=file_b),
]
symbols_c = [
Symbol(name="helper_c", kind="function", range=(1, 10), file=file_c),
]
gsi.update_file_symbols(file_a, symbols_a, index_path=index_path)
gsi.update_file_symbols(file_b, symbols_b, index_path=index_path)
gsi.update_file_symbols(file_c, symbols_c, index_path=index_path)
# Relationships:
# ClassA --imports--> ClassB (cross-directory)
# ClassA --calls--> helper_c (cross-directory)
# ClassB --inherits--> ClassA (cross-directory)
relationships_a = [
CodeRelationship(
source_symbol="ClassA",
target_symbol="ClassB",
relationship_type=RelationshipType.IMPORTS,
source_file=file_a,
target_file=file_b,
source_line=2,
),
CodeRelationship(
source_symbol="ClassA",
target_symbol="helper_c",
relationship_type=RelationshipType.CALL,
source_file=file_a,
target_file=file_c,
source_line=10,
),
]
relationships_b = [
CodeRelationship(
source_symbol="ClassB",
target_symbol="ClassA",
relationship_type=RelationshipType.INHERITS,
source_file=file_b,
target_file=file_a,
source_line=1,
),
]
gsi.update_file_relationships(file_a, relationships_a)
gsi.update_file_relationships(file_b, relationships_b)
return gsi
def test_expand_returns_related_results(temp_dir: Path) -> None:
"""expand() should return related symbols from global relationships."""
gsi = _setup_global_index(temp_dir)
try:
expander = GlobalGraphExpander(gsi)
file_a = str((temp_dir / "pkg_a" / "module_a.py").resolve())
base_results = [
SearchResult(
path=file_a,
score=1.0,
excerpt=None,
content=None,
start_line=1,
end_line=20,
symbol_name="ClassA",
symbol_kind="class",
),
]
related = expander.expand(base_results, top_n=10, max_related=50)
assert len(related) > 0
# All results should have static_graph source metadata
for r in related:
assert r.metadata.get("source") == "static_graph"
# Should find ClassB and/or helper_c as related symbols
related_symbols = {r.symbol_name for r in related}
assert len(related_symbols) > 0
finally:
gsi.close()
def test_score_decay_by_relationship_type(temp_dir: Path) -> None:
"""Score decay factors should be: IMPORTS=0.4, INHERITS=0.5, CALLS=0.3."""
# Verify the constants
assert DECAY_FACTORS["imports"] == 0.4
assert DECAY_FACTORS["inherits"] == 0.5
assert DECAY_FACTORS["calls"] == 0.3
assert DEFAULT_DECAY == 0.3
gsi = _setup_global_index(temp_dir)
try:
expander = GlobalGraphExpander(gsi)
file_a = str((temp_dir / "pkg_a" / "module_a.py").resolve())
base_results = [
SearchResult(
path=file_a,
score=1.0,
excerpt=None,
content=None,
start_line=1,
end_line=20,
symbol_name="ClassA",
symbol_kind="class",
),
]
related = expander.expand(base_results, top_n=10, max_related=50)
# Check that scores use decay factors
for r in related:
rel_type = r.metadata.get("relationship_type")
if rel_type:
expected_decay = DECAY_FACTORS.get(rel_type, DEFAULT_DECAY)
# Score should be base_score * decay (possibly * 0.8 for unresolved)
assert r.score <= 1.0 * expected_decay + 0.01
assert r.score > 0.0
finally:
gsi.close()
def test_expand_with_no_relationships_returns_empty(temp_dir: Path) -> None:
"""expand() should return empty list when no relationships exist."""
db_path = temp_dir / "empty_global.db"
gsi = GlobalSymbolIndex(db_path, project_id=1)
gsi.initialize()
try:
# Add a symbol but no relationships
file_x = str((temp_dir / "isolated.py").resolve())
index_path = str((temp_dir / "idx.db").resolve())
gsi.update_file_symbols(
file_x,
[Symbol(name="IsolatedFunc", kind="function", range=(1, 5), file=file_x)],
index_path=index_path,
)
expander = GlobalGraphExpander(gsi)
base_results = [
SearchResult(
path=file_x,
score=0.9,
excerpt=None,
content=None,
start_line=1,
end_line=5,
symbol_name="IsolatedFunc",
symbol_kind="function",
),
]
related = expander.expand(base_results, top_n=10, max_related=50)
assert related == []
finally:
gsi.close()
def test_expand_deduplicates_against_input(temp_dir: Path) -> None:
"""expand() should not include results already present in input."""
gsi = _setup_global_index(temp_dir)
try:
expander = GlobalGraphExpander(gsi)
file_a = str((temp_dir / "pkg_a" / "module_a.py").resolve())
file_b = str((temp_dir / "pkg_b" / "module_b.py").resolve())
# Include both ClassA and ClassB in input - ClassB should be deduplicated
base_results = [
SearchResult(
path=file_a,
score=1.0,
excerpt=None,
content=None,
start_line=1,
end_line=20,
symbol_name="ClassA",
symbol_kind="class",
),
SearchResult(
path=file_b,
score=0.8,
excerpt=None,
content=None,
start_line=1,
end_line=15,
symbol_name="ClassB",
symbol_kind="class",
),
]
related = expander.expand(base_results, top_n=10, max_related=50)
# No related result should match (path, symbol_name, start_line)
# of any input result
input_keys = {(r.path, r.symbol_name, r.start_line) for r in base_results}
for r in related:
assert (r.path, r.symbol_name, r.start_line) not in input_keys
finally:
gsi.close()
def test_resolve_target_with_double_colon_format(temp_dir: Path) -> None:
"""_resolve_target_to_file should handle 'file_path::symbol_name' format."""
gsi = _setup_global_index(temp_dir)
try:
expander = GlobalGraphExpander(gsi)
file_b = str((temp_dir / "pkg_b" / "module_b.py").resolve())
target_qname = f"{file_b}::ClassB"
result = expander._resolve_target_to_file(target_qname)
assert result is not None
resolved_file, start_line, end_line = result
assert resolved_file == file_b
# ClassB is at range (1, 15)
assert start_line == 1
assert end_line == 15
finally:
gsi.close()
def test_resolve_target_with_dot_notation(temp_dir: Path) -> None:
"""_resolve_target_to_file should handle 'module.ClassName' dot notation."""
gsi = _setup_global_index(temp_dir)
try:
expander = GlobalGraphExpander(gsi)
# "pkg.ClassB" - leaf name "ClassB" should be found via search
result = expander._resolve_target_to_file("pkg.ClassB")
assert result is not None
resolved_file, start_line, end_line = result
# Should resolve to ClassB's file
file_b = str((temp_dir / "pkg_b" / "module_b.py").resolve())
assert resolved_file == file_b
assert start_line == 1
assert end_line == 15
finally:
gsi.close()
def test_expand_empty_results_returns_empty(temp_dir: Path) -> None:
"""expand() with empty input should return empty list."""
db_path = temp_dir / "empty.db"
gsi = GlobalSymbolIndex(db_path, project_id=1)
gsi.initialize()
try:
expander = GlobalGraphExpander(gsi)
assert expander.expand([]) == []
finally:
gsi.close()
def test_expand_results_without_symbol_names_returns_empty(temp_dir: Path) -> None:
"""expand() should skip results without symbol_name."""
db_path = temp_dir / "nosym.db"
gsi = GlobalSymbolIndex(db_path, project_id=1)
gsi.initialize()
try:
expander = GlobalGraphExpander(gsi)
base_results = [
SearchResult(
path="/some/file.py",
score=1.0,
excerpt="some text",
content=None,
start_line=1,
end_line=5,
symbol_name=None,
symbol_kind=None,
),
]
assert expander.expand(base_results) == []
finally:
gsi.close()