From f2cfdb0900e34b5347ec0468373cf4d75756a16b Mon Sep 17 00:00:00 2001 From: vitali87 Date: Mon, 13 Jul 2026 01:22:31 +0400 Subject: [PATCH] fix(resolver): scope registry rehydrate to the indexed project (#711) --- codebase_rag/constants/graph.py | 14 +++- codebase_rag/graph_updater.py | 12 ++- .../test_cross_project_resolution_e2e.py | 81 +++++++++++++++++++ uv.lock | 2 +- 4 files changed, 101 insertions(+), 8 deletions(-) create mode 100644 codebase_rag/tests/integration/test_cross_project_resolution_e2e.py diff --git a/codebase_rag/constants/graph.py b/codebase_rag/constants/graph.py index 8ab1936ac..f6de13ca2 100644 --- a/codebase_rag/constants/graph.py +++ b/codebase_rag/constants/graph.py @@ -336,10 +336,14 @@ class AuditCheck(StrEnum): # (H) Rehydrate the in-memory function registry on an incremental run: returns # (H) every definition node's qualified name and label so call/instantiation -# (H) resolution can see symbols defined in files that were not re-parsed. +# (H) resolution can see symbols defined in files that were not re-parsed. The +# (H) $project_prefix filter scopes it to the project being indexed; without it, +# (H) another project's same-named symbols pollute the resolver trie and the +# (H) bare-name fallback binds calls across the project boundary (issue #711). CYPHER_ALL_DEFINITION_QNS = ( - "MATCH (n) WHERE n:Function OR n:Method OR n:Class OR n:Interface " - "OR n:Enum OR n:Type OR n:Union " + "MATCH (n) WHERE (n:Function OR n:Method OR n:Class OR n:Interface " + "OR n:Enum OR n:Type OR n:Union) " + "AND n.qualified_name STARTS WITH $project_prefix " "RETURN n.qualified_name AS qualified_name, head(labels(n)) AS label, " "n.is_property AS is_property, n.is_macro AS is_macro, n.path AS path, " "n.start_line AS start_line, n.end_line AS end_line" @@ -349,7 +353,8 @@ class AuditCheck(StrEnum): # (H) deferred import verification must count modules in UNCHANGED files as # (H) real targets, or editing one file would drop cross-file IMPORTS edges. CYPHER_ALL_MODULE_QNS = ( - "MATCH (n) WHERE n:Module OR n:ModuleInterface " + "MATCH (n) WHERE (n:Module OR n:ModuleInterface) " + "AND n.qualified_name STARTS WITH $project_prefix " "RETURN n.qualified_name AS qualified_name, head(labels(n)) AS label" ) @@ -378,6 +383,7 @@ class AuditCheck(StrEnum): CYPHER_ALL_INHERITS = ( "MATCH (child)-[r:INHERITS]->(base) " "WHERE child.qualified_name IS NOT NULL AND base.qualified_name IS NOT NULL " + "AND child.qualified_name STARTS WITH $project_prefix " "RETURN child.qualified_name AS child_qn, base.qualified_name AS base_qn, " "r.base_index AS base_index " "ORDER BY child_qn, base_index" diff --git a/codebase_rag/graph_updater.py b/codebase_rag/graph_updater.py index fe59fd383..aec604dce 100644 --- a/codebase_rag/graph_updater.py +++ b/codebase_rag/graph_updater.py @@ -875,7 +875,10 @@ def _rehydrate_registry_from_graph(self) -> None: if not isinstance(self.ingestor, QueryProtocol): return added = 0 - for row in self.ingestor.fetch_all(cs.CYPHER_ALL_DEFINITION_QNS): + project_params = {cs.KEY_PROJECT_PREFIX: self.project_name + "."} + for row in self.ingestor.fetch_all( + cs.CYPHER_ALL_DEFINITION_QNS, project_params + ): qn = row.get(cs.KEY_QUALIFIED_NAME) label = row.get(cs.KEY_LABEL) if not isinstance(qn, str) or not isinstance(label, str): @@ -921,7 +924,7 @@ def _rehydrate_registry_from_graph(self) -> None: # (H) Module qns from unchanged files: deferred import verification and # (H) C++20 module-impl resolution must count them as real targets, or # (H) an incremental run would drop edges a clean index emits. - for row in self.ingestor.fetch_all(cs.CYPHER_ALL_MODULE_QNS): + for row in self.ingestor.fetch_all(cs.CYPHER_ALL_MODULE_QNS, project_params): qn = row.get(cs.KEY_QUALIFIED_NAME) label = row.get(cs.KEY_LABEL) if not isinstance(qn, str) or not isinstance(label, str): @@ -945,7 +948,10 @@ def _rehydrate_class_inheritance_from_graph(self) -> None: if not isinstance(self.ingestor, QueryProtocol): return class_inheritance = self.factory.definition_processor.class_inheritance - rows = self.ingestor.fetch_all(cs.CYPHER_ALL_INHERITS) + rows = self.ingestor.fetch_all( + cs.CYPHER_ALL_INHERITS, + {cs.KEY_PROJECT_PREFIX: self.project_name + "."}, + ) for child, bases in self._rehydrated_bases_by_child( rows, class_inheritance ).items(): diff --git a/codebase_rag/tests/integration/test_cross_project_resolution_e2e.py b/codebase_rag/tests/integration/test_cross_project_resolution_e2e.py new file mode 100644 index 000000000..eb268279c --- /dev/null +++ b/codebase_rag/tests/integration/test_cross_project_resolution_e2e.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest + +from codebase_rag import constants as cs +from codebase_rag.capture import resolve_capture +from codebase_rag.graph_updater import GraphUpdater +from codebase_rag.parser_loader import load_parsers + +if TYPE_CHECKING: + from codebase_rag.services.graph_service import MemgraphIngestor + +pytestmark = [pytest.mark.integration] + +# (H) issue #711: two projects sharing one database. `collide` defines the bare +# (H) names `len` / `getenv`; `caller` makes unqualified calls on a tainted value. +# (H) Indexing `caller` after `collide` (no --clean) rehydrates the resolver trie +# (H) from the whole DB, so the bare-name fallback must NOT bind into `collide`. +COLLIDE_CODE = """\ +def len(x): + return 0 + + +def getenv(k): + return None +""" + +CALLER_CODE = """\ +import os + + +def leak(): + t = os.getenv("SECRET") + n = len(t) + return n +""" + + +def _index(ingestor: MemgraphIngestor, project: Path, *, io: bool) -> None: + parsers, queries = load_parsers() + capture = resolve_capture([cs.CaptureGroup.IO.value]) if io else None + GraphUpdater( + ingestor=ingestor, + repo_path=project, + parsers=parsers, + queries=queries, + capture=capture, + ).run() + + +def _cross_project_edges(ingestor: MemgraphIngestor) -> list[dict[str, str]]: + rows = ingestor.fetch_all( + "MATCH (a)-[r]->(b) " + "WHERE a.qualified_name STARTS WITH 'caller.' " + "AND b.qualified_name STARTS WITH 'collide.' " + "RETURN type(r) AS rel, a.qualified_name AS frm, b.qualified_name AS to" + ) + return [{k: str(v) for k, v in row.items()} for row in rows] + + +def test_bare_name_calls_do_not_leak_into_another_project( + memgraph_ingestor: MemgraphIngestor, tmp_path: Path +) -> None: + collide = tmp_path / "collide" + collide.mkdir() + (collide / "mod.py").write_text(COLLIDE_CODE, encoding="utf-8") + + caller = tmp_path / "caller" + caller.mkdir() + (caller / "app.py").write_text(CALLER_CODE, encoding="utf-8") + + # (H) Index the collider first so its symbols are already in the DB when the + # (H) caller's run rehydrates the registry from the graph. + _index(memgraph_ingestor, collide, io=True) + _index(memgraph_ingestor, caller, io=True) + + leaks = _cross_project_edges(memgraph_ingestor) + assert leaks == [], f"cross-project edges leaked from caller into collide: {leaks}" diff --git a/uv.lock b/uv.lock index e890876c6..8c538ba6d 100644 --- a/uv.lock +++ b/uv.lock @@ -534,7 +534,7 @@ wheels = [ [[package]] name = "code-graph-rag" -version = "0.0.315" +version = "0.0.316" source = { editable = "." } dependencies = [ { name = "click" },