Skip to content

Commit a6881e4

Browse files
committed
fix(neo4j)!: scope every destructive statement on the can:// id prefix; retire _module
Every delete — the per-module purge, the full-run orphan prune and the snapshot wipe — now matches `x.id = <id> OR x.id STARTS WITH <id> + '/'` instead of a label list plus the `_module` property. The id already carries language, application and file, so a prefix match is containment, and it separates two python applications sharing a module path, which no label anchor can (identical labels). `_module` leaves the graph, the catalog and its six per-label indexes; RowBuilder lifts it into an in-memory field so the incremental diff still groups by module and no projector site changes. `:PyCanNode` marks every node keyed by a `can://python/` id with a range index on `id` — an anchor so the prefix predicate seeks instead of scanning; scope comes from the prefix. An empty application id is refused rather than becoming `STARTS WITH ''`. `:PyAttribute` and `:PyVariable` ids are minted from the owner's can:// id (`<class-id>/<name>`, `<owner-id>/<name>@<line>`); the signature-minted ids carried no application segment and MERGEd across applications. An unhomed call target lands on an `@external` ghost under the application prefix, never a bare-signature id. `:PyExternal` therefore sits inside the application scope, which resolves #179. Also fixes the content_hash diff: it compared a module's can:// id against a file key (every module always "changed"), and keyed the database side by file key (a second application's identical module was never written). Graph contract 2.0.0 -> 3.0.0. Closes #173 Closes #179
1 parent 0ac851e commit a6881e4

12 files changed

Lines changed: 411 additions & 147 deletions

File tree

‎.claude/SCHEMA_DECISIONS.md‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,3 +412,37 @@ side) and `docs/design/specs/2026-08-28-config-use-edge-design.md` (the
412412
`points-to` edge whose def is not a `Name = <str Constant>` shape and so
413413
unfilterably kills the closure at `-a 4` while `-a 3` (ssa-only by
414414
construction) resolves cleanly.
415+
416+
## 2026-09-05 — Destructive Neo4j statements scope on the `can://` prefix; `_module` retires (issue #173, epic .github#50)
417+
418+
Spec: `codellm-devkit/.github` → `docs/design/specs/2026-09-02-prune-scope-on-can-id-prefix.md`.
419+
Reference implementation: codeanalyzer-java#220.
420+
421+
- **Scope is identity, not a property.** Every delete — the per-module purge,
422+
the full-run orphan prune, the snapshot wipe — matches `x.id = <id> OR x.id
423+
STARTS WITH <id> + '/'`. The id already carries language, application and
424+
file, so a prefix match is containment and separates two python applications
425+
sharing a module path, which no label anchor can (identical labels). The
426+
separator is mandatory (`rows.descendant_prefix`); an empty application id is
427+
refused (`rows.application_prefix`) rather than becoming `STARTS WITH ''`.
428+
- **`_module` leaves the graph, not the writer.** `RowBuilder.node` lifts it off
429+
the props into `NodeRow.module`, so no projector call site changed and the
430+
incremental diff still groups by module. The module id for the purge comes
431+
from the module's own row — never by splitting a declaration's id, because a
432+
file key may itself contain `/`.
433+
- **`PyCanNode` is an index anchor only.** Neo4j property indexes are
434+
label-scoped; without a label the prefix predicate scans the store. `STARTS
435+
WITH` seeks a range index, `CONTAINS`/`ENDS WITH` do not. Per-language
436+
marker (not a shared `CanNode`) so three analyzers do not contend on one
437+
index and a wrong prefix still costs one language at most. Add a shared
438+
label alongside only when a polyglot consumer asks for it.
439+
- **Attribute and variable ids are `can://`.** `<class-id>/<name>` and
440+
`<owner-id>/<name>@<line>`. The signature-minted ids they replace had no
441+
application segment and MERGEd across applications; they also fell outside
442+
every prefix, so the purge would have stopped reaching them. Module-level
443+
variables hang under `<module-id>/` so the module's own prefix reaches them.
444+
- **No bare-signature ids remain.** `_call_endpoint`'s last-resort ghost now
445+
mints `<app>/@external/<module>/<name>` (the `_home_external_symbols` shape),
446+
so java's "legacy ids cannot be prefix-scoped" case has no python analogue.
447+
- Graph contract `2.0.0 → 3.0.0` (a property removed). `analysis.json` is
448+
untouched; `_module` never appeared there.

‎CHANGELOG.md‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,45 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [Unreleased]
9+
10+
### Changed
11+
12+
- **BREAKING (graph contract 3.0.0):** every destructive Neo4j statement is
13+
scoped on the `can://` id prefix, and the internal `_module` property retires
14+
from every node, from the catalog and from its six per-label indexes (#173,
15+
epic codellm-devkit/.github#50). The per-module purge matches the module by
16+
id and its subtree by `id STARTS WITH <module-id> + '/'`; the full-run orphan
17+
prune and the snapshot wipe match `can://python/<app>/`. Two python
18+
applications sharing a module path no longer delete each other's nodes; an
19+
empty application id is refused instead of matching the whole store.
20+
Migration: a consumer that filtered on `x._module` filters on
21+
`x.id STARTS WITH 'can://python/<app>/<file>/'` instead.
22+
- **BREAKING:** `:PyAttribute` and `:PyVariable` ids are minted from the owner's
23+
`can://` id — `<class-id>/<name>` and `<owner-id>/<name>@<line>` — instead of
24+
from its signature. The old ids (`service.Service.name`) carried no
25+
application segment, so two applications MERGEd onto one node.
26+
- A call target nobody homed now lands on an `@external` ghost under the
27+
application prefix (`can://python/<app>/@external/<module>/<name>`), never
28+
on a bare-signature id, so every id-keyed node is a `can://` node.
29+
30+
### Added
31+
32+
- `:PyCanNode` marker label on every node keyed by a `can://python/` id, with a
33+
range index on `id`. It is the anchor the prefix predicates seek on; scope
34+
comes from the prefix, not the label. `:PyExternal` nodes carry it too, so
35+
external→application `PY_CALLS` edges sit inside the application scope
36+
(#179).
37+
38+
### Fixed
39+
40+
- The Bolt writer's `content_hash` diff compared the module row's `can://` id
41+
against a file key and never matched, so every module counted as changed on
42+
every push. It now reads the module row directly, and keys the database side
43+
by module id under the application prefix: keyed by file key it was
44+
application-blind, so a second application whose module shared the path and
45+
the hash looked "unchanged" and was never written.
46+
847
## [1.4.0] - 2026-09-02
948

1049
### Removed

‎codeanalyzer/neo4j/bolt.py‎

Lines changed: 83 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,16 @@
3737
3838
Nodes are MERGE-upserted, never blindly deleted, so a declaration another
3939
(unchanged) module still references survives and its incoming edges stay valid.
40-
``:PyExternal`` / ``:PyPackage`` / ``:PyDecorator`` are shared (no ``_module``) and are
40+
``:PyExternal`` / ``:PyPackage`` / ``:PyDecorator`` have no owning module and are
4141
MERGE-only.
4242
43-
Every ``_module`` match is anchored on the python-owned labels
44-
(``schema.MODULE_OWNED_PATTERN``). ``_module`` is a shared convention, not a python-private
45-
one -- codeanalyzer-java and codeanalyzer-typescript set it on their nodes too -- so an
46-
unlabelled match reaches a sibling analyzer's graph in a shared database (#171).
43+
**Every destructive statement is scoped on the ``can://`` id prefix** (#173). The id is a
44+
path — ``can://python/<app>/<file>/...`` — so ``id = <module-id> OR id STARTS WITH
45+
<module-id> + '/'`` is containment, and it is one language, one application and one
46+
module at once. That is what neither a label anchor nor the retired ``_module`` property
47+
could give: two python applications sharing ``src/foo.py`` carry identical labels and an
48+
identical file key, and only the id tells them apart. ``:PyCanNode`` anchors the predicate
49+
so it seeks an index instead of scanning the store; it carries no safety claim.
4750
4851
The ``neo4j`` driver is imported lazily so it stays an optional dependency and
4952
off the default (json) output path entirely.
@@ -53,16 +56,34 @@
5356
from dataclasses import dataclass
5457
from typing import Dict, List, Optional
5558

56-
from codeanalyzer.neo4j.rows import EdgeRow, GraphRows, NodeRow, chunk
57-
from codeanalyzer.neo4j.schema import CONSTRAINTS, INDEXES, MODULE_OWNED_PATTERN
59+
from codeanalyzer.neo4j.rows import (
60+
CAN_NODE, EdgeRow, GraphRows, NodeRow, application_prefix, chunk, descendant_prefix,
61+
)
62+
from codeanalyzer.neo4j.schema import CONSTRAINTS, INDEXES
5863
from codeanalyzer.utils import logger
5964

60-
DESCENDANTS = (
61-
"[:PY_DECLARES|PY_HAS_METHOD|PY_HAS_ATTRIBUTE|PY_DECLARES_VAR"
62-
"|PY_HAS_CALLSITE|PY_HAS_BODY_NODE*1..]"
63-
)
6465
BATCH = 1000
6566

67+
# The per-module purge (#173): the module by equality, its subtree by prefix. Anchored
68+
# on :PyCanNode only so the predicate can seek (see ``rows.CAN_NODE``).
69+
PURGE_MODULE_EDGES = (
70+
f"MATCH (x:{CAN_NODE}) WHERE x.id = $mid OR x.id STARTS WITH $pre "
71+
"MATCH (x)-[r]->() DELETE r"
72+
)
73+
PURGE_VANISHED_NODES = (
74+
f"MATCH (x:{CAN_NODE}) WHERE (x.id = $mid OR x.id STARTS WITH $pre) "
75+
"AND NOT x.id IN $keys DETACH DELETE x"
76+
)
77+
# The orphan prune: modules inside this application's prefix that the run no longer
78+
# emits, and everything under each. Batched — deleting a large application in one
79+
# transaction exhausts dbms.memory.transaction.total.max (typescript#116).
80+
PRUNE_VANISHED_MODULES = (
81+
f"MATCH (m:PyModule:{CAN_NODE}) WHERE m.id STARTS WITH $app AND NOT m.id IN $present "
82+
f"CALL {{ WITH m MATCH (x:{CAN_NODE}) WHERE x.id = m.id OR x.id STARTS WITH m.id + '/' "
83+
"DETACH DELETE x } IN TRANSACTIONS OF 1000 ROWS "
84+
"RETURN count(*) AS pruned"
85+
)
86+
6687

6788
@dataclass
6889
class BoltConfig:
@@ -93,35 +114,44 @@ def session():
93114
for stmt in [*CONSTRAINTS, *INDEXES]:
94115
s.run(stmt)
95116

96-
# The application anchor (a shared node) — used to scope the orphan prune
97-
# so it never touches modules belonging to a different :PyApplication.
117+
# The application anchor. Every destructive statement below is scoped to
118+
# ``can://python/<app>/``; an empty application id is refused up front rather
119+
# than becoming ``STARTS WITH ''`` (every node in the database).
98120
app_name = next(
99121
(n.value for n in rows.nodes if n.labels and n.labels[0] == "PyApplication"),
100122
None,
101123
)
124+
app_prefix = application_prefix(app_name)
102125

103-
# Partition nodes by owning module; shared nodes have no _module.
126+
# Partition nodes by owning module (an in-memory field, never emitted, #173);
127+
# shared nodes have none.
104128
by_module: Dict[str, List[NodeRow]] = {}
105129
shared: List[NodeRow] = []
106130
module_of: Dict[str, str] = {} # node value → owning module
107131
for n in rows.nodes:
108-
m = n.props.get("_module")
109-
if isinstance(m, str):
110-
by_module.setdefault(m, []).append(n)
111-
module_of[n.value] = m
132+
if n.module is not None:
133+
by_module.setdefault(n.module, []).append(n)
134+
module_of[n.value] = n.module
112135
else:
113136
shared.append(n)
114137

115-
# 2. diff content_hash.
138+
# 2. diff content_hash, keyed by module id inside this application's prefix.
139+
# Keyed by file key it was application-blind: a second application whose
140+
# module shares the path and the hash looked "unchanged" and was never written.
116141
db_hash: Dict[str, Optional[str]] = {}
117142
with session() as s:
118-
res = s.run("MATCH (m:PyModule) RETURN m.file_key AS k, m.content_hash AS h")
143+
res = s.run(
144+
f"MATCH (m:PyModule:{CAN_NODE}) WHERE m.id STARTS WITH $app "
145+
"RETURN m.id AS k, m.content_hash AS h",
146+
app=app_prefix,
147+
)
119148
for rec in res:
120149
db_hash[rec["k"]] = rec["h"]
121150
changed = set()
122151
for m, nodes in by_module.items():
123-
row_hash = _hash_of(nodes, m)
124-
if m not in db_hash or row_hash is None or row_hash != db_hash.get(m):
152+
mid = _module_id_of(nodes)
153+
row_hash = _hash_of(nodes)
154+
if mid not in db_hash or row_hash is None or row_hash != db_hash.get(mid):
125155
changed.add(m)
126156
logger.info(
127157
f"neo4j(bolt): {len(by_module)} modules ({len(changed)} changed), "
@@ -139,23 +169,19 @@ def session():
139169
if not eager:
140170
_upsert_nodes(session, neo4j, nodes)
141171
continue
172+
# The module id comes from the module's own row, never by splitting a
173+
# declaration's id: a file key may itself contain '/'.
174+
module_id = _module_id_of(nodes)
175+
if module_id is None or not module_id.startswith(app_prefix):
176+
raise ValueError(
177+
f"neo4j: module {m!r} has no can:// id under {app_prefix!r}; "
178+
"refusing to purge"
179+
)
142180
with session() as s:
143-
def _purge(tx, module=m, node_keys=keys):
144-
# Anchored on python-owned labels: `_module` is also set by the java
145-
# and typescript analyzers, so an unlabelled match would delete a
146-
# sibling's nodes wherever a file key collides (#171).
147-
tx.run(
148-
f"MATCH (x:{MODULE_OWNED_PATTERN}) WHERE x._module = $m "
149-
"MATCH (x)-[r]->() DELETE r",
150-
m=module,
151-
)
152-
tx.run(
153-
f"MATCH (x:{MODULE_OWNED_PATTERN}) WHERE x._module = $m "
154-
"AND NOT coalesce(x.signature, x.id, x.file_key) IN $keys "
155-
"DETACH DELETE x",
156-
m=module,
157-
keys=node_keys,
158-
)
181+
def _purge(tx, mid=module_id, node_keys=keys):
182+
params = {"mid": mid, "pre": descendant_prefix(mid)}
183+
tx.run(PURGE_MODULE_EDGES, **params)
184+
tx.run(PURGE_VANISHED_NODES, keys=node_keys, **params)
159185

160186
s.execute_write(_purge)
161187
_upsert_nodes(session, neo4j, nodes)
@@ -169,19 +195,13 @@ def _purge(tx, module=m, node_keys=keys):
169195
_upsert_edges(session, neo4j, edges)
170196

171197
# 6. orphan prune — only safe on a full run (a targeted run can't tell deleted from untargeted).
172-
# Scope to THIS application's anchor so a full run for application B never
173-
# deletes application A's modules from a shared database.
174-
if full_run and eager and app_name is not None:
175-
present = list(by_module.keys())
198+
# Scoped to ``can://python/<app>/`` so a full run for application B never deletes
199+
# application A's modules from a shared database — even when both are python and
200+
# share a module path.
201+
if full_run and eager:
202+
present = [mid for mid in (_module_id_of(ns) for ns in by_module.values()) if mid]
176203
with session() as s:
177-
res = s.run(
178-
"MATCH (:PyApplication {name: $app})-[:PY_HAS_MODULE]->(m:PyModule) "
179-
"WHERE NOT m.file_key IN $present "
180-
f"OPTIONAL MATCH (m)-{DESCENDANTS}->(x) DETACH DELETE x, m "
181-
"RETURN count(m) AS pruned",
182-
app=app_name,
183-
present=present,
184-
)
204+
res = s.run(PRUNE_VANISHED_MODULES, app=app_prefix, present=present)
185205
pruned = res.single()
186206
pruned_count = pruned["pruned"] if pruned else 0
187207
logger.info(f"neo4j(bolt): pruned {pruned_count} vanished module(s)")
@@ -263,12 +283,19 @@ def _upsert_edges(session, neo4j, edges: List[EdgeRow]) -> None:
263283
# ----------------------------------------------------------------------------------------------
264284

265285

266-
def _hash_of(nodes: List[NodeRow], file_key: str) -> Optional[str]:
267-
for n in nodes:
268-
if n.labels[0] == "PyModule" and n.value == file_key:
269-
h = n.props.get("content_hash")
270-
return h if isinstance(h, str) else None
271-
return None
286+
def _module_row(nodes: List[NodeRow]) -> Optional[NodeRow]:
287+
return next((n for n in nodes if n.labels[0] == "PyModule"), None)
288+
289+
290+
def _module_id_of(nodes: List[NodeRow]) -> Optional[str]:
291+
row = _module_row(nodes)
292+
return row.value if row is not None else None
293+
294+
295+
def _hash_of(nodes: List[NodeRow]) -> Optional[str]:
296+
row = _module_row(nodes)
297+
h = row.props.get("content_hash") if row is not None else None
298+
return h if isinstance(h, str) else None
272299

273300

274301
def _to_params(props, neo4j) -> dict:

‎codeanalyzer/neo4j/cypher.py‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,11 @@
2828
from typing import Dict, List
2929

3030
from codeanalyzer.neo4j.rows import (
31+
CAN_NODE,
3132
EdgeRow,
3233
GraphRows,
3334
NodeRow,
35+
application_prefix,
3436
chunk,
3537
cypher_map,
3638
cypher_value,
@@ -66,13 +68,17 @@ def render_cypher(rows: GraphRows, app_name: str) -> str:
6668

6769

6870
def _wipe(app_name: str) -> str:
71+
"""Everything under ``can://python/<app>/`` plus the application anchor (#173).
72+
Scoped by id prefix, so it is one language and one application by construction —
73+
a second python app sharing a module path, a sibling analyzer's graph, and the
74+
cross-language :Artifact / :Package nodes are all outside it."""
75+
prefix = cypher_value(application_prefix(app_name))
6976
name = cypher_value(app_name)
7077
return "\n".join(
7178
[
72-
f"MATCH (a:PyApplication {{name: {name}}})",
73-
"OPTIONAL MATCH (a)-[:PY_HAS_MODULE]->(m:PyModule)",
74-
"OPTIONAL MATCH (m)-[:PY_DECLARES|PY_HAS_METHOD|PY_HAS_ATTRIBUTE|PY_DECLARES_VAR|PY_HAS_CALLSITE*1..]->(x)",
75-
"DETACH DELETE x, m, a;",
79+
f"MATCH (x:{CAN_NODE}) WHERE x.id STARTS WITH {prefix}",
80+
"CALL { WITH x DETACH DELETE x } IN TRANSACTIONS OF 1000 ROWS;",
81+
f"MATCH (a:PyApplication {{name: {name}}}) DETACH DELETE a;",
7682
]
7783
)
7884

0 commit comments

Comments
 (0)