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,664 @@
1
+ """LLM tools, MCP servers / clients and agents (#66) -> the #31 endpoint model (codegraph/protocols).
2
+
3
+ Only modules importing an AI SDK take part (mcp, fastmcp, openai, agents, anthropic, langchain*, langgraph,
4
+ llama_index); other code is untouched.
5
+
6
+ endpoint:mcp_tool:<server>/<name>, mcp_resource:<server>/<uri>, mcp_prompt:<server>/<name>
7
+ receive FastMCP / MCPServer `@mcp.tool()` / `@mcp.resource("notes://{id}")` / `@mcp.prompt()`, `mcp.tool()(fn)`,
8
+ `add_tool(fn)` and local decorators that register their argument (the registrations refs.py records);
9
+ low-level `@server.call_tool()` handlers branching on `if name == "x"` (resolved)
10
+ send clients: `session.call_tool("x")`, `read_resource("uri")`, `get_prompt("x")` -> `*/<name>` (any server;
11
+ MATCHES_ENDPOINT pairs it with every server tool of that name)
12
+ endpoint:llm_tool:<name>
13
+ receive LangChain `@tool` / `@tool("name")`, `StructuredTool.from_function` / `Tool(name=, func=)`, BaseTool
14
+ subclasses (`name` class attribute -> `_run` / `_arun`), Agents SDK `@function_tool` (name_override),
15
+ Anthropic `@beta_tool`, LlamaIndex `FunctionTool.from_defaults(fn=, name=)` (exact); module dict
16
+ registries `TOOLS = {"x": fn}` / `{f.__name__: f for f in [a, b]}` and `if name == "x":` / `match name:`
17
+ branches in an agent loop (resolved)
18
+ send schema literals offered to the model (role offer): OpenAI `{"type": "function", "function": {"name"}}`,
19
+ Responses `{"type": "function", "name"}`, Anthropic `{"name", "input_schema"}`; from the function holding
20
+ them, or from each function passing the module constant as `tools=`; `bind_tools([...])`,
21
+ `create_react_agent(model, tools=[...])`, `AgentExecutor(tools=[...])`, `ToolNode([...])`
22
+ agent:<name> Agents SDK `Agent(name=..., tools=[...], handoffs=[...])` and agent constructors assigned to a module
23
+ name; OFFERS_TOOL -> its tools, HANDS_OFF_TO -> its handoffs; attrs.model
24
+ findings an agent loop dispatching by a runtime name (`globals()[name]`, `getattr(obj, name)`, `eval`) records
25
+ attrs.llm_dynamic_dispatch on the function instead of a guessed link
26
+ model config literal `model=` / `base_url=` of chat / responses / messages calls and model constructors ->
27
+ attrs.llm_calls on the calling function (facts for the external-system nodes of #40)
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import ast
32
+ import re
33
+
34
+ from ...core.model import EXACT, HEURISTIC, RESOLVED
35
+ from ...protocols import protocol_receive, protocol_send
36
+
37
+ LIBS = ("mcp", "fastmcp", "openai", "agents", "anthropic", "langchain", "langchain_core", "langchain_community",
38
+ "langchain_openai", "langchain_anthropic", "langgraph", "llama_index", "langchain_mcp_adapters")
39
+ MCP_SERVERS = ("FastMCP", "MCPServer", "Server")
40
+ TOOL_DECOS = {"tool": None, "function_tool": "openai-agents", "beta_tool": "anthropic", "beta_async_tool": "anthropic"}
41
+ TOOL_CTORS = {"from_function", "from_defaults", "Tool", "StructuredTool", "FunctionTool"}
42
+ OFFER_CALLS = {"Agent", "bind_tools", "create_react_agent", "create_agent", "AgentExecutor", "ToolNode", "create_tool_calling_agent",
43
+ "create_openai_tools_agent", "ReActAgent", "FunctionAgent", "OpenAIAgent", "AgentWorkflow"}
44
+ AGENT_CTORS = {"Agent", "create_react_agent", "create_agent", "AgentExecutor", "ReActAgent", "FunctionAgent", "OpenAIAgent"}
45
+ BASE_TOOLS = ("langchain_core.tools.BaseTool", "langchain_core.tools.base.BaseTool", "langchain.tools.BaseTool",
46
+ "langchain.tools.base.BaseTool", "langchain_community.tools.BaseTool")
47
+ CLIENT_CALLS = {"call_tool": "mcp_tool", "read_resource": "mcp_resource", "get_prompt": "mcp_prompt"}
48
+ NAME_VARS = {"name", "tool_name", "function_name", "fn_name", "func_name", "tool"}
49
+ LOOP_MARKS = {"tool_calls", "tool_call", "tool_use", "function_call", "tool_name", "tool_uses"}
50
+ REGISTRY_NAME = re.compile(r"tool|function|handler|dispatch|action", re.I)
51
+ MODEL_CALLS = {("completions", "create"): ("openai", "chat"), ("responses", "create"): ("openai", "responses"),
52
+ ("messages", "create"): ("anthropic", "messages"), ("messages", "stream"): ("anthropic", "messages"),
53
+ ("embeddings", "create"): ("openai", "embeddings")}
54
+ MODEL_CTORS = {"ChatOpenAI": "openai", "AzureChatOpenAI": "azure-openai", "ChatAnthropic": "anthropic", "OpenAI": "openai",
55
+ "AsyncOpenAI": "openai", "AzureOpenAI": "azure-openai", "Anthropic": "anthropic", "AsyncAnthropic": "anthropic",
56
+ "init_chat_model": None, "ChatOllama": "ollama", "OpenAIChatCompletionsModel": "openai"}
57
+
58
+
59
+ def _s(e):
60
+ return e.value if isinstance(e, ast.Constant) and isinstance(e.value, str) else None
61
+
62
+
63
+ def _kw(call, *names):
64
+ for k in call.keywords:
65
+ if k.arg in names:
66
+ return k.value
67
+ return None
68
+
69
+
70
+ def _last(e):
71
+ if isinstance(e, ast.Subscript): # Agent[Context](...)
72
+ e = e.value
73
+ return e.attr if isinstance(e, ast.Attribute) else e.id if isinstance(e, ast.Name) else None
74
+
75
+
76
+ def _head(m, e):
77
+ """Top-level package a name / attribute chain is imported from."""
78
+ while isinstance(e, (ast.Attribute, ast.Subscript)):
79
+ e = e.value
80
+ if isinstance(e, ast.Call):
81
+ return _head(m, e.func)
82
+ if not isinstance(e, ast.Name):
83
+ return None
84
+ imp = m.imports.get(e.id)
85
+ if not imp or not isinstance(imp, tuple) or len(imp) < 2 or not isinstance(imp[1], str):
86
+ return None
87
+ return imp[1].split(".")[0]
88
+
89
+
90
+ def _framework(head):
91
+ return {"agents": "openai-agents", "llama_index": "llamaindex", "langgraph": "langgraph", "openai": "openai",
92
+ "anthropic": "anthropic", "mcp": "mcp", "fastmcp": "mcp"}.get(head, "langchain" if head and head.startswith("langchain") else head)
93
+
94
+
95
+ def _first_line(doc):
96
+ return (doc or "").strip().splitlines()[0][:160] if (doc or "").strip() else None
97
+
98
+
99
+ def _params(fnode):
100
+ a = fnode.args
101
+ return [x.arg for x in a.posonlyargs + a.args + a.kwonlyargs if x.arg not in ("self", "cls", "ctx", "context")]
102
+
103
+
104
+ def _is_test(path):
105
+ p = path.replace("\\", "/")
106
+ return any(s.startswith("test") for s in p.split("/")[:-1]) or p.rsplit("/", 1)[-1].startswith("test_") or p.endswith("_test.py")
107
+
108
+
109
+ def index(prog, b, walk_body, Ctx) -> dict:
110
+ mods = [m for m in prog.modules.values() if any(
111
+ isinstance(v, tuple) and len(v) > 1 and isinstance(v[1], str) and v[1].split(".")[0] in LIBS for v in m.imports.values())]
112
+ ai_mods = {m.name for m in mods}
113
+ st = {"mcp": {"tool": 0, "resource": 0, "prompt": 0, "client_calls": 0}, "llm_tools": 0, "offers": 0, "agents": 0,
114
+ "dynamic_dispatch": 0, "model_calls": 0}
115
+ regd = [n for n in b.nodes.values() if n.kind in ("function", "method") and any(
116
+ r.get("framework") == "mcp" for r in ((n.attrs or {}).get("registrations") or []))]
117
+ if not mods and not regd:
118
+ return {}
119
+ by_file = {m.file: m for m in prog.modules.values()}
120
+ fn_by_id = {f.id: f for f in prog.funcs.values()}
121
+
122
+ # ---- MCP server objects: (module, var) -> server name
123
+ servers: dict = {}
124
+ for m in prog.modules.values():
125
+ for name, vals in m.vars.items():
126
+ for v, _line, _a in vals:
127
+ if isinstance(v, ast.Call) and _last(v.func) in MCP_SERVERS and _head(m, v.func) in ("mcp", "fastmcp"):
128
+ sn = _s(v.args[0]) if v.args else None
129
+ servers[(m.name, name)] = sn or _s(_kw(v, "name")) or m.name
130
+
131
+ def server_of(m, var):
132
+ if (m.name, var) in servers:
133
+ return servers[(m.name, var)]
134
+ imp = m.imports.get(var)
135
+ if imp and imp[0] == "sym" and (imp[1], imp[2]) in servers:
136
+ return servers[(imp[1], imp[2])]
137
+ return None
138
+
139
+ tool_of_func: dict = {} # FuncInfo.id -> endpoint id
140
+ tool_of_var: dict = {} # (module, var) -> endpoint id
141
+ tool_of_class: dict = {} # ClassInfo.id -> endpoint id
142
+ handlers: set = set()
143
+
144
+ def recv(protocol, name, f, file, line, conf, framework, **kw):
145
+ if hasattr(f, "id") and f.id not in b.nodes: # a nested def collapses into its owner: no handler node
146
+ return None
147
+ nid = protocol_receive(b, protocol, name, f.id if hasattr(f, "id") else f, file, line, conf, framework=framework,
148
+ node_attrs={"framework": framework, "declared_in": file, **kw})
149
+ if hasattr(f, "id"):
150
+ handlers.add(f.id)
151
+ return nid
152
+
153
+ # tools declared inside a function (servers built in a factory, tools defined in test bodies, #76): the nested def
154
+ # has no node of its own, so the endpoint is received by the enclosing function, with the nested name recorded
155
+ nested_owners: set = set()
156
+ local_servers: dict = {} # FuncInfo.id -> {local var: server name}
157
+ for f in prog.funcs.values():
158
+ if f.id not in b.nodes or f.module.name not in ai_mods:
159
+ continue
160
+ local: dict = {}
161
+ nested = []
162
+ for sub in ast.walk(f.node):
163
+ if sub is f.node:
164
+ continue
165
+ if isinstance(sub, ast.Assign) and isinstance(sub.value, ast.Call) and _last(sub.value.func) in MCP_SERVERS:
166
+ v = sub.value
167
+ if _head(f.module, v.func) in ("mcp", "fastmcp") or _last(v.func) in ("FastMCP", "MCPServer"):
168
+ sn = (_s(v.args[0]) if v.args else None) or _s(_kw(v, "name")) or f.name
169
+ for t in sub.targets:
170
+ if isinstance(t, ast.Name):
171
+ local[t.id] = sn
172
+ elif isinstance(sub, (ast.FunctionDef, ast.AsyncFunctionDef)) and sub.decorator_list:
173
+ nested.append(sub)
174
+ if local:
175
+ local_servers[f.id] = local
176
+ for fn in nested:
177
+ for d in fn.decorator_list:
178
+ de = d.func if isinstance(d, ast.Call) else d
179
+ if not (isinstance(de, ast.Attribute) and de.attr in ("tool", "resource", "prompt") and isinstance(de.value, ast.Name)):
180
+ continue
181
+ srv = local.get(de.value.id) or server_of(f.module, de.value.id)
182
+ if srv is None:
183
+ continue
184
+ call = d if isinstance(d, ast.Call) else None
185
+ pos = call.args if call is not None else []
186
+ if de.attr == "resource":
187
+ nm = ((_s(pos[0]) if pos else None) or _s(_kw(call, "uri"))) if call is not None else None
188
+ if not nm:
189
+ continue
190
+ else:
191
+ nm = ((_s(_kw(call, "name")) or (_s(pos[0]) if pos else None)) if call is not None else None) or fn.name
192
+ ok = protocol_receive(b, f"mcp_{de.attr}", f"{srv}/{nm}", f.id, f.file, d.lineno, EXACT, framework="mcp",
193
+ node_attrs={"framework": "mcp", "declared_in": f.file, "server": srv, "toolset": srv,
194
+ "description": _first_line(ast.get_docstring(fn)), "params": _params(fn),
195
+ "schema_source": "decorator", "nested_in": f.name, "handler_name": fn.name})
196
+ st["mcp"][de.attr] += bool(ok)
197
+ nested_owners.add(f.id)
198
+ # ---- 1. MCP server registrations (refs.py) -> endpoints
199
+ for n in regd:
200
+ f = fn_by_id.get(n.id)
201
+ if f is None:
202
+ continue
203
+ for r in n.attrs["registrations"]:
204
+ if r.get("framework") != "mcp" or (r.get("closure") and n.id in nested_owners): # read from the nested defs
205
+ continue
206
+ kind = r.get("registration") or "tool"
207
+ txt = r.get("decorator") or r.get("call") or ""
208
+ try:
209
+ e = ast.parse(txt, mode="eval").body
210
+ except SyntaxError:
211
+ e = None
212
+ call = e if isinstance(e, ast.Call) else None
213
+ if call is not None and isinstance(call.func, ast.Call): # mcp.tool()(fn)
214
+ call = call.func
215
+ var = None
216
+ if call is not None and isinstance(call.func, ast.Attribute) and isinstance(call.func.value, ast.Name):
217
+ var = call.func.value.id
218
+ elif isinstance(e, ast.Attribute) and isinstance(e.value, ast.Name): # @mcp.tool without call
219
+ var, call = e.value.id, None
220
+ at_file = (r.get("at") or "").rsplit(":", 1)[0]
221
+ m = by_file.get(at_file) or f.module
222
+ srv = (server_of(m, var) if var else None) or m.name
223
+ pos = [a for a in (call.args if call is not None else [])]
224
+ if kind == "resource":
225
+ nm = (_s(pos[0]) if pos else None) or _s(_kw(call, "uri")) if call is not None else None
226
+ else:
227
+ nm = (_s(_kw(call, "name")) if call is not None else None) or (_s(pos[0]) if pos and kind != "resource" else None)
228
+ if kind == "resource" and not nm:
229
+ continue
230
+ nm = nm or f.name
231
+ line = f.line if r.get("registered_by") else int((r.get("at") or ":0").rsplit(":", 1)[-1] or 0) or f.line
232
+ ok = recv(f"mcp_{kind}", f"{srv}/{nm}", f, f.file if r.get("registered_by") else at_file or f.file, line, EXACT if not r.get("registered_by") else RESOLVED,
233
+ "mcp", server=srv, description=_first_line(ast.get_docstring(f.node)), params=_params(f.node),
234
+ schema_source="decorator", toolset=srv)
235
+ st["mcp"][kind if kind in st["mcp"] else "tool"] += bool(ok)
236
+ # decorators on a server object the registration pass could not type (the SDK's own examples: MCPServer is a
237
+ # local class there)
238
+ for f in prog.funcs.values():
239
+ if f.id in handlers:
240
+ continue
241
+ for d in f.decorators:
242
+ de = d.func if isinstance(d, ast.Call) else d
243
+ if not (isinstance(de, ast.Attribute) and de.attr in ("tool", "resource", "prompt") and isinstance(de.value, ast.Name)):
244
+ continue
245
+ srv = server_of(f.module, de.value.id)
246
+ if srv is None:
247
+ continue
248
+ call = d if isinstance(d, ast.Call) else None
249
+ pos = call.args if call is not None else []
250
+ if de.attr == "resource":
251
+ nm = ((_s(pos[0]) if pos else None) or _s(_kw(call, "uri"))) if call is not None else None
252
+ if not nm:
253
+ continue
254
+ else:
255
+ nm = ((_s(_kw(call, "name")) or (_s(pos[0]) if pos else None)) if call is not None else None) or f.name
256
+ ok = recv(f"mcp_{de.attr}", f"{srv}/{nm}", f, f.file, d.lineno, EXACT, "mcp", server=srv, toolset=srv,
257
+ description=_first_line(ast.get_docstring(f.node)), params=_params(f.node), schema_source="decorator")
258
+ st["mcp"][de.attr] += bool(ok)
259
+ # the registering decorator's reference to each handler was the only link before: the endpoint replaces it
260
+ drop = [k for k, e in b.edges.items() if e.kind == "REFERENCES_FN" and e.dst in handlers
261
+ and (e.attrs or {}).get("how") == "decorator"]
262
+ for k in drop:
263
+ del b.edges[k]
264
+ if not mods:
265
+ return st
266
+
267
+ def scopes():
268
+ for f in prog.funcs.values():
269
+ if f.module.name in ai_mods:
270
+ yield f.module, f, walk_body(f.node)
271
+ for m in mods:
272
+ yield m, None, (s for st_ in m.tree.body if not isinstance(st_, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))
273
+ for s in ast.walk(st_))
274
+
275
+ # ---- 2. declared tools (decorators, constructors, BaseTool classes)
276
+ for f in prog.funcs.values():
277
+ m = f.module
278
+ if m.name not in ai_mods:
279
+ continue
280
+ for d in f.decorators:
281
+ de = d.func if isinstance(d, ast.Call) else d
282
+ last, head = _last(de), _head(m, de)
283
+ if last not in TOOL_DECOS or head not in LIBS or head in ("mcp", "fastmcp"):
284
+ continue
285
+ fw = TOOL_DECOS[last] or _framework(head)
286
+ nm = None
287
+ if isinstance(d, ast.Call):
288
+ nm = (_s(d.args[0]) if d.args else None) or _s(_kw(d, "name_override", "name", "name_or_callable"))
289
+ nid = recv("llm_tool", nm or f.name, f, f.file, d.lineno, EXACT, fw, description=_first_line(ast.get_docstring(f.node)),
290
+ params=_params(f.node), schema_source="decorator")
291
+ if nid:
292
+ tool_of_func[f.id] = nid
293
+ st["llm_tools"] += 1
294
+ for m, f, nodes in scopes():
295
+ ctx = Ctx(m, f, f.cls if f else None)
296
+ for sub in nodes:
297
+ if isinstance(sub, (ast.Assign, ast.AnnAssign)) and isinstance(sub.value, ast.Call):
298
+ c = sub.value
299
+ if _last(c.func) in TOOL_CTORS and _head(m, c.func) in LIBS and _head(m, c.func) not in ("mcp", "fastmcp"):
300
+ fnx = _kw(c, "func", "fn", "coroutine", "async_fn") or (c.args[0] if c.args and _last(c.func) in ("from_function", "from_defaults") else None)
301
+ t = prog.infer(fnx, ctx) if fnx is not None else None
302
+ h = t[1] if t and t[0] in ("func", "bound") else None
303
+ nm = _s(_kw(c, "name")) or (h.name if h else None)
304
+ if not nm:
305
+ continue
306
+ nid = recv("llm_tool", nm, h or (f.id if f else m.id), m.file, c.lineno, EXACT if h else RESOLVED,
307
+ _framework(_head(m, c.func)), description=_s(_kw(c, "description")),
308
+ schema_source="constructor")
309
+ if not nid:
310
+ continue
311
+ if h:
312
+ tool_of_func.setdefault(h.id, nid)
313
+ tgts = sub.targets if isinstance(sub, ast.Assign) else [sub.target]
314
+ for tg in tgts:
315
+ if isinstance(tg, ast.Name) and f is None:
316
+ tool_of_var[(m.name, tg.id)] = nid
317
+ st["llm_tools"] += 1
318
+ for c in prog.classes.values():
319
+ if c.module.name not in ai_mods or not prog.subclass_of(c, *BASE_TOOLS):
320
+ continue
321
+ nm, conf = None, EXACT
322
+ for s in c.node.body:
323
+ if isinstance(s, (ast.Assign, ast.AnnAssign)):
324
+ tg = s.targets[0] if isinstance(s, ast.Assign) else s.target
325
+ if isinstance(tg, ast.Name) and tg.id == "name":
326
+ nm = _s(s.value)
327
+ if not nm:
328
+ nm, conf = c.name, HEURISTIC
329
+ for mn in ("_run", "_arun"):
330
+ f = c.methods.get(mn)
331
+ if f is not None:
332
+ nid = recv("llm_tool", nm, f, f.file, f.line, conf, "langchain", schema_source="class attrs",
333
+ params=_params(f.node))
334
+ if nid:
335
+ tool_of_class[c.id] = nid
336
+ st["llm_tools"] += 1
337
+
338
+ # ---- 3. dict registries and agent-loop branches
339
+ registries: dict = {} # (module, var) -> [endpoint ids]
340
+ for m in mods:
341
+ for name, vals in m.vars.items():
342
+ if not REGISTRY_NAME.search(name):
343
+ continue
344
+ for v, line, _a in vals:
345
+ pairs = []
346
+ if isinstance(v, ast.Dict) and v.keys and all(_s(k) for k in v.keys):
347
+ pairs = [(_s(k), x) for k, x in zip(v.keys, v.values)]
348
+ elif isinstance(v, ast.DictComp) and isinstance(v.key, ast.Attribute) and v.key.attr == "__name__" \
349
+ and v.generators and isinstance(v.generators[0].iter, (ast.List, ast.Tuple)):
350
+ pairs = [(None, x) for x in v.generators[0].iter.elts]
351
+ ids = []
352
+ for k, x in pairs:
353
+ t = prog.infer(x, Ctx(m, None, None))
354
+ h = t[1] if t and t[0] in ("func", "bound") else None
355
+ if h is None:
356
+ continue
357
+ nid = recv("llm_tool", k or h.name, h, m.file, line, RESOLVED, "custom", via="dict registry",
358
+ schema_source="dict registry", toolset=f"{m.name}.{name}", params=_params(h.node))
359
+ if not nid:
360
+ continue
361
+ ids.append(nid)
362
+ tool_of_func.setdefault(h.id, nid)
363
+ st["llm_tools"] += 1
364
+ if ids:
365
+ registries[(m.name, name)] = ids
366
+
367
+ def nested_call_tool(f):
368
+ """`@server.call_tool()` on a def nested in f (`async def serve(): server = Server(...)`, #76): server var."""
369
+ for sub in walk_body(f.node):
370
+ if isinstance(sub, (ast.FunctionDef, ast.AsyncFunctionDef)):
371
+ for d in sub.decorator_list:
372
+ de = d.func if isinstance(d, ast.Call) else d
373
+ if _last(de) == "call_tool" and isinstance(de, ast.Attribute) and isinstance(de.value, ast.Name):
374
+ return de.value.id
375
+ return None
376
+
377
+ def loop_like(f):
378
+ for d in f.decorators:
379
+ de = d.func if isinstance(d, ast.Call) else d
380
+ if _last(de) == "call_tool":
381
+ return "mcp"
382
+ if f.id in local_servers and nested_call_tool(f):
383
+ return "mcp"
384
+ for sub in walk_body(f.node):
385
+ if isinstance(sub, ast.Attribute) and sub.attr in LOOP_MARKS:
386
+ return "llm"
387
+ if isinstance(sub, ast.Constant) and sub.value in ("tool_use", "function_call", "tool_calls"):
388
+ return "llm"
389
+ if LOOP_MARKS & set(_params(f.node)) or ("tool" in f.name.lower() and NAME_VARS & set(_params(f.node))):
390
+ return "llm"
391
+ return None
392
+
393
+ def name_expr(e):
394
+ return (isinstance(e, ast.Name) and e.id in NAME_VARS) or (isinstance(e, ast.Attribute) and e.attr == "name")
395
+
396
+ def lit_of(m, e):
397
+ """A string literal, or a `Cls.MEMBER` whose class body assigns it a string (`class GitTools(str, Enum)`, #76)."""
398
+ if _s(e):
399
+ return _s(e)
400
+ if not (isinstance(e, ast.Attribute) and isinstance(e.value, ast.Name)):
401
+ return None
402
+ c = m.classes.get(e.value.id)
403
+ imp = m.imports.get(e.value.id)
404
+ if c is None and isinstance(imp, tuple) and len(imp) > 2 and imp[0] == "sym":
405
+ c = prog.classes.get(f"{imp[1]}.{imp[2]}")
406
+ v = c.attrs.get(e.attr) if c is not None else None
407
+ return _s(v[0]) if v else None
408
+
409
+ def lowlevel_server(f):
410
+ for d in f.decorators:
411
+ de = d.func if isinstance(d, ast.Call) else d
412
+ if _last(de) == "call_tool" and isinstance(de, ast.Attribute) and isinstance(de.value, ast.Name):
413
+ return server_of(f.module, de.value.id) or f.module.name
414
+ var = nested_call_tool(f)
415
+ if var:
416
+ return local_servers.get(f.id, {}).get(var) or server_of(f.module, var) or f.module.name
417
+ return None
418
+
419
+ for f in list(prog.funcs.values()):
420
+ if f.module.name not in ai_mods:
421
+ continue
422
+ kind = loop_like(f)
423
+ if not kind:
424
+ continue
425
+ ctx = Ctx(f.module, f, f.cls)
426
+ srv = lowlevel_server(f) if kind == "mcp" else None
427
+ branches = []
428
+ for sub in walk_body(f.node):
429
+ if isinstance(sub, ast.If) and isinstance(sub.test, ast.Compare) and len(sub.test.ops) == 1 \
430
+ and isinstance(sub.test.ops[0], ast.Eq):
431
+ l, r = sub.test.left, sub.test.comparators[0]
432
+ lit = lit_of(f.module, r) if name_expr(l) else lit_of(f.module, l) if name_expr(r) else None
433
+ if lit:
434
+ branches.append((lit, sub.body, sub.lineno, "if"))
435
+ elif isinstance(sub, ast.Match) and name_expr(sub.subject):
436
+ for case in sub.cases:
437
+ p = case.pattern
438
+ if isinstance(p, ast.MatchValue) and lit_of(f.module, p.value):
439
+ branches.append((lit_of(f.module, p.value), case.body, p.lineno, "match"))
440
+ elif isinstance(sub, ast.Subscript) and isinstance(sub.value, ast.Call) and _last(sub.value.func) in ("globals", "locals"):
441
+ _dynamic(b, f, sub, st)
442
+ elif isinstance(sub, ast.Call) and _last(sub.func) == "getattr" and len(sub.args) >= 2 and _dyn_name(sub.args[1]):
443
+ _dynamic(b, f, sub, st)
444
+ elif isinstance(sub, ast.Call) and _last(sub.func) == "eval":
445
+ _dynamic(b, f, sub, st)
446
+ for lit, body, line, how in branches:
447
+ h = None
448
+ for s in body:
449
+ for c in ast.walk(s):
450
+ if isinstance(c, ast.Call):
451
+ for tgt, _conf, _via in prog.resolve_call(c, ctx):
452
+ if hasattr(tgt, "node") and getattr(tgt, "kind", None) in ("function", "method"):
453
+ h = tgt
454
+ break
455
+ if h:
456
+ break
457
+ if h:
458
+ break
459
+ if srv:
460
+ recv("mcp_tool", f"{srv}/{lit}", h or f, f.file, line, RESOLVED, "mcp", server=srv, via=f"{how} name",
461
+ schema_source="low-level server", toolset=srv)
462
+ st["mcp"]["tool"] += 1
463
+ else:
464
+ nid = recv("llm_tool", lit, h or f, f.file, line, RESOLVED, "custom", via=f"{how} name",
465
+ schema_source="agent loop")
466
+ if h and nid:
467
+ tool_of_func.setdefault(h.id, nid)
468
+ st["llm_tools"] += bool(nid)
469
+
470
+ # ---- 4. senders: schema literals, offers, MCP client calls; agents; model config
471
+ schema_vars: dict = {} # (module, var) -> [(name, framework, line)]
472
+
473
+ def schema(d):
474
+ if not isinstance(d, ast.Dict):
475
+ return None
476
+ kv = {_s(k): v for k, v in zip(d.keys, d.values) if _s(k)}
477
+ if _s(kv.get("type")) == "function" and isinstance(kv.get("function"), ast.Dict):
478
+ inner = {_s(k): v for k, v in zip(kv["function"].keys, kv["function"].values) if _s(k)}
479
+ return (_s(inner.get("name")), "openai") if _s(inner.get("name")) else None
480
+ if _s(kv.get("type")) == "function" and _s(kv.get("name")) and ("parameters" in kv or "description" in kv):
481
+ return _s(kv["name"]), "openai"
482
+ if _s(kv.get("name")) and "input_schema" in kv:
483
+ return _s(kv["name"]), "anthropic"
484
+ return None
485
+
486
+ for m in mods:
487
+ for name, vals in m.vars.items():
488
+ for v, line, _a in vals:
489
+ for sub in ast.walk(v):
490
+ sc = schema(sub)
491
+ if sc:
492
+ schema_vars.setdefault((m.name, name), []).append((*sc, sub.lineno))
493
+
494
+ def tools_in(e, ctx, depth=0):
495
+ """Endpoint ids of the tools a `tools=[...]` value lists."""
496
+ out = []
497
+ if e is None or depth > 3:
498
+ return out
499
+ if isinstance(e, (ast.List, ast.Tuple, ast.Set)):
500
+ for x in e.elts:
501
+ out += tools_in(x, ctx, depth + 1)
502
+ return out
503
+ if isinstance(e, ast.Starred):
504
+ return tools_in(e.value, ctx, depth + 1)
505
+ if isinstance(e, ast.Call) and _last(e.func) not in ("list",):
506
+ t = prog.infer(e.func, ctx)
507
+ if t and t[0] == "type" and t[1].id in tool_of_class:
508
+ return [tool_of_class[t[1].id]]
509
+ return out
510
+ t = prog.infer(e, ctx)
511
+ if t and t[0] in ("func", "bound") and t[1].id in tool_of_func:
512
+ return [tool_of_func[t[1].id]]
513
+ if isinstance(e, ast.Name):
514
+ r = prog.resolve_name(ctx.mod, e.id)
515
+ if ctx.func is not None and e.id in prog.local_vars(ctx):
516
+ for lv in _local_values(ctx.func.node, e.id):
517
+ out += tools_in(lv, ctx, depth + 1)
518
+ return out
519
+ if r and r[0] == "var":
520
+ key = (r[1].name, r[2])
521
+ if key in tool_of_var:
522
+ return [tool_of_var[key]]
523
+ for v, _l, _a in r[1].vars.get(r[2], []):
524
+ out += tools_in(v, Ctx(r[1], None, None), depth + 1)
525
+ return out
526
+
527
+ def offer(m, f, ids, file, line, via, fw):
528
+ for nid in ids:
529
+ src = f.id if f else m.id
530
+ b.add_edge(src, nid, "SENDS_TO", file, line, RESOLVED, role="offer", via=via, framework=fw)
531
+ st["offers"] += 1
532
+
533
+ agent_vars: dict = {}
534
+ for m, f, nodes in scopes():
535
+ ctx = Ctx(m, f, f.cls if f else None)
536
+ test = _is_test(m.file)
537
+ assigned = {}
538
+ for sub in nodes:
539
+ if isinstance(sub, ast.Assign) and isinstance(sub.value, ast.Call) and len(sub.targets) == 1 \
540
+ and isinstance(sub.targets[0], ast.Name):
541
+ assigned[id(sub.value)] = sub.targets[0].id
542
+ for sub in (nodes if isinstance(nodes, list) else list(_rewalk(m, f, walk_body))):
543
+ if f is not None:
544
+ sc = schema(sub)
545
+ if sc:
546
+ protocol_send(b, "llm_tool", sc[0], f.id, m.file, sub.lineno, EXACT, test=test, role="offer",
547
+ framework=sc[1], schema_source="schema literal",
548
+ node_attrs={"framework": sc[1], "schema_source": "schema literal"})
549
+ st["offers"] += 1
550
+ if not isinstance(sub, ast.Call):
551
+ continue
552
+ last = _last(sub.func)
553
+ head = _head(m, sub.func)
554
+ tv = _kw(sub, "tools")
555
+ if f is not None and isinstance(tv, ast.Name):
556
+ r = prog.resolve_name(m, tv.id) if tv.id not in prog.local_vars(ctx) else None
557
+ if r and r[0] == "var" and (r[1].name, r[2]) in schema_vars:
558
+ for nm, fw, _l in schema_vars[(r[1].name, r[2])]:
559
+ protocol_send(b, "llm_tool", nm, f.id, m.file, sub.lineno, RESOLVED, test=test, role="offer",
560
+ framework=fw, schema_source="schema literal", via=f"tools={tv.id}",
561
+ node_attrs={"framework": fw, "schema_source": "schema literal"})
562
+ st["offers"] += 1
563
+ if last in CLIENT_CALLS and head is None and m.name in ai_mods:
564
+ nm = (_s(sub.args[0]) if sub.args else None) or _s(_kw(sub, "name", "uri"))
565
+ if nm and f is not None:
566
+ protocol_send(b, CLIENT_CALLS[last], f"*/{nm}", f.id, m.file, sub.lineno, EXACT, test=test, role="invoke",
567
+ framework="mcp")
568
+ st["mcp"]["client_calls"] += 1
569
+ continue
570
+ if last in OFFER_CALLS and (head in LIBS or (last == "bind_tools")):
571
+ lst = tv if tv is not None else (sub.args[0] if last in ("bind_tools", "ToolNode") and sub.args else
572
+ sub.args[1] if last in ("create_react_agent", "create_agent", "create_tool_calling_agent")
573
+ and len(sub.args) > 1 else None)
574
+ ids = tools_in(lst, ctx)
575
+ fw = _framework(head) or "langchain"
576
+ var = assigned.get(id(sub))
577
+ if last in AGENT_CTORS and (_s(_kw(sub, "name")) or (var and f is None)):
578
+ aname = _s(_kw(sub, "name")) or f"{m.name}.{var}"
579
+ aid = b.add_node("agent", aname, aname.rsplit(".", 1)[-1], fqn=aname, file=m.file, line=sub.lineno,
580
+ lang="python", attrs={"framework": fw, "model": _s(_kw(sub, "model")),
581
+ "tools": ids, "instructions_source": "literal" if _s(_kw(sub, "instructions")) else None})
582
+ for nid in ids:
583
+ b.add_edge(aid, nid, "OFFERS_TOOL", m.file, sub.lineno, RESOLVED)
584
+ offer(m, f, ids, m.file, sub.lineno, last, fw) # constructing the agent hands the tools to the model
585
+ b.add_edge(f.id if f else m.id, aid, "CONTAINS" if f is None else "INSTANTIATES", m.file, sub.lineno, EXACT)
586
+ if var and f is None:
587
+ agent_vars[(m.name, var)] = (aid, sub, ctx)
588
+ st["agents"] += 1
589
+ else:
590
+ offer(m, f, ids, m.file, sub.lineno, last, fw)
591
+ if last in MODEL_CTORS and head in LIBS or (isinstance(sub.func, ast.Attribute) and isinstance(sub.func.value, ast.Attribute)
592
+ and (sub.func.value.attr, sub.func.attr) in MODEL_CALLS):
593
+ mdl, base = _s(_kw(sub, "model", "model_name")), _s(_kw(sub, "base_url", "openai_api_base", "azure_endpoint"))
594
+ if mdl or base:
595
+ prov, op = MODEL_CALLS.get((sub.func.value.attr, sub.func.attr), (None, None)) if isinstance(sub.func, ast.Attribute) \
596
+ and isinstance(sub.func.value, ast.Attribute) else (MODEL_CTORS.get(last), "client")
597
+ owner = b.nodes.get(f.id if f else m.id)
598
+ if owner is not None:
599
+ if owner.attrs is None:
600
+ owner.attrs = {}
601
+ rec = {k: v for k, v in {"provider": prov or _framework(head), "op": op, "model": mdl, "base_url": base,
602
+ "line": sub.lineno}.items() if v}
603
+ if rec not in owner.attrs.setdefault("llm_calls", []):
604
+ owner.attrs["llm_calls"].append(rec)
605
+ st["model_calls"] += 1
606
+ for (mn, var), (aid, call, ctx) in agent_vars.items():
607
+ for h in (_kw(call, "handoffs").elts if isinstance(_kw(call, "handoffs"), (ast.List, ast.Tuple)) else []):
608
+ if isinstance(h, ast.Name):
609
+ r = prog.resolve_name(ctx.mod, h.id)
610
+ if r and r[0] == "var" and (r[1].name, r[2]) in agent_vars:
611
+ b.add_edge(aid, agent_vars[(r[1].name, r[2])][0], "HANDS_OFF_TO", ctx.mod.file, call.lineno, RESOLVED)
612
+ # module-level schema constants nobody passes as tools= are offered by their module
613
+ offered = {e.dst for e in b.edges.values() if e.kind in ("SENDS_TO", "TEST_CALLS") and e.dst.startswith("endpoint:llm_tool:")}
614
+ for (mn, var), lst in schema_vars.items():
615
+ m = prog.modules.get(mn)
616
+ for nm, fw, line in lst:
617
+ if f"endpoint:llm_tool:{nm}" not in offered and m is not None:
618
+ protocol_send(b, "llm_tool", nm, m.id, m.file, line, EXACT, test=_is_test(m.file), role="offer", framework=fw,
619
+ schema_source="schema literal", node_attrs={"framework": fw})
620
+ st["offers"] += 1
621
+ return {k: v for k, v in st.items() if v} if any(st["mcp"].values()) or any(v for k, v in st.items() if k != "mcp") else {}
622
+
623
+
624
+ def _dyn_name(e) -> bool:
625
+ """A runtime tool name: `name` / `tool_name` / ... or `<tool call>.name` (`tool_call.function.name`, `block.name`)."""
626
+ if isinstance(e, ast.JoinedStr):
627
+ return any(isinstance(v, ast.FormattedValue) and _dyn_name(v.value) for v in e.values)
628
+ if isinstance(e, ast.Name):
629
+ return e.id in NAME_VARS - {"tool"}
630
+ if isinstance(e, ast.Attribute) and e.attr == "name":
631
+ try:
632
+ base = ast.unparse(e.value).lower()
633
+ except Exception: # noqa: BLE001
634
+ return False
635
+ return any(w in base for w in ("tool", "function", "call", "block", "item"))
636
+ return False
637
+
638
+
639
+ def _rewalk(m, f, walk_body):
640
+ if f is not None:
641
+ return walk_body(f.node)
642
+ return (s for st_ in m.tree.body if not isinstance(st_, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) for s in ast.walk(st_))
643
+
644
+
645
+ def _local_values(fnode, name):
646
+ for sub in ast.walk(fnode):
647
+ if isinstance(sub, ast.Assign) and any(isinstance(t, ast.Name) and t.id == name for t in sub.targets):
648
+ yield sub.value
649
+
650
+
651
+ def _dynamic(b, f, sub, st):
652
+ n = b.nodes.get(f.id)
653
+ if n is None:
654
+ return
655
+ if n.attrs is None:
656
+ n.attrs = {}
657
+ try:
658
+ expr = ast.unparse(sub)[:100]
659
+ except Exception: # noqa: BLE001
660
+ expr = "?"
661
+ rec = {"line": sub.lineno, "expr": expr}
662
+ if rec not in n.attrs.setdefault("llm_dynamic_dispatch", []):
663
+ n.attrs["llm_dynamic_dispatch"].append(rec)
664
+ st["dynamic_dispatch"] += 1