diff --git a/build_ast_graph.py b/build_ast_graph.py index 8e798899..8760a904 100644 --- a/build_ast_graph.py +++ b/build_ast_graph.py @@ -2509,6 +2509,229 @@ def _drop_all(conn: kuzu.Connection) -> None: pass +# --------------------------------------------------------------------------- +# Symmetric delete helpers (PR-T2) +# --------------------------------------------------------------------------- + +def _del_count( + conn: kuzu.Connection, count_q: str, del_q: str, fp: str +) -> int: + r = conn.execute(count_q, {"fp": fp}) + n = int(r.get_next()[0]) if r.has_next() else 0 + if n > 0: + conn.execute(del_q, {"fp": fp}) + return n + + +def delete_symbols_for_file(conn: kuzu.Connection, file_path: str) -> int: + for edge in ( + "DECLARES", "EXTENDS", "IMPLEMENTS", "INJECTS", "CALLS", "OVERRIDES", + ): + conn.execute( + f"MATCH (a:Symbol)-[e:{edge}]->(b:Symbol) " + "WHERE a.filename = $fp OR b.filename = $fp DELETE e", + {"fp": file_path}, + ) + conn.execute( + "MATCH (s:Symbol)-[e:UNRESOLVED_AT]->(u:UnresolvedCallSite) " + "WHERE s.filename = $fp DELETE e", + {"fp": file_path}, + ) + conn.execute( + "MATCH (s:Symbol), (u:UnresolvedCallSite) " + "WHERE s.filename = $fp AND u.caller_id = s.id DELETE u", + {"fp": file_path}, + ) + conn.execute( + "MATCH (s:Symbol)-[e:EXPOSES]->(r:Route) " + "WHERE s.filename = $fp DELETE e", + {"fp": file_path}, + ) + conn.execute( + "MATCH (s:Symbol)-[e:DECLARES_CLIENT]->(c:Client) " + "WHERE s.filename = $fp DELETE e", + {"fp": file_path}, + ) + conn.execute( + "MATCH (s:Symbol)-[e:DECLARES_PRODUCER]->(p:Producer) " + "WHERE s.filename = $fp DELETE e", + {"fp": file_path}, + ) + return _del_count( + conn, + "MATCH (s:Symbol) WHERE s.filename = $fp RETURN count(s) AS n", + "MATCH (s:Symbol) WHERE s.filename = $fp DELETE s", + file_path, + ) + + +def delete_extends_for_file(conn: kuzu.Connection, file_path: str) -> int: + return _del_count( + conn, + "MATCH (a:Symbol)-[e:EXTENDS]->(b:Symbol) " + "WHERE a.filename = $fp RETURN count(e) AS n", + "MATCH (a:Symbol)-[e:EXTENDS]->(b:Symbol) " + "WHERE a.filename = $fp DELETE e", + file_path, + ) + + +def delete_implements_for_file(conn: kuzu.Connection, file_path: str) -> int: + return _del_count( + conn, + "MATCH (a:Symbol)-[e:IMPLEMENTS]->(b:Symbol) " + "WHERE a.filename = $fp RETURN count(e) AS n", + "MATCH (a:Symbol)-[e:IMPLEMENTS]->(b:Symbol) " + "WHERE a.filename = $fp DELETE e", + file_path, + ) + + +def delete_injects_for_file(conn: kuzu.Connection, file_path: str) -> int: + return _del_count( + conn, + "MATCH (a:Symbol)-[e:INJECTS]->(b:Symbol) " + "WHERE a.filename = $fp RETURN count(e) AS n", + "MATCH (a:Symbol)-[e:INJECTS]->(b:Symbol) " + "WHERE a.filename = $fp DELETE e", + file_path, + ) + + +def delete_calls_for_file(conn: kuzu.Connection, file_path: str) -> int: + conn.execute( + "MATCH (s:Symbol)-[e:UNRESOLVED_AT]->(u:UnresolvedCallSite) " + "WHERE s.filename = $fp DELETE e", + {"fp": file_path}, + ) + conn.execute( + "MATCH (s:Symbol), (u:UnresolvedCallSite) " + "WHERE s.filename = $fp AND u.caller_id = s.id DELETE u", + {"fp": file_path}, + ) + return _del_count( + conn, + "MATCH (a:Symbol)-[e:CALLS]->(b:Symbol) " + "WHERE a.filename = $fp RETURN count(e) AS n", + "MATCH (a:Symbol)-[e:CALLS]->(b:Symbol) " + "WHERE a.filename = $fp DELETE e", + file_path, + ) + + +def delete_routes_for_file(conn: kuzu.Connection, file_path: str) -> int: + conn.execute( + "MATCH (s:Symbol)-[e:EXPOSES]->(r:Route) " + "WHERE r.filename = $fp DELETE e", + {"fp": file_path}, + ) + conn.execute( + "MATCH (c:Client)-[e:HTTP_CALLS]->(r:Route) " + "WHERE r.filename = $fp DELETE e", + {"fp": file_path}, + ) + conn.execute( + "MATCH (p:Producer)-[e:ASYNC_CALLS]->(r:Route) " + "WHERE r.filename = $fp DELETE e", + {"fp": file_path}, + ) + return _del_count( + conn, + "MATCH (r:Route) WHERE r.filename = $fp RETURN count(r) AS n", + "MATCH (r:Route) WHERE r.filename = $fp DELETE r", + file_path, + ) + + +def delete_clients_for_file(conn: kuzu.Connection, file_path: str) -> int: + conn.execute( + "MATCH (s:Symbol)-[e:DECLARES_CLIENT]->(c:Client) " + "WHERE c.filename = $fp DELETE e", + {"fp": file_path}, + ) + conn.execute( + "MATCH (c:Client)-[e:HTTP_CALLS]->(r:Route) " + "WHERE c.filename = $fp DELETE e", + {"fp": file_path}, + ) + return _del_count( + conn, + "MATCH (c:Client) WHERE c.filename = $fp RETURN count(c) AS n", + "MATCH (c:Client) WHERE c.filename = $fp DELETE c", + file_path, + ) + + +def delete_producers_for_file(conn: kuzu.Connection, file_path: str) -> int: + conn.execute( + "MATCH (s:Symbol)-[e:DECLARES_PRODUCER]->(p:Producer) " + "WHERE p.filename = $fp DELETE e", + {"fp": file_path}, + ) + conn.execute( + "MATCH (p:Producer)-[e:ASYNC_CALLS]->(r:Route) " + "WHERE p.filename = $fp DELETE e", + {"fp": file_path}, + ) + return _del_count( + conn, + "MATCH (p:Producer) WHERE p.filename = $fp RETURN count(p) AS n", + "MATCH (p:Producer) WHERE p.filename = $fp DELETE p", + file_path, + ) + + +def delete_http_calls_for_file(conn: kuzu.Connection, file_path: str) -> int: + return _del_count( + conn, + "MATCH (c:Client)-[e:HTTP_CALLS]->(r:Route) " + "WHERE c.filename = $fp RETURN count(e) AS n", + "MATCH (c:Client)-[e:HTTP_CALLS]->(r:Route) " + "WHERE c.filename = $fp DELETE e", + file_path, + ) + + +def delete_async_calls_for_file(conn: kuzu.Connection, file_path: str) -> int: + return _del_count( + conn, + "MATCH (p:Producer)-[e:ASYNC_CALLS]->(r:Route) " + "WHERE p.filename = $fp RETURN count(e) AS n", + "MATCH (p:Producer)-[e:ASYNC_CALLS]->(r:Route) " + "WHERE p.filename = $fp DELETE e", + file_path, + ) + + +def delete_overrides_for_file(conn: kuzu.Connection, file_path: str) -> int: + return _del_count( + conn, + "MATCH (a:Symbol)-[e:OVERRIDES]->(b:Symbol) " + "WHERE a.filename = $fp RETURN count(e) AS n", + "MATCH (a:Symbol)-[e:OVERRIDES]->(b:Symbol) " + "WHERE a.filename = $fp DELETE e", + file_path, + ) + + +def delete_all_for_file( + conn: kuzu.Connection, file_path: str +) -> dict[str, int]: + return { + "http_calls": delete_http_calls_for_file(conn, file_path), + "async_calls": delete_async_calls_for_file(conn, file_path), + "routes": delete_routes_for_file(conn, file_path), + "clients": delete_clients_for_file(conn, file_path), + "producers": delete_producers_for_file(conn, file_path), + "calls": delete_calls_for_file(conn, file_path), + "extends": delete_extends_for_file(conn, file_path), + "implements": delete_implements_for_file(conn, file_path), + "injects": delete_injects_for_file(conn, file_path), + "overrides": delete_overrides_for_file(conn, file_path), + "symbols": delete_symbols_for_file(conn, file_path), + } + + def _create_schema(conn: kuzu.Connection) -> None: for stmt in ( _SCHEMA_NODE, diff --git a/plans/active/PLAN-TIER2-INCREMENTAL-REBUILD.md b/plans/active/PLAN-TIER2-INCREMENTAL-REBUILD.md index 5c59a8b0..a88f8633 100644 --- a/plans/active/PLAN-TIER2-INCREMENTAL-REBUILD.md +++ b/plans/active/PLAN-TIER2-INCREMENTAL-REBUILD.md @@ -199,19 +199,25 @@ Landing order: **T1 -> T2 -> T3 -> T4**. PR-T5 is optional and may follow. ## Tests for PR-T2 1. `test_delete_symbols_for_file` -2. `test_delete_extends_for_file` -3. `test_delete_implements_for_file` -4. `test_delete_injects_for_file` -5. `test_delete_calls_for_file` -6. `test_delete_routes_for_file` -7. `test_delete_clients_for_file` -8. `test_delete_producers_for_file` -9. `test_delete_http_calls_for_file` -10. `test_delete_async_calls_for_file` -11. `test_delete_overrides_for_file` -12. `test_delete_all_for_file` -13. `test_delete_idempotent` -14. `test_delete_unknown_file_returns_zero` +2. `test_deletes_declares_edges` +3. `test_delete_extends_for_file` +4. `test_delete_implements_for_file` +5. `test_delete_injects_for_file` +6. `test_delete_calls_for_file` +7. `test_deletes_unresolved_call_sites` +8. `test_delete_routes_for_file` +9. `test_deletes_exposes_edges` +10. `test_delete_clients_for_file` +11. `test_deletes_declares_client_edges` +12. `test_delete_producers_for_file` +13. `test_deletes_declares_producer_edges` +14. `test_delete_http_calls_for_file` +15. `test_delete_async_calls_for_file` +16. `test_delete_overrides_for_file` +17. `test_delete_all_for_file` +18. `test_calls_each_helper` +19. `test_delete_idempotent` +20. `test_delete_unknown_file_returns_zero` ## Definition of done (PR-T2) - All delete helpers implemented and unit-tested in isolation. @@ -536,8 +542,8 @@ Landing order: **T1 -> T2 -> T3 -> T4**. PR-T5 is optional and may follow. # Tracking -- `PR-T1`: _pending_ -- `PR-T2`: _pending_ +- `PR-T1`: _done_ +- `PR-T2`: _done_ - `PR-T3`: _pending_ - `PR-T4`: _pending_ - `PR-T5`: _deferred_ diff --git a/tests/test_symmetric_delete.py b/tests/test_symmetric_delete.py new file mode 100644 index 00000000..2e9e50be --- /dev/null +++ b/tests/test_symmetric_delete.py @@ -0,0 +1,509 @@ +"""Tests for symmetric delete helpers (PR-T2). + +Each delete helper takes ``(conn, file_path)`` and returns the deleted row +count. The helpers are additive — nothing calls them yet (that's PR-T3). +""" + +from __future__ import annotations + +import kuzu +import pytest + +from _builders import build_kuzu_to + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def conn(tmp_path, corpus_root): + """Per-test fresh writeable Kuzu DB from bank-chat-system (pass1-5).""" + db_path = tmp_path / "code_graph.kuzu" + build_kuzu_to(corpus_root, db_path, max_pass=5) + db = kuzu.Database(str(db_path)) + return kuzu.Connection(db) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _count(conn, cypher, params=None): + r = conn.execute(cypher, params or {}) + return int(r.get_next()[0]) if r.has_next() else 0 + + +def _find_file_with(conn, cypher): + r = conn.execute(cypher) + assert r.has_next(), f"no matching rows in fixture: {cypher}" + return str(r.get_next()[0]) + + +# --------------------------------------------------------------------------- +# Per-helper tests +# --------------------------------------------------------------------------- + +class TestDeleteSymbolsForFile: + def test_delete_symbols_for_file(self, conn): + from build_ast_graph import delete_symbols_for_file + + fp = _find_file_with( + conn, + "MATCH (s:Symbol) WHERE s.kind = 'class' " + "RETURN s.filename AS fn LIMIT 1", + ) + before = _count( + conn, + "MATCH (s:Symbol) WHERE s.filename = $fp RETURN count(s) AS n", + {"fp": fp}, + ) + assert before > 0 + + n = delete_symbols_for_file(conn, fp) + assert n > 0 + + after = _count( + conn, + "MATCH (s:Symbol) WHERE s.filename = $fp RETURN count(s) AS n", + {"fp": fp}, + ) + assert after == 0 + + def test_deletes_declares_edges(self, conn): + from build_ast_graph import delete_symbols_for_file + + fp = _find_file_with( + conn, + "MATCH (a:Symbol)-[:DECLARES]->(b:Symbol) " + "RETURN DISTINCT a.filename AS fn LIMIT 1", + ) + delete_symbols_for_file(conn, fp) + + remaining = _count( + conn, + "MATCH (a:Symbol)-[e:DECLARES]->(b:Symbol) " + "WHERE a.filename = $fp OR b.filename = $fp " + "RETURN count(e) AS n", + {"fp": fp}, + ) + assert remaining == 0 + + +class TestDeleteExtendsForFile: + def test_delete_extends_for_file(self, conn): + from build_ast_graph import delete_extends_for_file + + fp = _find_file_with( + conn, + "MATCH (a:Symbol)-[:EXTENDS]->(b:Symbol) " + "RETURN DISTINCT a.filename AS fn LIMIT 1", + ) + before = _count( + conn, + "MATCH (a:Symbol)-[e:EXTENDS]->(b:Symbol) " + "WHERE a.filename = $fp RETURN count(e) AS n", + {"fp": fp}, + ) + assert before > 0 + + n = delete_extends_for_file(conn, fp) + assert n == before + + after = _count( + conn, + "MATCH (a:Symbol)-[e:EXTENDS]->(b:Symbol) " + "WHERE a.filename = $fp RETURN count(e) AS n", + {"fp": fp}, + ) + assert after == 0 + + +class TestDeleteImplementsForFile: + def test_delete_implements_for_file(self, conn): + from build_ast_graph import delete_implements_for_file + + fp = _find_file_with( + conn, + "MATCH (a:Symbol)-[:IMPLEMENTS]->(b:Symbol) " + "RETURN DISTINCT a.filename AS fn LIMIT 1", + ) + before = _count( + conn, + "MATCH (a:Symbol)-[e:IMPLEMENTS]->(b:Symbol) " + "WHERE a.filename = $fp RETURN count(e) AS n", + {"fp": fp}, + ) + assert before > 0 + + n = delete_implements_for_file(conn, fp) + assert n == before + + after = _count( + conn, + "MATCH (a:Symbol)-[e:IMPLEMENTS]->(b:Symbol) " + "WHERE a.filename = $fp RETURN count(e) AS n", + {"fp": fp}, + ) + assert after == 0 + + +class TestDeleteInjectsForFile: + def test_delete_injects_for_file(self, conn): + from build_ast_graph import delete_injects_for_file + + fp = _find_file_with( + conn, + "MATCH (a:Symbol)-[:INJECTS]->(b:Symbol) " + "RETURN DISTINCT a.filename AS fn LIMIT 1", + ) + before = _count( + conn, + "MATCH (a:Symbol)-[e:INJECTS]->(b:Symbol) " + "WHERE a.filename = $fp RETURN count(e) AS n", + {"fp": fp}, + ) + assert before > 0 + + n = delete_injects_for_file(conn, fp) + assert n == before + + after = _count( + conn, + "MATCH (a:Symbol)-[e:INJECTS]->(b:Symbol) " + "WHERE a.filename = $fp RETURN count(e) AS n", + {"fp": fp}, + ) + assert after == 0 + + +class TestDeleteCallsForFile: + def test_delete_calls_for_file(self, conn): + from build_ast_graph import delete_calls_for_file + + fp = _find_file_with( + conn, + "MATCH (a:Symbol)-[:CALLS]->(b:Symbol) " + "RETURN DISTINCT a.filename AS fn LIMIT 1", + ) + before = _count( + conn, + "MATCH (a:Symbol)-[e:CALLS]->(b:Symbol) " + "WHERE a.filename = $fp RETURN count(e) AS n", + {"fp": fp}, + ) + assert before > 0 + + n = delete_calls_for_file(conn, fp) + assert n == before + + after = _count( + conn, + "MATCH (a:Symbol)-[e:CALLS]->(b:Symbol) " + "WHERE a.filename = $fp RETURN count(e) AS n", + {"fp": fp}, + ) + assert after == 0 + + def test_deletes_unresolved_call_sites(self, conn): + from build_ast_graph import delete_calls_for_file + + fp = _find_file_with( + conn, + "MATCH (a:Symbol)-[:UNRESOLVED_AT]->(u:UnresolvedCallSite) " + "RETURN DISTINCT a.filename AS fn LIMIT 1", + ) + delete_calls_for_file(conn, fp) + + remaining = _count( + conn, + "MATCH (a:Symbol)-[e:UNRESOLVED_AT]->(u:UnresolvedCallSite) " + "WHERE a.filename = $fp RETURN count(e) AS n", + {"fp": fp}, + ) + assert remaining == 0 + + +class TestDeleteRoutesForFile: + def test_delete_routes_for_file(self, conn): + from build_ast_graph import delete_routes_for_file + + fp = _find_file_with( + conn, + "MATCH (r:Route) RETURN r.filename AS fn LIMIT 1", + ) + before = _count( + conn, + "MATCH (r:Route) WHERE r.filename = $fp RETURN count(r) AS n", + {"fp": fp}, + ) + assert before > 0 + + n = delete_routes_for_file(conn, fp) + assert n == before + + after = _count( + conn, + "MATCH (r:Route) WHERE r.filename = $fp RETURN count(r) AS n", + {"fp": fp}, + ) + assert after == 0 + + def test_deletes_exposes_edges(self, conn): + from build_ast_graph import delete_routes_for_file + + fp = _find_file_with( + conn, + "MATCH (a:Symbol)-[:EXPOSES]->(r:Route) " + "RETURN DISTINCT r.filename AS fn LIMIT 1", + ) + delete_routes_for_file(conn, fp) + + remaining = _count( + conn, + "MATCH (a:Symbol)-[e:EXPOSES]->(r:Route) " + "WHERE r.filename = $fp RETURN count(e) AS n", + {"fp": fp}, + ) + assert remaining == 0 + + +class TestDeleteClientsForFile: + def test_delete_clients_for_file(self, conn): + from build_ast_graph import delete_clients_for_file + + fp = _find_file_with( + conn, + "MATCH (c:Client) RETURN c.filename AS fn LIMIT 1", + ) + before = _count( + conn, + "MATCH (c:Client) WHERE c.filename = $fp RETURN count(c) AS n", + {"fp": fp}, + ) + assert before > 0 + + n = delete_clients_for_file(conn, fp) + assert n == before + + after = _count( + conn, + "MATCH (c:Client) WHERE c.filename = $fp RETURN count(c) AS n", + {"fp": fp}, + ) + assert after == 0 + + def test_deletes_declares_client_edges(self, conn): + from build_ast_graph import delete_clients_for_file + + fp = _find_file_with( + conn, + "MATCH (a:Symbol)-[:DECLARES_CLIENT]->(c:Client) " + "RETURN DISTINCT c.filename AS fn LIMIT 1", + ) + delete_clients_for_file(conn, fp) + + remaining = _count( + conn, + "MATCH (a:Symbol)-[e:DECLARES_CLIENT]->(c:Client) " + "WHERE c.filename = $fp RETURN count(e) AS n", + {"fp": fp}, + ) + assert remaining == 0 + + +class TestDeleteProducersForFile: + def test_delete_producers_for_file(self, conn): + from build_ast_graph import delete_producers_for_file + + fp = _find_file_with( + conn, + "MATCH (p:Producer) RETURN p.filename AS fn LIMIT 1", + ) + before = _count( + conn, + "MATCH (p:Producer) WHERE p.filename = $fp RETURN count(p) AS n", + {"fp": fp}, + ) + assert before > 0 + + n = delete_producers_for_file(conn, fp) + assert n == before + + after = _count( + conn, + "MATCH (p:Producer) WHERE p.filename = $fp RETURN count(p) AS n", + {"fp": fp}, + ) + assert after == 0 + + def test_deletes_declares_producer_edges(self, conn): + from build_ast_graph import delete_producers_for_file + + fp = _find_file_with( + conn, + "MATCH (a:Symbol)-[:DECLARES_PRODUCER]->(p:Producer) " + "RETURN DISTINCT p.filename AS fn LIMIT 1", + ) + delete_producers_for_file(conn, fp) + + remaining = _count( + conn, + "MATCH (a:Symbol)-[e:DECLARES_PRODUCER]->(p:Producer) " + "WHERE p.filename = $fp RETURN count(e) AS n", + {"fp": fp}, + ) + assert remaining == 0 + + +class TestDeleteHttpCallsForFile: + def test_delete_http_calls_for_file(self, conn): + from build_ast_graph import delete_http_calls_for_file + + fp = _find_file_with( + conn, + "MATCH (c:Client)-[:HTTP_CALLS]->(r:Route) " + "RETURN DISTINCT c.filename AS fn LIMIT 1", + ) + before = _count( + conn, + "MATCH (c:Client)-[e:HTTP_CALLS]->(r:Route) " + "WHERE c.filename = $fp RETURN count(e) AS n", + {"fp": fp}, + ) + assert before > 0 + + n = delete_http_calls_for_file(conn, fp) + assert n == before + + after = _count( + conn, + "MATCH (c:Client)-[e:HTTP_CALLS]->(r:Route) " + "WHERE c.filename = $fp RETURN count(e) AS n", + {"fp": fp}, + ) + assert after == 0 + + +class TestDeleteAsyncCallsForFile: + def test_delete_async_calls_for_file(self, conn): + from build_ast_graph import delete_async_calls_for_file + + fp = _find_file_with( + conn, + "MATCH (p:Producer)-[:ASYNC_CALLS]->(r:Route) " + "RETURN DISTINCT p.filename AS fn LIMIT 1", + ) + before = _count( + conn, + "MATCH (p:Producer)-[e:ASYNC_CALLS]->(r:Route) " + "WHERE p.filename = $fp RETURN count(e) AS n", + {"fp": fp}, + ) + assert before > 0 + + n = delete_async_calls_for_file(conn, fp) + assert n == before + + after = _count( + conn, + "MATCH (p:Producer)-[e:ASYNC_CALLS]->(r:Route) " + "WHERE p.filename = $fp RETURN count(e) AS n", + {"fp": fp}, + ) + assert after == 0 + + +class TestDeleteOverridesForFile: + def test_delete_overrides_for_file(self, conn): + from build_ast_graph import delete_overrides_for_file + + fp = _find_file_with( + conn, + "MATCH (a:Symbol)-[:OVERRIDES]->(b:Symbol) " + "RETURN DISTINCT a.filename AS fn LIMIT 1", + ) + before = _count( + conn, + "MATCH (a:Symbol)-[e:OVERRIDES]->(b:Symbol) " + "WHERE a.filename = $fp RETURN count(e) AS n", + {"fp": fp}, + ) + assert before > 0 + + n = delete_overrides_for_file(conn, fp) + assert n == before + + after = _count( + conn, + "MATCH (a:Symbol)-[e:OVERRIDES]->(b:Symbol) " + "WHERE a.filename = $fp RETURN count(e) AS n", + {"fp": fp}, + ) + assert after == 0 + + +class TestDeleteAllForFile: + def test_delete_all_for_file(self, conn): + from build_ast_graph import delete_all_for_file + + fp = _find_file_with( + conn, + "MATCH (s:Symbol) WHERE s.kind = 'class' " + "RETURN s.filename AS fn LIMIT 1", + ) + result = delete_all_for_file(conn, fp) + + assert isinstance(result, dict) + assert len(result) > 0 + assert all(isinstance(v, int) for v in result.values()) + + # Verify symbols are gone + after = _count( + conn, + "MATCH (s:Symbol) WHERE s.filename = $fp RETURN count(s) AS n", + {"fp": fp}, + ) + assert after == 0 + + def test_calls_each_helper(self, conn): + """delete_all_for_file should return counts from each sub-helper.""" + from build_ast_graph import delete_all_for_file + + fp = _find_file_with( + conn, + "MATCH (s:Symbol) WHERE s.kind = 'class' " + "RETURN s.filename AS fn LIMIT 1", + ) + result = delete_all_for_file(conn, fp) + + expected_keys = { + "symbols", "extends", "implements", "injects", + "calls", "routes", "clients", "producers", + "http_calls", "async_calls", "overrides", + } + assert expected_keys == set(result.keys()) + + +class TestDeleteEdgeCases: + def test_delete_idempotent(self, conn): + from build_ast_graph import delete_extends_for_file + + fp = _find_file_with( + conn, + "MATCH (a:Symbol)-[:EXTENDS]->(b:Symbol) " + "RETURN DISTINCT a.filename AS fn LIMIT 1", + ) + first = delete_extends_for_file(conn, fp) + assert first > 0 + + second = delete_extends_for_file(conn, fp) + assert second == 0 + + def test_delete_unknown_file_returns_zero(self, conn): + from build_ast_graph import delete_all_for_file + + bogus = "no/such/File.java" + result = delete_all_for_file(conn, bogus) + + assert all(v == 0 for v in result.values())