Skip to content

AST: @property/@staticmethod methods get class-unqualified node ids but class-qualified rationale_for edge targets → docstring nodes orphaned at build #1050

Description

@p-gueguen

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions