Summary
The Python AST extractor generates inconsistent node ids for methods decorated with @property / @staticmethod: the method node gets a class-unqualified id, but the rationale_for edge that links the method's docstring/comment targets a class-qualified id. The two ids don't match, so build_from_json treats the edge as dangling and drops it (the src not in node_set or tgt not in node_set: continue branch). The result is orphaned (degree-0) rationale nodes — every docstring/comment on a decorated method becomes a disconnected node in the graph.
The same class-qualified-vs-not mismatch can also drop calls / references edges that point into decorated methods, so those methods are under-connected in general.
Minimal reproduction
repro.py:
class Bar:
@property
def baz(self) -> int:
"""Return the baz value."""
return 1
def normal(self) -> int:
"""A normal instance method."""
return 3
from pathlib import Path
from graphify.extract import extract
from graphify.build import build_from_json
ast = extract([Path("repro.py")])
# method NODE ids:
# baz (@property) -> id "..._baz" label "baz()" (NO class segment, NO leading dot)
# normal (instance) -> id "..._bar_normal" label ".normal()" (class segment present)
# rationale_for EDGE targets:
# baz docstring -> target "..._bar_baz" -> target_in_nodes = False (class-qualified, does not match node)
# normal docstring -> target "..._bar_normal" -> target_in_nodes = True
G = build_from_json(ast)
# baz's docstring rationale node -> degree 0 (ISOLATED)
# normal's docstring rationale node -> degree 1 (connected)
Observed output:
NODE ids:
id=..._baz label='baz()'
id=..._bar_normal label='.normal()'
rationale_for edges:
target=..._bar_baz target_in_nodes=False <-- dropped at build
target=..._bar_normal target_in_nodes=True
after build:
rationale ..._rationale_4 degree=0 'Return the baz value.' <-- orphaned
rationale ..._rationale_13 degree=1 'A normal instance method.'
Root cause
For a decorated method, the node-id derivation omits the enclosing class (id file_method, label method() with no leading dot), while the rationale_for edge-target derivation keeps the class (id file_class_method). The two code paths disagree, so the edge endpoint never resolves and the edge is silently discarded as "external/stdlib".
Larger-corpus evidence
On a real ~8k-LOC Python project (294 docstring/comment rationale nodes): 38 rationale nodes were isolated. For all 38, the source endpoint (the rationale node) was present and only the target (the parent method) was missing; all 38 targets resolved by removing exactly one interior id segment (the class), and every parent was verified to be a @property or @staticmethod.
Suggested fix
Make the decorated-method node id consistent with the edge-target id — i.e. include the enclosing class segment for @property / @staticmethod methods just as for regular instance methods (and ideally keep the leading-dot label convention too). Either derivation is fine as long as both sides use the same one.
Workaround (post-extraction)
Remap an edge endpoint that is missing from the node set by dropping one interior id segment when exactly one candidate exists:
def declassify_unique(eid, node_set):
p = eid.split("_")
hits = [h for i in range(1, len(p) - 1) if (h := "_".join(p[:i] + p[i+1:])) in node_set]
return hits[0] if len(hits) == 1 else None
This reconnected 37/38 orphaned nodes in the corpus above (the 1 remainder had an ambiguous declassification and was left alone). Empty __init__.py files and pure HTML header boilerplate remain legitimately isolated — they have nothing to connect to.
Environment
graphify v8 (installed via uv pip install; graphify.__version__ is not exposed and importlib.metadata.version("graphify") raises PackageNotFoundError, so I can't give an exact build string). Python 3.12. The behavior is deterministic and reproduces from the snippet above.
Summary
The Python AST extractor generates inconsistent node ids for methods decorated with
@property/@staticmethod: the method node gets a class-unqualified id, but therationale_foredge that links the method's docstring/comment targets a class-qualified id. The two ids don't match, sobuild_from_jsontreats the edge as dangling and drops it (thesrc not in node_set or tgt not in node_set: continuebranch). The result is orphaned (degree-0)rationalenodes — every docstring/comment on a decorated method becomes a disconnected node in the graph.The same class-qualified-vs-not mismatch can also drop
calls/referencesedges that point into decorated methods, so those methods are under-connected in general.Minimal reproduction
repro.py:Observed output:
Root cause
For a decorated method, the node-id derivation omits the enclosing class (id
file_method, labelmethod()with no leading dot), while therationale_foredge-target derivation keeps the class (idfile_class_method). The two code paths disagree, so the edge endpoint never resolves and the edge is silently discarded as "external/stdlib".Larger-corpus evidence
On a real ~8k-LOC Python project (294 docstring/comment
rationalenodes): 38rationalenodes were isolated. For all 38, the source endpoint (the rationale node) was present and only the target (the parent method) was missing; all 38 targets resolved by removing exactly one interior id segment (the class), and every parent was verified to be a@propertyor@staticmethod.Suggested fix
Make the decorated-method node id consistent with the edge-target id — i.e. include the enclosing class segment for
@property/@staticmethodmethods just as for regular instance methods (and ideally keep the leading-dot label convention too). Either derivation is fine as long as both sides use the same one.Workaround (post-extraction)
Remap an edge endpoint that is missing from the node set by dropping one interior id segment when exactly one candidate exists:
This reconnected 37/38 orphaned nodes in the corpus above (the 1 remainder had an ambiguous declassification and was left alone). Empty
__init__.pyfiles and pure HTML header boilerplate remain legitimately isolated — they have nothing to connect to.Environment
graphify
v8(installed viauv pip install;graphify.__version__is not exposed andimportlib.metadata.version("graphify")raisesPackageNotFoundError, so I can't give an exact build string). Python 3.12. The behavior is deterministic and reproduces from the snippet above.