cg-code-graph 0.10.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. cg_code_graph-0.10.1.dist-info/METADATA +678 -0
  2. cg_code_graph-0.10.1.dist-info/RECORD +174 -0
  3. cg_code_graph-0.10.1.dist-info/WHEEL +5 -0
  4. cg_code_graph-0.10.1.dist-info/entry_points.txt +3 -0
  5. cg_code_graph-0.10.1.dist-info/licenses/LICENSE +21 -0
  6. cg_code_graph-0.10.1.dist-info/top_level.txt +1 -0
  7. codegraph/__init__.py +2 -0
  8. codegraph/aitools.py +129 -0
  9. codegraph/apps.py +76 -0
  10. codegraph/blindspots.py +428 -0
  11. codegraph/bridges.py +1701 -0
  12. codegraph/cli.py +725 -0
  13. codegraph/concepts.py +362 -0
  14. codegraph/config.py +559 -0
  15. codegraph/core/__init__.py +0 -0
  16. codegraph/core/cache.py +375 -0
  17. codegraph/core/detect.py +80 -0
  18. codegraph/core/extractors.py +187 -0
  19. codegraph/core/fsutil.py +61 -0
  20. codegraph/core/generated.py +575 -0
  21. codegraph/core/model.py +174 -0
  22. codegraph/core/paths.py +175 -0
  23. codegraph/core/plugin.py +160 -0
  24. codegraph/core/store.py +80 -0
  25. codegraph/core/syntax_errors.py +132 -0
  26. codegraph/coverage.py +928 -0
  27. codegraph/doctor.py +453 -0
  28. codegraph/external.py +613 -0
  29. codegraph/indexer.py +336 -0
  30. codegraph/link.py +434 -0
  31. codegraph/lint_async.py +524 -0
  32. codegraph/mcp_server.py +1303 -0
  33. codegraph/parity.py +473 -0
  34. codegraph/parity_structure.py +307 -0
  35. codegraph/payload.py +321 -0
  36. codegraph/plans.py +1285 -0
  37. codegraph/platform_scan.py +643 -0
  38. codegraph/platforms.py +1369 -0
  39. codegraph/plugins/__init__.py +0 -0
  40. codegraph/plugins/cfamily/__init__.py +0 -0
  41. codegraph/plugins/cfamily/plugin.py +930 -0
  42. codegraph/plugins/cfamily/syntax.py +881 -0
  43. codegraph/plugins/dart/__init__.py +0 -0
  44. codegraph/plugins/dart/bridges.py +345 -0
  45. codegraph/plugins/dart/extractor/bin/extract.dart +717 -0
  46. codegraph/plugins/dart/extractor/pubspec.lock +149 -0
  47. codegraph/plugins/dart/extractor/pubspec.yaml +7 -0
  48. codegraph/plugins/dart/http.py +904 -0
  49. codegraph/plugins/dart/models.py +308 -0
  50. codegraph/plugins/dart/plugin.py +625 -0
  51. codegraph/plugins/dart/program.py +907 -0
  52. codegraph/plugins/django/__init__.py +0 -0
  53. codegraph/plugins/django/extras.py +378 -0
  54. codegraph/plugins/django/models.py +508 -0
  55. codegraph/plugins/django/plugin.py +728 -0
  56. codegraph/plugins/django/schemas.py +339 -0
  57. codegraph/plugins/django/shapes.py +216 -0
  58. codegraph/plugins/django/urls.py +603 -0
  59. codegraph/plugins/express/__init__.py +0 -0
  60. codegraph/plugins/express/plugin.py +428 -0
  61. codegraph/plugins/flutter/__init__.py +0 -0
  62. codegraph/plugins/flutter/plugin.py +538 -0
  63. codegraph/plugins/kotlin/__init__.py +0 -0
  64. codegraph/plugins/kotlin/exact.py +457 -0
  65. codegraph/plugins/kotlin/plugin.py +1961 -0
  66. codegraph/plugins/kotlin/reparse.py +234 -0
  67. codegraph/plugins/laravel/__init__.py +0 -0
  68. codegraph/plugins/laravel/broadcast.py +351 -0
  69. codegraph/plugins/laravel/plugin.py +863 -0
  70. codegraph/plugins/laravel/tests.py +262 -0
  71. codegraph/plugins/laravel/values.py +728 -0
  72. codegraph/plugins/native/__init__.py +0 -0
  73. codegraph/plugins/native/gates.py +286 -0
  74. codegraph/plugins/native/runner.py +183 -0
  75. codegraph/plugins/native/scipread.py +194 -0
  76. codegraph/plugins/native/ts.py +54 -0
  77. codegraph/plugins/nest/__init__.py +0 -0
  78. codegraph/plugins/nest/plugin.py +654 -0
  79. codegraph/plugins/nextjs/__init__.py +0 -0
  80. codegraph/plugins/nextjs/plugin.py +336 -0
  81. codegraph/plugins/nuxt/__init__.py +0 -0
  82. codegraph/plugins/nuxt/plugin.py +308 -0
  83. codegraph/plugins/php/__init__.py +0 -0
  84. codegraph/plugins/php/extractor/composer.json +5 -0
  85. codegraph/plugins/php/extractor/composer.lock +76 -0
  86. codegraph/plugins/php/extractor/extract.php +743 -0
  87. codegraph/plugins/php/gating.py +573 -0
  88. codegraph/plugins/php/plugin.py +668 -0
  89. codegraph/plugins/php/strings.py +197 -0
  90. codegraph/plugins/python/__init__.py +0 -0
  91. codegraph/plugins/python/aitools.py +664 -0
  92. codegraph/plugins/python/external.py +245 -0
  93. codegraph/plugins/python/fields.py +107 -0
  94. codegraph/plugins/python/plugin.py +1733 -0
  95. codegraph/plugins/python/refs.py +485 -0
  96. codegraph/plugins/python/roots.py +412 -0
  97. codegraph/plugins/python/socketio.py +210 -0
  98. codegraph/plugins/python/subproc.py +864 -0
  99. codegraph/plugins/python/tests.py +1040 -0
  100. codegraph/plugins/python/values.py +179 -0
  101. codegraph/plugins/pyweb/__init__.py +0 -0
  102. codegraph/plugins/pyweb/plugin.py +1334 -0
  103. codegraph/plugins/pyweb/values.py +68 -0
  104. codegraph/plugins/rust/__init__.py +0 -0
  105. codegraph/plugins/rust/cargo.py +226 -0
  106. codegraph/plugins/rust/plugin.py +980 -0
  107. codegraph/plugins/rust/syntax.py +678 -0
  108. codegraph/plugins/scip/__init__.py +0 -0
  109. codegraph/plugins/scip/importer.py +129 -0
  110. codegraph/plugins/scip/scip.proto +962 -0
  111. codegraph/plugins/scip/scip_pb2.py +97 -0
  112. codegraph/plugins/stubs/__init__.py +0 -0
  113. codegraph/plugins/stubs/plugins.py +38 -0
  114. codegraph/plugins/swift/__init__.py +0 -0
  115. codegraph/plugins/swift/baseurl.py +109 -0
  116. codegraph/plugins/swift/exact.py +415 -0
  117. codegraph/plugins/swift/indexstore.py +209 -0
  118. codegraph/plugins/swift/packages.py +174 -0
  119. codegraph/plugins/swift/plugin.py +2890 -0
  120. codegraph/plugins/ts/__init__.py +0 -0
  121. codegraph/plugins/ts/baseurl.py +185 -0
  122. codegraph/plugins/ts/extractor/extract.mjs +2652 -0
  123. codegraph/plugins/ts/extractor/fw.mjs +685 -0
  124. codegraph/plugins/ts/extractor/package-lock.json +205 -0
  125. codegraph/plugins/ts/extractor/package.json +9 -0
  126. codegraph/plugins/ts/plugin.py +480 -0
  127. codegraph/plugins/tsweb/__init__.py +0 -0
  128. codegraph/plugins/tsweb/common.py +290 -0
  129. codegraph/plugins/tsweb/data.py +276 -0
  130. codegraph/presets/__init__.py +146 -0
  131. codegraph/presets/c_cpp.yaml +9 -0
  132. codegraph/presets/common.yaml +66 -0
  133. codegraph/presets/dart.yaml +9 -0
  134. codegraph/presets/django-ninja.yaml +15 -0
  135. codegraph/presets/django.yaml +25 -0
  136. codegraph/presets/djangorestframework.yaml +17 -0
  137. codegraph/presets/express.yaml +17 -0
  138. codegraph/presets/kotlin.yaml +11 -0
  139. codegraph/presets/laravel.yaml +40 -0
  140. codegraph/presets/nest.yaml +11 -0
  141. codegraph/presets/nextjs.yaml +15 -0
  142. codegraph/presets/nuxt.yaml +9 -0
  143. codegraph/presets/php.yaml +5 -0
  144. codegraph/presets/python.yaml +10 -0
  145. codegraph/presets/rust.yaml +5 -0
  146. codegraph/presets/swift.yaml +10 -0
  147. codegraph/presets/typescript.yaml +13 -0
  148. codegraph/process_runs.py +328 -0
  149. codegraph/protocols/__init__.py +299 -0
  150. codegraph/protocols/builtin.py +67 -0
  151. codegraph/protocols/matchers.py +144 -0
  152. codegraph/protocols/view.py +334 -0
  153. codegraph/query.py +2089 -0
  154. codegraph/realtime.py +260 -0
  155. codegraph/roundtrip.py +346 -0
  156. codegraph/routes.py +442 -0
  157. codegraph/starters.py +218 -0
  158. codegraph/tests_index.py +117 -0
  159. codegraph/viz/__init__.py +0 -0
  160. codegraph/viz/graph.py +369 -0
  161. codegraph/viz/server.py +198 -0
  162. codegraph/viz/static/app.css +148 -0
  163. codegraph/viz/static/app.js +1082 -0
  164. codegraph/viz/static/index.html +81 -0
  165. codegraph/viz/static/layered.js +237 -0
  166. codegraph/viz/static/vendor/VERSIONS.txt +4 -0
  167. codegraph/viz/static/vendor/cose-base.js +3214 -0
  168. codegraph/viz/static/vendor/cytoscape-fcose.js +1549 -0
  169. codegraph/viz/static/vendor/cytoscape.min.js +31 -0
  170. codegraph/viz/static/vendor/layout-base.js +5230 -0
  171. codegraph/viz/tools/package-lock.json +303 -0
  172. codegraph/viz/tools/package.json +7 -0
  173. codegraph/viz/tools/shoot.mjs +165 -0
  174. codegraph/xcode.py +251 -0
@@ -0,0 +1,485 @@
1
+ """Python entry points and function references (stdlib `ast` + `tomllib`, no code execution).
2
+
3
+ Entry points (`script` nodes, CALLS to what they run):
4
+ * `if __name__ == "__main__":` blocks -> `script:<module>` (entry `main`, "python -m pkg.mod");
5
+ * `pkg/__main__.py` -> `script:pkg.__main__` (entry `main`, "python -m pkg"), its whole module body;
6
+ * packaging entry points: PEP 621 `[project.scripts]` / `[project.gui-scripts]` / `[project.entry-points.<group>]`,
7
+ Poetry `[tool.poetry.scripts]` / `[tool.poetry.plugins.<group>]`, `setup.cfg [options.entry_points]` and a
8
+ literal `setup(entry_points=...)` -> `script:<group>:<name>` (entry `main` for console / GUI scripts,
9
+ `public_api` for plugin groups such as `pytest11`), resolved through the detected source roots.
10
+ * registrations with well-known frameworks: MCP servers (`@mcp.tool()`, `mcp.tool()(fn)`, `add_tool`) ->
11
+ `message_handler`; click / typer / Flask CLI commands -> `cli_command`. A local decorator that wraps its argument
12
+ and registers the wrapper passes the registration on to every function it decorates.
13
+
14
+ Function references (REFERENCES_FN, propagating, attrs.how):
15
+ * collection - an element of a tuple / list / set / dict literal (dispatch tables, module constants);
16
+ * callback - an argument to a call (`map(f, xs)`, `Thread(target=f)`, `atexit.register(f)`, `run(f, x)`);
17
+ * assignment - assigned, returned or used as a default value (`handler = f`, `return f`);
18
+ * decorator - the decorated function, from the decorator that receives it (a local decorator function, a
19
+ local registry method, `@group.command()` on a function object) or, for an external registering
20
+ decorator (`@app.route`, `@registry.register`), from the module (heuristic).
21
+ Decorator applications are calls too: `@deco` adds CALLS module -> deco (attrs.via = "decorator").
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import ast
26
+ import configparser
27
+ import re
28
+ from pathlib import Path
29
+
30
+ from ...blindspots import REGISTER_DECOS, ROUTE_DECOS
31
+ from ...core.model import EXACT, HEURISTIC, RESOLVED
32
+
33
+ MCP_SERVERS = ("FastMCP", "MCPServer")
34
+ MCP_DECOS = {"tool", "resource", "prompt"}
35
+ MCP_ADDERS = {"add_tool": "tool", "add_resource": "resource", "add_prompt": "prompt"}
36
+ CLICK_LIBS = ("click", "asyncclick", "rich_click", "cloup")
37
+ COLLECTION_ADDS = {"append", "appendleft", "add", "insert", "extend", "setdefault"}
38
+ SCRIPT_GROUPS = {"console_scripts": "console script", "gui_scripts": "GUI script"}
39
+
40
+
41
+ def is_main_guard(test) -> bool:
42
+ """`__name__ == "__main__"` (either side)."""
43
+ if not (isinstance(test, ast.Compare) and len(test.ops) == 1 and isinstance(test.ops[0], ast.Eq)):
44
+ return False
45
+ sides = [test.left, test.comparators[0]]
46
+ return any(isinstance(x, ast.Name) and x.id == "__name__" for x in sides) and \
47
+ any(isinstance(x, ast.Constant) and x.value == "__main__" for x in sides)
48
+
49
+
50
+ # --------------------------------------------------------------------------- packaging entry points
51
+
52
+ def _line_of(text: str, name: str) -> int:
53
+ pat = re.compile(r"^\s*['\"]?" + re.escape(name) + r"['\"]?\s*[=:]", re.M)
54
+ m = pat.search(text)
55
+ return text.count("\n", 0, m.start()) + 1 if m else 1
56
+
57
+
58
+ def _ini_entries(text: str) -> list[tuple[str, str]]:
59
+ out = []
60
+ for ln in text.splitlines():
61
+ ln = ln.strip()
62
+ if "=" in ln and not ln.startswith(("#", ";", "[")):
63
+ k, v = ln.split("=", 1)
64
+ out.append((k.strip(), v.strip()))
65
+ return out
66
+
67
+
68
+ def _pyproject_entries(text: str) -> list[tuple[str, str, str, str]]:
69
+ """(group, name, target, declared_in)"""
70
+ try:
71
+ import tomllib
72
+ d = tomllib.loads(text)
73
+ except Exception: # noqa: BLE001 (invalid TOML: no entry points from this file)
74
+ return []
75
+ out = []
76
+ proj = d.get("project") if isinstance(d.get("project"), dict) else {}
77
+ for key, group in (("scripts", "console_scripts"), ("gui-scripts", "gui_scripts")):
78
+ for k, v in (proj.get(key) or {}).items() if isinstance(proj.get(key), dict) else ():
79
+ if isinstance(v, str):
80
+ out.append((group, k, v, f"[project.{key}]"))
81
+ eps = proj.get("entry-points") if isinstance(proj.get("entry-points"), dict) else {}
82
+ for group, items in eps.items():
83
+ for k, v in (items or {}).items() if isinstance(items, dict) else ():
84
+ if isinstance(v, str):
85
+ out.append((group, k, v, f"[project.entry-points.{group}]"))
86
+ tool = d.get("tool") if isinstance(d.get("tool"), dict) else {}
87
+ poetry = tool.get("poetry") if isinstance(tool.get("poetry"), dict) else {}
88
+ for k, v in (poetry.get("scripts") or {}).items() if isinstance(poetry.get("scripts"), dict) else ():
89
+ if isinstance(v, dict):
90
+ v = v.get("reference") or v.get("callable") if v.get("type", "console") == "console" else None
91
+ if isinstance(v, str):
92
+ out.append(("console_scripts", k, v, "[tool.poetry.scripts]"))
93
+ for group, items in (poetry.get("plugins") or {}).items() if isinstance(poetry.get("plugins"), dict) else ():
94
+ for k, v in (items or {}).items() if isinstance(items, dict) else ():
95
+ if isinstance(v, str):
96
+ out.append((group, k, v, f"[tool.poetry.plugins.{group}]"))
97
+ flit = tool.get("flit") if isinstance(tool.get("flit"), dict) else {}
98
+ for k, v in (flit.get("scripts") or {}).items() if isinstance(flit.get("scripts"), dict) else ():
99
+ if isinstance(v, str):
100
+ out.append(("console_scripts", k, v, "[tool.flit.scripts]"))
101
+ return out
102
+
103
+
104
+ def _setup_cfg_entries(text: str) -> list[tuple[str, str, str, str]]:
105
+ cp = configparser.ConfigParser(interpolation=None)
106
+ try:
107
+ cp.read_string(text)
108
+ except configparser.Error:
109
+ return []
110
+ if not cp.has_section("options.entry_points"):
111
+ return []
112
+ out = []
113
+ for group, val in cp.items("options.entry_points"):
114
+ for k, v in _ini_entries(val):
115
+ out.append((group, k, v, "[options.entry_points]"))
116
+ return out
117
+
118
+
119
+ def _setup_py_entries(text: str) -> list[tuple[str, str, str, str]]:
120
+ try:
121
+ tree = ast.parse(text)
122
+ except (SyntaxError, ValueError, RecursionError):
123
+ return []
124
+ consts = {}
125
+ for st in tree.body:
126
+ if isinstance(st, ast.Assign) and len(st.targets) == 1 and isinstance(st.targets[0], ast.Name):
127
+ consts[st.targets[0].id] = st.value
128
+ out = []
129
+
130
+ def strs(v):
131
+ if isinstance(v, ast.Name):
132
+ v = consts.get(v.id)
133
+ if isinstance(v, ast.Constant) and isinstance(v.value, str):
134
+ return [x for x in v.value.splitlines() if x.strip()]
135
+ if isinstance(v, (ast.List, ast.Tuple)):
136
+ return [x.value for x in v.elts if isinstance(x, ast.Constant) and isinstance(x.value, str)]
137
+ return []
138
+ for n in ast.walk(tree):
139
+ if not isinstance(n, ast.Call):
140
+ continue
141
+ fname = n.func.attr if isinstance(n.func, ast.Attribute) else n.func.id if isinstance(n.func, ast.Name) else ""
142
+ if fname != "setup":
143
+ continue
144
+ for kw in n.keywords:
145
+ if kw.arg != "entry_points":
146
+ continue
147
+ v = consts.get(kw.value.id) if isinstance(kw.value, ast.Name) else kw.value
148
+ if isinstance(v, ast.Dict):
149
+ for k, items in zip(v.keys, v.values):
150
+ if isinstance(k, ast.Constant) and isinstance(k.value, str):
151
+ for line in strs(items):
152
+ if "=" in line:
153
+ a, b = line.split("=", 1)
154
+ out.append((k.value, a.strip(), b.strip(), "setup(entry_points=...)"))
155
+ elif isinstance(v, ast.Constant) and isinstance(v.value, str): # ini-style string
156
+ group = None
157
+ for line in v.value.splitlines():
158
+ line = line.strip()
159
+ if line.startswith("[") and line.endswith("]"):
160
+ group = line[1:-1].strip()
161
+ elif group and "=" in line:
162
+ a, b = line.split("=", 1)
163
+ out.append((group, a.strip(), b.strip(), "setup(entry_points=...)"))
164
+ return out
165
+
166
+
167
+ def packaging_entry_points(root: Path, project_dirs: list[str]) -> list[dict]:
168
+ out = []
169
+ for pd in project_dirs:
170
+ base = root / pd if pd else root
171
+ for fn, parse in (("pyproject.toml", _pyproject_entries), ("setup.cfg", _setup_cfg_entries),
172
+ ("setup.py", _setup_py_entries)):
173
+ p = base / fn
174
+ if not p.is_file():
175
+ continue
176
+ try:
177
+ text = p.read_text(encoding="utf-8", errors="replace")
178
+ except OSError:
179
+ continue
180
+ rel = f"{pd}/{fn}" if pd else fn
181
+ for group, name, target, where in parse(text):
182
+ out.append({"project": pd, "file": rel, "line": _line_of(text, name), "group": group, "name": name,
183
+ "target": target, "declared_in": f"{rel} {where}"})
184
+ return out
185
+
186
+
187
+ # --------------------------------------------------------------------------- the pass
188
+
189
+ class RefPass:
190
+ def __init__(self, prog, b, ctx_cls):
191
+ self.prog, self.b, self.Ctx = prog, b, ctx_cls
192
+ self.fn_by_node = {id(f.node): f for f in prog.funcs.values()}
193
+ self.cls_by_node = {id(c.node): c for c in prog.classes.values()}
194
+ self.names = {f.name for f in prog.funcs.values()}
195
+ self.stats = {"references": {}, "decorator_calls": 0, "main_blocks": 0, "entry_points": 0,
196
+ "entry_points_unresolved": [], "registrations": {}}
197
+ self.registrars: dict = {} # FuncInfo.id -> (FuncInfo, kind, attrs): wraps and registers its argument
198
+ self.decorated_by: dict = {} # FuncInfo.id -> [FuncInfo] decorated with it
199
+ self.passed: list = [] # (call, ctx, FuncInfo) first argument of a call is a known function
200
+ self.var_regs: dict = {} # (module name, var) -> [FuncInfo] registered on that object (typer app)
201
+ self.obj_calls: list = [] # (script id, (module name, var), file, line): a script calls a CLI object
202
+
203
+ # ---- helpers
204
+ def ref(self, src, f, how, file, line, conf=RESOLVED, **attrs):
205
+ if src == f.id:
206
+ return
207
+ self.b.add_edge(src, f.id, "REFERENCES_FN", file, line, conf, how=how, **attrs)
208
+ self.stats["references"][how] = self.stats["references"].get(how, 0) + 1
209
+
210
+ def func_of(self, t):
211
+ if t and t[0] in ("func", "bound"):
212
+ return t[1]
213
+ return None
214
+
215
+ def value_ref(self, t):
216
+ """The function a value expression refers to; None for a property (reading it runs the getter)."""
217
+ f = self.func_of(t)
218
+ if f is None or (t[0] == "bound" and is_property(f)):
219
+ return None
220
+ return f
221
+
222
+ def set_entry(self, f, kind, attrs):
223
+ n = self.b.nodes.get(f.id)
224
+ if n is None:
225
+ return
226
+ reg = dict(attrs)
227
+ if n.attrs is None:
228
+ n.attrs = {}
229
+ n.attrs.setdefault("registrations", [])
230
+ if reg not in n.attrs["registrations"]:
231
+ n.attrs["registrations"].append(reg)
232
+ if n.entry_kind is None:
233
+ n.entry_kind = kind
234
+ elif n.entry_kind != kind:
235
+ n.attrs["entry_kinds"] = sorted(set(n.attrs.get("entry_kinds", [n.entry_kind])) | {kind})
236
+ key = f"{attrs.get('framework')}:{kind}"
237
+ self.stats["registrations"][key] = self.stats["registrations"].get(key, 0) + 1
238
+
239
+ # ---- framework registrations
240
+ def registration(self, e, ctx):
241
+ """`e` is a decorator expression (without its call) or the callee of a registering call: (kind, attrs)."""
242
+ p = self.prog
243
+ if not isinstance(e, ast.Attribute):
244
+ return None
245
+ attr = e.attr
246
+ recv = p.infer(e.value, ctx)
247
+ rname = recv[1] if recv and recv[0] in ("einst", "ext") and isinstance(recv[1], str) else ""
248
+ last = rname.rsplit(".", 1)[-1]
249
+ if recv and recv[0] == "einst" and last in MCP_SERVERS and attr in (MCP_DECOS | set(MCP_ADDERS)):
250
+ return "message_handler", {"framework": "mcp", "registration": MCP_ADDERS.get(attr, attr)}
251
+ full = p.infer(e, ctx)
252
+ if full and full[0] == "ext" and attr in ("command", "group") and full[1].split(".")[0] in CLICK_LIBS:
253
+ return "cli_command", {"framework": full[1].split(".")[0], "registration": attr}
254
+ if attr in ("command", "group"):
255
+ if recv and recv[0] == "einst" and rname.split(".")[0] in CLICK_LIBS:
256
+ return "cli_command", {"framework": rname.split(".")[0], "registration": attr}
257
+ g = self.func_of(recv)
258
+ if g is not None and any(self._is_click(d, g) for d in g.decorators):
259
+ return "cli_command", {"framework": "click", "registration": attr}
260
+ if recv and recv[0] == "einst" and rname.split(".")[0] == "typer" and attr in ("command", "callback"):
261
+ return "cli_command", {"framework": "typer", "registration": attr}
262
+ if attr == "command" and (dotted(e.value) or "").endswith(".cli"):
263
+ return "cli_command", {"framework": "flask", "registration": "cli.command"}
264
+ return None
265
+
266
+ def _is_click(self, d, g) -> bool:
267
+ de = d.func if isinstance(d, ast.Call) else d
268
+ t = self.prog.infer(de, self.Ctx(g.module, None, None))
269
+ if t and t[0] == "ext" and t[1].split(".")[0] in CLICK_LIBS and t[1].rsplit(".", 1)[-1] in ("group", "command"):
270
+ return True
271
+ return bool(isinstance(de, ast.Attribute) and de.attr in ("group", "command") and
272
+ self.func_of(self.prog.infer(de.value, self.Ctx(g.module, None, None))) is not None)
273
+
274
+ def _recv_var(self, e, ctx):
275
+ """(module, var) of a module-level object a decorator registers on (`app` in `@app.command()`)."""
276
+ if isinstance(e, ast.Attribute) and isinstance(e.value, ast.Name):
277
+ r = self.prog.resolve_name(ctx.mod, e.value.id)
278
+ if r and r[0] == "var":
279
+ return (r[1].name, r[2])
280
+ return None
281
+
282
+ # ---- decorators
283
+ def decorator(self, d, target, owner_id, ctx, file):
284
+ """`target` is the decorated FuncInfo, or None for a nested def (it collapses into the owner)."""
285
+ p = self.prog
286
+ de = d.func if isinstance(d, ast.Call) else d
287
+ reg = self.registration(de, ctx)
288
+ if reg:
289
+ if target is not None:
290
+ self.set_entry(target, reg[0], {**reg[1], "decorator": ann(d), "at": f"{file}:{d.lineno}"})
291
+ rv = self._recv_var(de, ctx)
292
+ if rv:
293
+ self.var_regs.setdefault(rv, []).append(target)
294
+ elif ctx.func is not None:
295
+ self.registrars.setdefault(ctx.func.id, (ctx.func, reg[0], {**reg[1], "decorator": ann(d)}))
296
+ t = p.infer(de, ctx)
297
+ dfn = self.func_of(t)
298
+ if dfn is not None:
299
+ self.b.add_edge(owner_id, dfn.id, "CALLS", file, d.lineno, EXACT if t[0] == "func" else RESOLVED, via="decorator")
300
+ self.stats["decorator_calls"] += 1
301
+ if target is not None:
302
+ self.ref(dfn.id, target, "decorator", file, d.lineno)
303
+ self.decorated_by.setdefault(dfn.id, []).append(target)
304
+ return
305
+ if target is None:
306
+ return
307
+ if isinstance(de, ast.Attribute):
308
+ g = self.func_of(p.infer(de.value, ctx))
309
+ if g is not None: # @group.command() / @dispatcher.register: the function object holds the target
310
+ self.ref(g.id, target, "decorator", file, d.lineno, via=de.attr)
311
+ return
312
+ if reg:
313
+ return
314
+ name = de.attr if isinstance(de, ast.Attribute) else de.id if isinstance(de, ast.Name) else ""
315
+ has_recv = isinstance(de, ast.Attribute)
316
+ if (has_recv and (name in ROUTE_DECOS or name in REGISTER_DECOS or name.startswith("register"))) or \
317
+ (not has_recv and name in REGISTER_DECOS and t is not None):
318
+ if "mock" in (dotted(de) or "").split(".") or (dotted(de) or "").startswith("patch"):
319
+ return
320
+ self.ref(owner_id, target, "decorator", file, d.lineno, HEURISTIC, decorator=ann(d), registry="external")
321
+
322
+ # ---- scope walk
323
+ def scan(self, owner_id, roots, ctx, file, module_scope: bool, calls: list | None = None):
324
+ """roots: [(node, parent)] or [(node, parent, refs)]. Module scope stops at def bodies (decorators and defaults
325
+ stay); function scope walks like `walk_body` (nested defs collapse into the owner, nested classes do not), and
326
+ appends every Call to `calls` in walk_body order. refs=False: walk for calls only (a def's own decorators and
327
+ defaults belong to the enclosing scope)."""
328
+ p, names = self.prog, self.names
329
+ Name, Attribute, Load, Call = ast.Name, ast.Attribute, ast.Load, ast.Call
330
+ defs = (ast.FunctionDef, ast.AsyncFunctionDef)
331
+ stack = [r if len(r) == 3 else (r[0], r[1], True) for r in roots]
332
+ while stack:
333
+ n, par, on = stack.pop()
334
+ t = type(n)
335
+ if t is Name or t is Attribute:
336
+ if t is Attribute:
337
+ stack.append((n.value, n, on))
338
+ if not on or type(n.ctx) is not Load or (n.id if t is Name else n.attr) not in names:
339
+ continue
340
+ how = self._how(n, par)
341
+ if how:
342
+ f = self.value_ref(p.infer(n, ctx))
343
+ if f is not None:
344
+ self.ref(owner_id, f, how, file, n.lineno)
345
+ if how == "callback" and type(par) is Call and par.args and par.args[0] is n:
346
+ self.passed.append((par, ctx, f))
347
+ continue
348
+ if t in defs:
349
+ if module_scope:
350
+ tgt = self.fn_by_node.get(id(n))
351
+ for dd in n.decorator_list:
352
+ self.decorator(dd, tgt, owner_id, ctx, file)
353
+ stack.append((dd, n, on))
354
+ stack.extend((x, n.args, on) for x in n.args.defaults + n.args.kw_defaults if x is not None)
355
+ continue
356
+ if on:
357
+ for dd in n.decorator_list:
358
+ self.decorator(dd, None, owner_id, ctx, file)
359
+ stack.extend((c, n, on) for c in ast.iter_child_nodes(n))
360
+ continue
361
+ if t is ast.ClassDef:
362
+ if module_scope:
363
+ cctx = self.Ctx(ctx.mod, None, self.cls_by_node.get(id(n)))
364
+ inner = n.decorator_list + n.bases + [k.value for k in n.keywords] + n.body
365
+ self.scan(owner_id, [(x, n) for x in inner], cctx, file, True)
366
+ continue
367
+ if t is Call:
368
+ if calls is not None:
369
+ calls.append(n)
370
+ if on:
371
+ self._registration_call(n, owner_id, ctx, file)
372
+ stack.extend((c, n, on) for c in ast.iter_child_nodes(n))
373
+
374
+ @staticmethod
375
+ def _how(n, par):
376
+ """How the function-valued expression `n` is used by its parent node (None: not a reference)."""
377
+ tp = type(par)
378
+ if tp in (ast.List, ast.Tuple, ast.Set, ast.Dict, ast.Starred):
379
+ return "collection"
380
+ if tp is ast.Call:
381
+ if par.func is n:
382
+ return None
383
+ if isinstance(par.func, ast.Attribute) and par.func.attr in COLLECTION_ADDS:
384
+ return "collection" # HANDLERS.append(f), REGISTRY.setdefault(k, f)
385
+ return "callback"
386
+ if tp is ast.keyword:
387
+ return "callback"
388
+ if tp in (ast.Assign, ast.AnnAssign, ast.NamedExpr, ast.Return, ast.Yield):
389
+ return "assignment" if par.value is n else None
390
+ if tp is ast.Lambda:
391
+ return "assignment" if par.body is n else None
392
+ if tp is ast.arguments:
393
+ return "assignment"
394
+ if tp is ast.IfExp:
395
+ return "assignment" if par.test is not n else None
396
+ if tp is ast.BoolOp:
397
+ return "assignment"
398
+ return None
399
+
400
+ def _registration_call(self, call, owner_id, ctx, file):
401
+ """`mcp.tool()(fn)`, `mcp.add_tool(fn)`, `app.command()(fn)`: fn (or the closure that wraps the owner's
402
+ argument) becomes an entry point."""
403
+ fn = call.func
404
+ inner = fn.func if isinstance(fn, ast.Call) else fn if isinstance(fn, ast.Attribute) and fn.attr in MCP_ADDERS else None
405
+ if inner is None or not call.args:
406
+ return
407
+ reg = self.registration(inner, ctx)
408
+ if not reg:
409
+ return
410
+ a = call.args[0]
411
+ f = self.func_of(self.prog.infer(a, ctx))
412
+ attrs = {**reg[1], "call": ann(call)[:120], "at": f"{file}:{call.lineno}"}
413
+ if f is not None:
414
+ self.set_entry(f, reg[0], attrs)
415
+ elif ctx.func is not None and isinstance(a, (ast.Name, ast.Lambda)):
416
+ self.registrars.setdefault(ctx.func.id, (ctx.func, reg[0], attrs))
417
+
418
+ def object_calls(self, sid, stmts, ctx, file):
419
+ """`app()` / `cli()` in a script body where `app` is a module-level object (typer app, click group object)."""
420
+ for st in stmts:
421
+ if isinstance(st, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
422
+ continue
423
+ for sub in ast.walk(st):
424
+ if isinstance(sub, ast.Call) and isinstance(sub.func, (ast.Name, ast.Attribute)):
425
+ e = sub.func
426
+ if isinstance(e, ast.Name):
427
+ r = self.prog.resolve_name(ctx.mod, e.id)
428
+ else:
429
+ base = self.prog.infer(e.value, ctx)
430
+ r = self.prog.lookup(base[1].name, e.attr) if base and base[0] == "mod" else None
431
+ if r and r[0] == "var":
432
+ self.obj_calls.append((sid, (r[1].name, r[2]), file, sub.lineno))
433
+
434
+ def finish(self):
435
+ """Registering wrappers: every function decorated with (or passed to) one is registered; a wrapper with no
436
+ such use is itself the registered handler (its closure collapses into it)."""
437
+ p = self.prog
438
+ for sid, key, file, line in self.obj_calls:
439
+ for g in self.var_regs.get(key, []):
440
+ self.ref(sid, g, "callback", file, line, via="registered command")
441
+ for fid, (F, kind, attrs) in self.registrars.items():
442
+ targets = list(self.decorated_by.get(fid, []))
443
+ for call, ctx, g in self.passed:
444
+ if any(t is F for t, _c, _v in p.resolve_call(call, ctx)):
445
+ targets.append(g)
446
+ if targets:
447
+ for g in targets:
448
+ self.set_entry(g, kind, {**attrs, "registered_by": F.qual})
449
+ else:
450
+ self.set_entry(F, kind, {**attrs, "closure": True})
451
+
452
+
453
+ PROPERTY_DECOS = {"property", "cached_property", "setter", "getter", "deleter", "abstractproperty", "hybrid_property",
454
+ "classproperty", "lazy_property", "reify", "computed_field"}
455
+
456
+
457
+ def is_property(f) -> bool:
458
+ cached = getattr(f, "_is_property", None)
459
+ if cached is None:
460
+ cached = False
461
+ for d in f.decorators:
462
+ name = (dotted(d.func if isinstance(d, ast.Call) else d) or "").rsplit(".", 1)[-1]
463
+ if name in PROPERTY_DECOS or name.endswith("_property"):
464
+ cached = True
465
+ break
466
+ f._is_property = cached
467
+ return cached
468
+
469
+
470
+ def ann(e) -> str:
471
+ try:
472
+ return ast.unparse(e)
473
+ except Exception: # noqa: BLE001
474
+ return ""
475
+
476
+
477
+ def dotted(e) -> str | None:
478
+ parts = []
479
+ while isinstance(e, ast.Attribute):
480
+ parts.append(e.attr)
481
+ e = e.value
482
+ if isinstance(e, ast.Name):
483
+ parts.append(e.id)
484
+ return ".".join(reversed(parts))
485
+ return None