Repository navigation
Description
Problem
At -a 2, one decorator on a function produces a call-graph edge and another on the same
function produces none. Verified on main (6f02581) with a fixture whose module-level
plain_function carries @trace('hot') and @functools.lru_cache(maxsize=128):
app -> trace(tag) ['pycg']
Account -> trace(tag) ['pycg']
deco(fn) -> wraps ['jedi', 'pycg']
functools.wraps gets an edge. functools.lru_cache — same module, same file, same decorator
position — gets none, and never appears in external_symbols either.
This was found while investigating #129. That issue closed because decoration is being made
recoverable through qualified_name rather than through edges (see
the spec, D5),
but the missing edge is a separate defect that survives that decision: it is a hole in the call
graph, not a question about how decoration is modelled.
Scope boundary
Diagnoses and fixes why this call edge is missing. Does not change how decoration is
represented (that is #128), does not add a decoration edge family, and does not re-attribute the
existing app -> trace(tag) edges — those are correct for evaluation order.
Goals
- Root-cause why
lru_cacheis dropped wherewrapsis not — written down, not just patched - Determine whether the cause is specific to decorators-with-arguments, or affects ordinary
calls too; if the latter, the fix belongs at the shared site, not the decorator path - Missing edge emitted, with its target homed in
external_symbols - Regression test with a hand-written expected edge set
Caveats and known risks
- Fix the cause, not the symptom. If PyCG mishandles a broader class of calls, patching only
the decorator path leaves every sibling caller still broken. - PyCG is archived upstream (Nov 2023) and already carries local patches in
semantic_analysis/pycg/pycg_analysis.py. A fix likely lands as another local patch, which is
maintenance debt worth naming rather than absorbing silently. - L2 contract. New edges at L2 are additive and safe for
L1 ⊆ L2. Changing existing edge
endpoints is not, and is out of scope. - The Jedi backend may already resolve this correctly — worth checking whether the gap is
PyCG-only before patching PyCG.
Definition of done
- The root cause is stated in the PR description, not just fixed
@functools.lru_cache(maxsize=128)produces a call edge whose target is homed in
external_symbols- The expected edge set for the fixture is written by hand first, then matched exactly
analysis.json(-a 1) ⊆ analysis.json(-a 2)still holds
Activity
[-]PyCG drops the call edge for decorators with arguments (functools.lru_cache)[/-][+]Module-scope calls to library targets are dropped from the call graph[/+]rahlk commentedon Aug 20, 2026
Diagnosed — the original title was wrong on both counts, so I have retitled it.
PyCG drops nothing. Its raw output for the fixture below contains both missing edges:
They are discarded downstream, in
filter_external_edges(semantic_analysis/call_graph.py). That function keeps an edge when either endpoint is an app symbol, but buildsapp_symbolsfrom callables and classes only — never modules. PyCG attributes a call made in module scope to the MODULE, soapp -> functools.reducehas a module name on one end and a library on the other, matches neither set, and is dropped as if both ends were third-party.Not about arguments. A matrix over scope × called-or-bare × first-party-or-stdlib:
@local_bare@local_factory("x")@functools.cache@functools.lru_cache(maxsize=128)@functools.wraps(outer)@functools.cachetakes no arguments and fails;@local_factory("x")takes one and works.Not about decorators either. A plain module-level call with no decorator anywhere is lost identically:
So every module-level call into stdlib or a dependency was missing from the call graph of every analyzed project. Decorators were only how it surfaced — which is why this issue asked for a root cause rather than a patch to the decorator path.
Fixed in #143 by adding module names to
app_symbols. They come from the analyzed symbol table rather than a denylist, so a first-party module shadowing a library name is still an app symbol (tested). The filter's real purpose is unchanged — a genuine library-to-library edge is still dropped.feat(callgraph): harden the defuse linker against Joern and Fraunhofe…