Files
graphify/tests/test_affected_defines.py
safishamsiandClaude Opus 4.8 842ca78374 docs(changelog): add #4290/#4169/#4178/#4301/#4302 and credit #4142 to 0.9.85
Also fix the #4288 reference in the affected-defines test docstring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-10 22:13:39 +01:00

196 lines
7.2 KiB
Python

"""#4288 — affected <Symbol> must reach callers through its defining file.
Dart/Flutter (and Python, Go, and other file-module languages) extract a class
or function as a symbol node with a ``defines`` edge from its source file.
Without ``defines`` in ``DEFAULT_AFFECTED_RELATIONS`` the reverse walk stops
at depth 0 because the only incoming edge is invisible.
Measured on a real Flutter project (12 104 nodes, 12 420 edges):
- 4 793 symbols defined via ``defines`` edges
- 4 691 of them (97.9 %) had *zero* incoming edges from any whitelisted
relation — ``affected`` returned "No affected nodes found" for each one
- After adding ``defines``: 100 % of symbols reachable through at minimum
their defining file
"""
from __future__ import annotations
import networkx as nx
from graphify.affected import DEFAULT_AFFECTED_RELATIONS, affected_nodes, resolve_seed
# ── Fixture: Dart-style graph ──────────────────────────────────────────────
def _dart_style_graph():
"""Graph matching Dart extractor output.
``database_wrapper.dart`` declares ``DatabaseWrapper``, and ``app.dart``
imports the wrapper file via a package URI (here collapsed to a direct
file-level import for the unit test; the import-resolution half is
covered by #2570).
"""
g = nx.DiGraph()
g.add_node(
"db_file",
label="database_wrapper.dart",
source_file="lib/database/database_wrapper.dart",
source_location="L1",
)
g.add_node(
"db_class",
label="DatabaseWrapper",
source_file="lib/database/database_wrapper.dart",
)
g.add_node(
"consumer_file",
label="app.dart",
source_file="lib/app.dart",
)
g.add_edge("db_file", "db_class", relation="defines")
g.add_edge("consumer_file", "db_file", relation="imports")
return g
def _python_style_graph():
"""Graph matching Python module output.
``module.py`` defines ``MyClass``, and ``consumer.py`` imports it.
"""
g = nx.DiGraph()
g.add_node(
"mod_file",
label="module.py",
source_file="pkg/module.py",
source_location="L1",
)
g.add_node(
"mod_class",
label="MyClass",
source_file="pkg/module.py",
)
g.add_node(
"consumer_file",
label="consumer.py",
source_file="pkg/consumer.py",
)
g.add_edge("mod_file", "mod_class", relation="defines")
g.add_edge("consumer_file", "mod_file", relation="imports")
return g
# ── Helpers ────────────────────────────────────────────────────────────────
NODE_IDS = {
"db_class": "DatabaseWrapper",
"mod_class": "MyClass",
"foo_target": "Foo",
}
# ── Tests ──────────────────────────────────────────────────────────────────
def test_affected_reaches_defining_file_via_defines():
"""``affected "<Symbol>"`` must walk ``defines`` to the defining file.
Before fix: the only incoming edge is ``defines`` (excluded), so 0 hits.
After fix: the defining file is found at depth 1.
"""
g = _dart_style_graph()
hits = {h.node_id for h in affected_nodes(g, "db_class", depth=2)}
assert "db_file" in hits, "defining file must be reachable via defines"
def test_affected_reaches_importers_through_defines():
"""The full chain: symbol → defines → file → imports → consumer."""
g = _dart_style_graph()
hits = {h.node_id for h in affected_nodes(g, "db_class", depth=2)}
assert "consumer_file" in hits, (
"consumer that imports the file must be reachable "
"(symbol → defines → file → imports → consumer)"
)
def test_defines_relation_reported_in_hit():
"""The ``AffectedHit.relation`` field must carry ``"defines"``."""
g = _dart_style_graph()
hits = affected_nodes(g, "db_class", depth=2)
file_hits = [h for h in hits if h.node_id == "db_file"]
assert file_hits, "defining file should be in hits"
assert file_hits[0].via_relation == "defines", (
f"expected via_relation='defines', got {file_hits[0].via_relation!r}"
)
def test_defines_only_one_hop():
"""A symbol with ONLY ``defines`` incoming (the Dart case) still works."""
g = _dart_style_graph()
# Remove the import edge so the file has 0 incoming whitelisted edges
g.remove_edge("consumer_file", "db_file")
hits = {h.node_id for h in affected_nodes(g, "db_class", depth=2)}
assert "db_file" in hits, (
"defining file must be reachable even without consumers"
)
def test_defines_respects_depth_limit():
"""With ``depth=1``, only the defining file (not importers) is returned."""
g = _dart_style_graph()
hits = {h.node_id for h in affected_nodes(g, "db_class", depth=1)}
assert "db_file" in hits, "defining file must be reachable at depth 1"
assert "consumer_file" not in hits, (
"consumer must NOT be reachable at depth 1 "
"(needs depth≥2: symbol → defines → file → imports → consumer)"
)
def test_defines_excluded_by_relation_filter():
"""``--relation calls`` must NOT traverse ``defines``."""
g = _dart_style_graph()
relations = {"calls"}
hits = {h.node_id for h in affected_nodes(
g, "db_class", depth=2, relations=relations,
)}
assert "db_file" not in hits, (
"defining file must not be reachable when defines is excluded"
)
assert "consumer_file" not in hits
def test_file_seed_unaffected_by_defines_change():
"""``affected "<file>"`` must behave identically — ``defines`` is irrelevant."""
g = _dart_style_graph()
# Seed is the defining file (the target of the import).
# ``consumer_file`` imports ``db_file``, so ``in_edges(db_file)``
# should find ``consumer_file`` via the ``imports`` relation —
# regardless of whether ``defines`` is in the whitelist.
hits = {h.node_id for h in affected_nodes(g, "db_file", depth=2)}
assert "consumer_file" in hits, (
"file-level affected must still work via imports"
)
def test_python_module_works_too():
"""Same pattern applies to Python: module defines class, consumer imports."""
g = _python_style_graph()
hits = {h.node_id for h in affected_nodes(g, "mod_class", depth=2)}
assert "mod_file" in hits
assert "consumer_file" in hits
def test_non_defines_relations_still_work():
"""Adding ``defines`` must not break existing relation-based traversal."""
g = nx.DiGraph()
g.add_node("caller", label="use()", source_file="caller.py")
g.add_node("target", label="Foo", source_file="target.py")
g.add_edge("caller", "target", relation="references")
hits = {h.node_id for h in affected_nodes(g, "target", depth=1)}
assert "caller" in hits, "references-based traversal must still work"
def test_defines_is_in_DEFAULT_AFFECTED_RELATIONS():
"""Sanity check: ``defines`` is actually in the default tuple."""
assert "defines" in DEFAULT_AFFECTED_RELATIONS, (
"defines must be in DEFAULT_AFFECTED_RELATIONS for the fix to work"
)