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
14 changes: 10 additions & 4 deletions codebase_rag/constants/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
)

Expand Down Expand Up @@ -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"
Expand Down
12 changes: 9 additions & 3 deletions codebase_rag/graph_updater.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand All @@ -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():
Expand Down
Original file line number Diff line number Diff line change
@@ -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}"
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading