diff --git a/CHANGELOG.md b/CHANGELOG.md index 87975132..8fa07bab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -110,6 +110,12 @@ surface** (3b). Design records: `docs/design/specs/2026-09-06-leg-2.5-typescript Python re-imports every name unchanged. ### Fixed +- **`get_cfg`/`get_cdg`/`get_ddg` on the Python Neo4j backend no longer drop self-loop edges.** The + per-callable query bound the containment relationship twice, so Cypher's relationship-uniqueness rule + silently discarded every edge whose endpoints are the same body node — a statement that reads a + variable it also redefines. 64,702 such edges exist on the odoo reference graph; one callable returned + 28,146 of its 28,394. `total` was counted from the same match, so the short page reported itself + complete. (#349) - **`JavaAnalysis.get_method_parameters()`** is annotated `List[JCallableParameter]`, which is what it has always returned. diff --git a/cldk/analysis/python/neo4j/neo4j_backend.py b/cldk/analysis/python/neo4j/neo4j_backend.py index 5f0b42f3..b20ec738 100644 --- a/cldk/analysis/python/neo4j/neo4j_backend.py +++ b/cldk/analysis/python/neo4j/neo4j_backend.py @@ -1332,9 +1332,17 @@ def resolve_value(self, name: str, *, within: str) -> SliceNode: #: one is impossible on a graph built that way (verified: 0 cross-callable edges of 5,521,626 #: on odoo-slim-19), but a graph built some other way must not be able to widen the answer #: silently. + #: One callable's own ``rel`` edges. The second containment hop is a **pattern predicate**, not a + #: second pattern: written as ``(s)-[r]->(d)<-[:PY_HAS_BODY_NODE]-(c)`` it binds the same + #: ``PY_HAS_BODY_NODE`` relationship twice whenever ``s`` and ``d`` are the same node, and Cypher's + #: relationship-uniqueness rule then drops the row. A data-dependence self-loop — a statement that + #: reads a variable it also redefines — is exactly that shape, and there are 64,702 of them on the + #: odoo reference graph. The old spelling lost every one of them silently, and because ``total`` is + #: counted from the same match, the short page reported itself complete (#349). _OWN_EDGES = ( "MATCH (c:PyCallable) WHERE c.id STARTS WITH $prefix AND c.signature = $sig " - "MATCH (c)-[:PY_HAS_BODY_NODE]->(s:PyBodyNode)-[r:{rel}]->(d:PyBodyNode)<-[:PY_HAS_BODY_NODE]-(c) " + "MATCH (c)-[:PY_HAS_BODY_NODE]->(s:PyBodyNode)-[r:{rel}]->(d:PyBodyNode) " + "WHERE (c)-[:PY_HAS_BODY_NODE]->(d) " ) def _own_edges(self, name: str, in_class: str | None, rel: str, projection: str, order: EdgeOrder, page_size: int, cursor: str | None): diff --git a/tests/analysis/python/test_neo4j_self_loops.py b/tests/analysis/python/test_neo4j_self_loops.py new file mode 100644 index 00000000..1784e0f4 --- /dev/null +++ b/tests/analysis/python/test_neo4j_self_loops.py @@ -0,0 +1,70 @@ +################################################################################ +# Copyright IBM Corporation 2026 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +################################################################################ + +"""A per-callable graph must contain its self-loops (#349). + +A data-dependence self-loop is a statement that reads a variable it also redefines. Written as two +containment patterns, ``(c)-[:PY_HAS_BODY_NODE]->(s)-[r]->(d)<-[:PY_HAS_BODY_NODE]-(c)`` binds the +same ``PY_HAS_BODY_NODE`` relationship twice when ``s`` and ``d`` are one node, and Cypher's +relationship-uniqueness rule drops the row. The second hop is therefore a *predicate*. + +The defect was invisible from inside: ``total`` is counted from the same match, so the short page +agreed with its own count and reported ``complete``. Only a comparison against the graph shows it. +""" + +import os + +import pytest + +_URI = os.environ.get("CLDK_TEST_NEO4J_URI") + +pytestmark = pytest.mark.skipif( + not _URI, + reason="needs a live Python graph: set CLDK_TEST_NEO4J_URI / _USER / _PASSWORD", +) + + +def test_the_query_does_not_bind_the_containment_relationship_twice(): + """The shape check, which needs no graph and is the regression guard proper.""" + from cldk.analysis.python.neo4j.neo4j_backend import PyNeo4jBackend + + match = PyNeo4jBackend._OWN_EDGES.format(rel="PY_DDG") + assert match.count("PY_HAS_BODY_NODE") == 2, "both hops are still there" + assert "<-[:PY_HAS_BODY_NODE]-(c)" not in match, ( + "the second containment hop is a pattern again, so Cypher's relationship-uniqueness rule " + "will silently drop every self-loop edge (#349)" + ) + assert "WHERE (c)-[:PY_HAS_BODY_NODE]->(d)" in match + + +def test_a_callables_ddg_contains_its_self_loops(live_analysis): + """Against the live graph: the page must hold every self-loop the graph holds.""" + backend = live_analysis.backend + rows = backend._run( + "MATCH (c:PyCallable) WHERE c.id STARTS WITH $prefix " + "MATCH (c)-[:PY_HAS_BODY_NODE]->(s:PyBodyNode)-[r:PY_DDG]->(s) " + "RETURN c.signature AS sig, count(r) AS loops ORDER BY loops DESC LIMIT 1", + prefix=backend._scope_prefix, + ) + if not rows or not rows[0]["loops"]: + pytest.skip("this graph has no data-dependence self-loop to check") + sig, expected = rows[0]["sig"], rows[0]["loops"] + + page = backend.get_ddg(sig, page_size=1_000_000) + returned = sum(1 for e in page.edges if e.src == e.dst) + assert returned == expected, f"{sig}: {returned} self-loops returned, {expected} in the graph" + assert page.total == len(page.edges) + assert page.complete