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
49 changes: 49 additions & 0 deletions src/codegraphcontext/tools/indexing/persistence/writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -1833,6 +1833,55 @@ def _work(session):
warning_logger(f"Failed to link C++ methods for label {clab}: {e}")

execute_write_operation(self.driver, backend, _work)

def repair_missing_contains_links(self, repo_path_str: str) -> Dict[str, int]:
"""Post-index invariant: every symbol under the repo must be reachable
from its ``File`` through ``CONTAINS``.

The per-file linking MERGE keys on the ``occurrence_index`` property
(#1393). On FalkorDB Lite an index created while parallel workers are
still writing can come up operational but empty, so those MATCHes
silently match zero rows: the symbols persist, but not one
``File-[:CONTAINS]`` edge does, and the scoped ``cgc stats`` counters
(which traverse ``Repository-[:CONTAINS*]->Function``) report 0.
Detect the label-wise all-unlinked state, warn, and back-fill the
edges from the already-stored ``path`` property. Partially linked
labels are left alone — that indicates a different, per-file defect.
"""
repo_path_str = _normalize_path(repo_path_str)
backend = get_backend_type(self.driver, self._db_manager)
repaired: Dict[str, int] = {}

def _work(session):
for label in ("Function", "Class", "Variable"):
symbols = session.run(
f"MATCH (n:{label}) WHERE n.path STARTS WITH $repo_path "
"RETURN count(n) AS c",
repo_path=repo_path_str,
).single()["c"]
linked = session.run(
f"MATCH (f:File)-[:CONTAINS]->(n:{label}) "
"WHERE f.path STARTS WITH $repo_path RETURN count(n) AS c",
repo_path=repo_path_str,
).single()["c"]
if symbols and not linked:
warning_logger(
f"[INVARIANT] {symbols} {label} node(s) have no "
"File-[:CONTAINS] edge — the per-file linking pass "
"matched nothing (known FalkorDB Lite empty-index "
"race). Back-filling from node paths."
)
session.run(
f"MATCH (f:File) WHERE f.path STARTS WITH $repo_path "
f"MATCH (n:{label}) WHERE n.path = f.path "
"MERGE (f)-[:CONTAINS]->(n)",
repo_path=repo_path_str,
)
repaired[label] = symbols
return repaired

return execute_write_operation(self.driver, backend, _work)

def write_spring_inject_links(self, inject_batch: List[Dict[str, Any]]) -> None:
"""Create INJECTS edges: injector Class -> injected Class (via @Autowired / @Inject)."""
if not inject_batch:
Expand Down
9 changes: 9 additions & 0 deletions src/codegraphcontext/tools/indexing/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,15 @@ async def process_file(file: Path) -> Optional[Dict[str, Any]]:
info_logger("[CPP] Linking C++ out-of-line method definitions to their classes...")
writer.write_cpp_class_function_links(resolved_repo_path_str)

# ── File->CONTAINS invariant (empty-index race self-heal) ────────────────
if job_id:
job_manager.update_job(job_id, status_message="Verifying File->CONTAINS linking...")
repaired_contains = writer.repair_missing_contains_links(resolved_repo_path_str)
if repaired_contains:
warning_logger(
f"[INVARIANT] Back-filled File-[:CONTAINS] edges for: {repaired_contains}"
)

# ── Spring injection edges (#887) ─────────────────────────────────────────
if job_id:
job_manager.update_job(job_id, status_message="Processing Spring injection edges...")
Expand Down
125 changes: 125 additions & 0 deletions tests/unit/tools/test_contains_invariant_repair.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
"""
Unit tests for the post-index File->CONTAINS invariant check and repair.

Regression target: on FalkorDB Lite, a property index created while parallel
workers are still writing can end up empty but operational. Every Cypher
predicate routed through that index then silently matches zero rows, which
made the per-file `MATCH (f:File) MATCH (n:Function {...occurrence_index...})
MERGE (f)-[:CONTAINS]->(n)` linking pass write nothing — while node creation
itself succeeded. `cgc stats <repo>` reported 0 functions/classes because the
scoped counters traverse Repository-[:CONTAINS*]->Function.

The fix adds GraphWriter.repair_missing_contains_links(repo_path): after
indexing, if a label has symbol nodes under the repo prefix but zero
File-[:CONTAINS]->symbol edges, warn and back-fill the edges from the
already-stored `path` property.
"""

from tests.unit.tools.test_graph_builder_perf_fixes import (
_FakeDBManager,
_FakeDriver,
_FakeResult,
_RecordingSession,
)


def _make_writer(responses=None):
from codegraphcontext.tools.indexing.persistence.writer import GraphWriter

session = _RecordingSession(responses=responses or [])
driver = _FakeDriver(session)
writer = GraphWriter(driver, db_manager=_FakeDBManager(driver, backend="falkordb"))
return writer, session


def _count(n):
return _FakeResult([{"c": n}])


class TestRepairMissingContainsLinks:
"""Invariant: symbols stored under the repo must be CONTAINS-linked to their File."""

def _violation_responses(self):
# Per label: symbols count, linked count, backfill result.
return [
_count(12), _count(0), _FakeResult(), # Function: 12 nodes, 0 links
_count(3), _count(0), _FakeResult(), # Class: violation
_count(40), _count(0), _FakeResult(), # Variable: violation
]

def test_backfills_every_label_that_is_unlinked(self):
writer, session = _make_writer(self._violation_responses())
repaired = writer.repair_missing_contains_links("/repo")

backfills = [
c for c in session.calls
if "MERGE (f)-[:CONTAINS]->(n)" in c["query"]
]
assert len(backfills) == 3, "Expected one back-fill MERGE per violating label"
for label in ("Function", "Class", "Variable"):
assert any(c["query"].count(f":{label}") >= 1 for c in backfills), (
f"Expected a back-fill query touching {label}"
)
assert repaired == {"Function": 12, "Class": 3, "Variable": 40}

def test_backfill_is_path_scoped_and_pairwise(self):
"""The repair must join File and symbol on the stored path property,
scoped to the repository prefix — not link symbols across repos."""
writer, session = _make_writer(self._violation_responses())
writer.repair_missing_contains_links("/repo")

backfill = next(
c for c in session.calls if "MERGE (f)-[:CONTAINS]->(n)" in c["query"]
)
assert "STARTS WITH $repo_path" in backfill["query"]
assert "n.path = f.path" in backfill["query"]
assert backfill["kwargs"]["repo_path"].rstrip("/") == "/repo"

def test_healthy_graph_writes_nothing(self):
responses = [
_count(12), _count(12), # Function: fully linked
_count(3), _count(3), # Class: fully linked
_count(40), _count(40), # Variable: fully linked
]
writer, session = _make_writer(responses)
repaired = writer.repair_missing_contains_links("/repo")

assert not any("MERGE" in c["query"] for c in session.calls)
assert repaired == {}

def test_empty_repo_is_a_noop(self):
responses = [_count(0)] * 6
writer, session = _make_writer(responses)
repaired = writer.repair_missing_contains_links("/repo")

assert not any("MERGE" in c["query"] for c in session.calls)
assert repaired == {}

def test_partial_linking_is_left_alone(self):
"""If any File->CONTAINS edge exists for a label the linking pass ran
at least partly; blanket back-fill would mask a different bug."""
responses = [
_count(12), _count(5), # Function: partially linked
_count(3), _count(3),
_count(40), _count(40),
]
writer, session = _make_writer(responses)
repaired = writer.repair_missing_contains_links("/repo")

assert not any("MERGE (f)-[:CONTAINS]->(n)" in c["query"] for c in session.calls)
assert repaired == {}


class TestPipelineWiresRepair:
"""The post-processing phase must actually invoke the invariant check."""

def test_pipeline_calls_repair_after_linking_passes(self):
import inspect

from codegraphcontext.tools.indexing import pipeline

source = inspect.getsource(pipeline)
assert "repair_missing_contains_links" in source, (
"pipeline.py must run the File->CONTAINS invariant check after "
"the post-processing linking passes"
)
Loading