mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-13 23:49:14 +00:00
fix(affected): add defines to DEFAULT_AFFECTED_RELATIONS
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
Tests: 10 new regression tests in test_affected_defines.py.
All pass post-fix, all fail pre-fix.
(cherry picked from commit c695fdf8eb)
This commit is contained in:
1 parent
b28c2c5368
commit
289e870962
2 files changed
+203
No files matched your search
@@ -28,6 +28,14 @@ DEFAULT_AFFECTED_RELATIONS = (
|
||||
"uses",
|
||||
"mixes_in",
|
||||
"embeds",
|
||||
# `defines` — emitted when a file declares a named symbol (class, function,
|
||||
# type, etc.). Without it, `affected "<Symbol>"` is blind in languages
|
||||
# where the only incoming edge to a symbol is its `defines` edge from the
|
||||
# source file — Dart/Flutter, Python, Go, etc. — because every other
|
||||
# relation (calls, references, imports) targets the *file* node, not the
|
||||
# symbol node. The reverse walk follows `defines` from the symbol back to
|
||||
# its defining file and then continues through `imports` to consumers.
|
||||
"defines",
|
||||
"requires",
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
"""#XXXX — 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"
|
||||
)
|
||||
Reference in new issue
Block a user