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

Closed

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.

Activity

  1. safishamsi commented on May 27, 2026

    @safishamsi
    Member

    Fixed in v8 (commit 9f73400). Root cause: in tree-sitter-python, @property/@staticmethod/@classmethod wrap the inner function_definition in a decorated_definition node. The main walk() recurser in _extract_generic had no branch for decorated_definition, so it fell through to the default recurse which resets parent_class_nid=None. The inner method was then visited with no class context, producing a class-unqualified node ID (..._baz) and label baz() (no leading dot). Meanwhile the rationale walker propagated parent_nid across the wrapper correctly, targeting ..._bar_baz. Mismatch → edge dropped → docstring orphaned.\n\nFix: added a decorated_definition branch that treats the wrapper as transparent, forwarding parent_class_nid unchanged to its children. Decorated methods now produce class-qualified IDs and leading-dot labels identical to regular instance methods. Regression test added in tests/test_rationale.py covering @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.

  2. added a commit that references this issue on Jul 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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