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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 9 additions & 1 deletion cldk/analysis/python/neo4j/neo4j_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
70 changes: 70 additions & 0 deletions tests/analysis/python/test_neo4j_self_loops.py
Original file line number Diff line number Diff line change
@@ -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