From a77918aae34d4c22f1dc2b0f05d64c3f8043c28e Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Thu, 20 Aug 2026 16:58:43 -0400 Subject: [PATCH] fix(call-graph): keep module-scope calls to library targets (#131) Reported as "PyCG drops the call edge for decorators with arguments". PyCG drops nothing -- its raw output contains `app -> functools.cache` and `app -> functools.lru_cache`. The edges were discarded downstream, by us. `filter_external_edges` keeps an edge when either endpoint is an app symbol, but built `app_symbols` from callables and classes only, never modules. PyCG attributes a call made in module scope to the MODULE, so `app -> functools.reduce` had a module name on one end and a library on the other, matched neither set, and was dropped as though both ends were third-party. The diagnosis contradicts the title twice. It is not about arguments: `@functools.cache` takes none and was dropped, while `@local_factory("x")` takes one and survived. And it is not about decorators: a plain module-level `TOTAL = functools.reduce(...)`, with no decorator anywhere, was lost the same way. The real discriminator is module scope plus an external target, 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 got noticed. Fix: module names join `app_symbols`. They come from the analyzed symbol table rather than a denylist, so a first-party module shadowing a library name is still correctly an app symbol -- covered by a test. The filter's actual purpose is unchanged: a genuine library-to-library edge is still dropped. --- codeanalyzer/semantic_analysis/call_graph.py | 10 +++ test/test_call_graph_module_scope_edges.py | 68 ++++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 test/test_call_graph_module_scope_edges.py diff --git a/codeanalyzer/semantic_analysis/call_graph.py b/codeanalyzer/semantic_analysis/call_graph.py index 40273e4..05715a7 100644 --- a/codeanalyzer/semantic_analysis/call_graph.py +++ b/codeanalyzer/semantic_analysis/call_graph.py @@ -254,9 +254,19 @@ def filter_external_edges( walking every callable in the symbol table recursively (including nested functions and closures via ``callables``) plus every class, so PyCG-discovered closure nodes are correctly recognised as app symbols. + + Module names count as app symbols too (#131). PyCG attributes a call made in + module scope to the MODULE -- ``app -> functools.reduce`` for a module-level + ``functools.reduce(...)``, or for a decorator applied to a top-level + definition, since a decorator executes in its enclosing scope. Without the + module names here, both endpoints looked third-party and every module-scope + call to a library target was discarded as lib→lib. """ app_symbols: set = {c.signature for c in iter_callables_in_symbol_table(symbol_table)} app_symbols.update(cls.signature for cls in iter_classes_in_symbol_table(symbol_table)) + app_symbols.update( + mod.module_name for mod in symbol_table.values() if mod.module_name + ) return [ e for e in edges diff --git a/test/test_call_graph_module_scope_edges.py b/test/test_call_graph_module_scope_edges.py new file mode 100644 index 0000000..9d8d61a --- /dev/null +++ b/test/test_call_graph_module_scope_edges.py @@ -0,0 +1,68 @@ +"""Module-scope calls to library targets are not dropped as lib->lib (#131). + +`filter_external_edges` keeps an edge when either endpoint is an app symbol, but +it built `app_symbols` from callables and classes only. PyCG attributes a call in +module scope to the MODULE (`app -> functools.reduce`), and a module name was in +neither set — so every module-level call to a library target was discarded as if +both ends were third-party. + +Surfaced as "decorators with arguments are dropped", but decorators are only how +it was noticed: a plain `TOTAL = functools.reduce(...)` at module scope was lost +the same way. +""" +from codeanalyzer.schema.py_schema import PyCallable, PyCallEdge, PyModule +from codeanalyzer.semantic_analysis.call_graph import filter_external_edges + + +def _symbol_table() -> dict: + fn = PyCallable(name="in_a_function", path="app.py", signature="app.in_a_function") + return { + "app.py": PyModule( + file_path="app.py", + module_name="app", + functions={"in_a_function": fn}, + ) + } + + +def test_module_scope_call_to_a_library_target_is_kept(): + st = _symbol_table() + edges = [PyCallEdge(src="app", dst="functools.reduce", weight=1, prov=["pycg"])] + assert filter_external_edges(edges, st) == edges + + +def test_module_scope_decorator_application_is_kept(): + """The symptom in the issue title: a decorator applied at module scope.""" + st = _symbol_table() + edges = [ + PyCallEdge(src="app", dst="functools.lru_cache", weight=1, prov=["pycg"]), + PyCallEdge(src="app", dst="functools.cache", weight=1, prov=["pycg"]), + ] + assert filter_external_edges(edges, st) == edges + + +def test_a_genuine_library_to_library_edge_is_still_dropped(): + """The filter's actual purpose must survive the fix.""" + st = _symbol_table() + edges = [PyCallEdge(src="os.path.join", dst="os.sep", weight=1, prov=["pycg"])] + assert filter_external_edges(edges, st) == [] + + +def test_callable_endpoints_are_unaffected(): + st = _symbol_table() + edges = [ + PyCallEdge(src="app.in_a_function", dst="functools.reduce", weight=1, prov=["jedi"]), + PyCallEdge(src="app", dst="app.in_a_function", weight=1, prov=["pycg"]), + ] + assert filter_external_edges(edges, st) == edges + + +def test_a_module_named_like_a_library_is_still_an_app_symbol(): + """Module names come from the analyzed symbol table, so shadowing is fine.""" + st = { + "functools.py": PyModule( + file_path="functools.py", module_name="functools", functions={} + ) + } + edges = [PyCallEdge(src="functools", dst="os.getcwd", weight=1, prov=["pycg"])] + assert filter_external_edges(edges, st) == edges