Repository navigation
AST: @property/@staticmethod methods get class-unqualified node ids but class-qualified rationale_for edge targets → docstring nodes orphaned at build #1050
Description
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 3from 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 NoneThis 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.
Activity
fix: memory-dir gitignore leak, Pass 2 dedup cross-file identical mer…
safishamsi commentedon May 27, 2026
Fixed in v8 (commit 9f73400). Root cause: in tree-sitter-python,
@property/@staticmethod/@classmethodwrap the innerfunction_definitionin adecorated_definitionnode. The mainwalk()recurser in_extract_generichad no branch fordecorated_definition, so it fell through to the default recurse which resetsparent_class_nid=None. The inner method was then visited with no class context, producing a class-unqualified node ID (..._baz) and labelbaz()(no leading dot). Meanwhile the rationale walker propagatedparent_nidacross the wrapper correctly, targeting..._bar_baz. Mismatch → edge dropped → docstring orphaned.\n\nFix: added adecorated_definitionbranch that treats the wrapper as transparent, forwardingparent_class_nidunchanged to its children. Decorated methods now produce class-qualified IDs and leading-dot labels identical to regular instance methods. Regression test added intests/test_rationale.pycovering@property,@staticmethod,@classmethod— verifies class-qualified IDs, no dangling rationale edges, and docstring degree > 0 after build. 1391/1391 tests pass. Outstanding diagnosis on your end.feat: BYOND DreamMaker support, --mode deep flag, changelog fixes (#884…
fix(extract): emit references edges for Python decorators (#2154)
test(extract): cover Python decorator reference edges (#2154)
Squashed commit of the following:
fix: memory-dir gitignore leak, Pass 2 dedup cross-file identical mer…
feat: BYOND DreamMaker support, --mode deep flag, changelog fixes (Gr…
fix(extract): emit references edges for Python decorators (Graphify-L…
test(extract): cover Python decorator reference edges (Graphify-Labs#…
fix(extract): keep Scala file-scope val/var type references (#2054)