Skip to content

Commit de41937

Browse files
committed
fix(neo4j): scope bolt full-run orphan prune to the application anchor
Closes #45 The full-run prune deleted any :PyModule whose file_key was not in the current emit across the ENTIRE database -- not just the application being written -- so a full-run push for application B wiped application A's modules, leaving an orphaned :PyApplication with zero PY_HAS_MODULE edges. A single Neo4j database therefore could not hold multiple applications via full-run --emit neo4j. Anchor the prune to the :PyApplication {name} being emitted (MATCH (:PyApplication {name:$app})-[:PY_HAS_MODULE]->(m:PyModule) WHERE NOT m.file_key IN $present ...), so it only removes that application's vanished modules. Adds a container regression test (app-b push leaves app-a intact).
1 parent df0eae9 commit de41937

2 files changed

Lines changed: 43 additions & 2 deletions

File tree

codeanalyzer/neo4j/bolt.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,13 @@ def session():
7777
for stmt in [*CONSTRAINTS, *INDEXES]:
7878
s.run(stmt)
7979

80+
# The application anchor (a shared node) — used to scope the orphan prune
81+
# so it never touches modules belonging to a different :PyApplication.
82+
app_name = next(
83+
(n.value for n in rows.nodes if n.labels and n.labels[0] == "PyApplication"),
84+
None,
85+
)
86+
8087
# Partition nodes by owning module; shared nodes have no _module.
8188
by_module: Dict[str, List[NodeRow]] = {}
8289
shared: List[NodeRow] = []
@@ -135,13 +142,17 @@ def _purge(tx, module=m, node_keys=keys):
135142
_upsert_edges(session, neo4j, edges)
136143

137144
# 6. orphan prune — only safe on a full run (a targeted run can't tell deleted from untargeted).
138-
if full_run:
145+
# Scope to THIS application's anchor so a full run for application B never
146+
# deletes application A's modules from a shared database.
147+
if full_run and app_name is not None:
139148
present = list(by_module.keys())
140149
with session() as s:
141150
res = s.run(
142-
"MATCH (m:PyModule) WHERE NOT m.file_key IN $present "
151+
"MATCH (:PyApplication {name: $app})-[:PY_HAS_MODULE]->(m:PyModule) "
152+
"WHERE NOT m.file_key IN $present "
143153
f"OPTIONAL MATCH (m)-{DESCENDANTS}->(x) DETACH DELETE x, m "
144154
"RETURN count(m) AS pruned",
155+
app=app_name,
145156
present=present,
146157
)
147158
pruned = res.single()

test/test_neo4j_bolt.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,24 @@
1515

1616
from codeanalyzer.neo4j import project
1717
from codeanalyzer.neo4j.bolt import BoltConfig, bolt_writer
18+
from codeanalyzer.schema import PyApplication, PyCallable, PyModule
1819

1920
from sample_graph_app import make_sample_app
2021

22+
23+
def _single_module_app(file_key: str = "appb/main.py") -> PyApplication:
24+
"""A minimal second application with its own (distinct) module file_key."""
25+
fn = PyCallable(
26+
name="main", path=file_key, signature="appb.main", return_type="None",
27+
code="def main():\n ...", start_line=1, end_line=2,
28+
code_start_line=1, cyclomatic_complexity=1,
29+
)
30+
mod = PyModule(
31+
file_path=file_key, module_name="appb.main", functions={"main": fn},
32+
content_hash="h-b", last_modified=1.0, file_size=10,
33+
)
34+
return PyApplication(symbol_table={file_key: mod}, call_graph=[])
35+
2136
pytestmark = pytest.mark.skipif(
2237
not os.environ.get("RUN_CONTAINER_TESTS"),
2338
reason="opt-in: set RUN_CONTAINER_TESTS=1 (needs Docker/Podman) to run the Neo4j bolt test",
@@ -105,6 +120,21 @@ def test_full_push_materializes_the_whole_graph_and_schema(driver, cfg):
105120
assert _num(driver, "MATCH (e:PyExternal) RETURN count(e)") >= 1
106121

107122

123+
def test_full_run_does_not_prune_another_applications_modules(driver, cfg):
124+
"""Regression for #45: a full-run push for one application must not prune the
125+
modules of a *different* application sharing the database."""
126+
bolt_writer(project(make_sample_app(), "app-a"), cfg, full_run=True)
127+
before = _num(driver, "MATCH (:PyApplication {name:'app-a'})-[:PY_HAS_MODULE]->(m) RETURN count(m)")
128+
assert before > 0
129+
130+
# A full-run push for a different application must leave app-a untouched.
131+
bolt_writer(project(_single_module_app(), "app-b"), cfg, full_run=True)
132+
133+
after = _num(driver, "MATCH (:PyApplication {name:'app-a'})-[:PY_HAS_MODULE]->(m) RETURN count(m)")
134+
assert after == before, "full-run push for app-b pruned app-a's modules (#45)"
135+
assert _num(driver, "MATCH (:PyApplication {name:'app-b'})-[:PY_HAS_MODULE]->(m) RETURN count(m)") == 1
136+
137+
108138
def test_re_pushing_identical_analysis_is_idempotent(driver, cfg):
109139
rows = project(make_sample_app(), "sample-app")
110140
bolt_writer(rows, cfg, full_run=True)

0 commit comments

Comments
 (0)