codegraph-ir 0.1.0__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.
- cgir/__init__.py +6 -0
- cgir/analyses/__init__.py +7 -0
- cgir/analyses/call_graph.py +114 -0
- cgir/analyses/cfg.py +320 -0
- cgir/analyses/effects.py +95 -0
- cgir/analyses/entrypoints.py +55 -0
- cgir/analyses/param_flow.py +75 -0
- cgir/analyses/pdg.py +75 -0
- cgir/analyses/purity.py +37 -0
- cgir/analyses/reaching_defs.py +115 -0
- cgir/analyses/symbols.py +106 -0
- cgir/api/__init__.py +1 -0
- cgir/api/mcp_server.py +172 -0
- cgir/api/server.py +107 -0
- cgir/cli.py +688 -0
- cgir/config.py +22 -0
- cgir/export/__init__.py +5 -0
- cgir/export/graphml.py +52 -0
- cgir/export/html_viz.py +1087 -0
- cgir/export/json_export.py +37 -0
- cgir/export/mermaid.py +57 -0
- cgir/export/neo4j.py +11 -0
- cgir/hooks.py +189 -0
- cgir/ir/__init__.py +16 -0
- cgir/ir/component_spec.py +100 -0
- cgir/ir/edges.py +31 -0
- cgir/ir/graph.py +132 -0
- cgir/ir/nodes.py +38 -0
- cgir/languages/__init__.py +25 -0
- cgir/languages/base.py +221 -0
- cgir/languages/cache.py +73 -0
- cgir/languages/python.py +1145 -0
- cgir/languages/registry.py +24 -0
- cgir/languages/typescript.py +853 -0
- cgir/manifest.py +77 -0
- cgir/pipeline.py +54 -0
- cgir/py.typed +0 -0
- cgir/regenerate/__init__.py +6 -0
- cgir/regenerate/prompt_pack.py +16 -0
- cgir/regenerate/regenerator.py +108 -0
- cgir/report/__init__.py +5 -0
- cgir/report/diff.py +226 -0
- cgir/report/flow.py +84 -0
- cgir/report/impact.py +234 -0
- cgir/report/lint.py +84 -0
- cgir/report/pack.py +271 -0
- cgir/report/stats.py +121 -0
- cgir/slicing/__init__.py +5 -0
- cgir/slicing/slicer.py +174 -0
- cgir/sources/__init__.py +6 -0
- cgir/sources/base.py +14 -0
- cgir/sources/codeql_source.py +13 -0
- cgir/sources/joern_source.py +13 -0
- cgir/sources/tree_sitter_source.py +270 -0
- cgir/trace/__init__.py +5 -0
- cgir/trace/trace_map.py +83 -0
- cgir/verify.py +173 -0
- cgir/watch.py +171 -0
- codegraph_ir-0.1.0.dist-info/METADATA +143 -0
- codegraph_ir-0.1.0.dist-info/RECORD +63 -0
- codegraph_ir-0.1.0.dist-info/WHEEL +4 -0
- codegraph_ir-0.1.0.dist-info/entry_points.txt +2 -0
- codegraph_ir-0.1.0.dist-info/licenses/LICENSE +21 -0
cgir/languages/python.py
ADDED
|
@@ -0,0 +1,1145 @@
|
|
|
1
|
+
"""PythonAdapter — the Python implementation of :class:`LanguageAdapter`.
|
|
2
|
+
|
|
3
|
+
Holds everything grammar- or stdlib-specific to Python: tree-sitter-python
|
|
4
|
+
parsing, the effect-detection tables, and call-site extraction. The
|
|
5
|
+
analysis algorithms that consume these live language-neutrally in
|
|
6
|
+
``cgir/analyses``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import tree_sitter_python
|
|
12
|
+
from tree_sitter import Language, Parser
|
|
13
|
+
from tree_sitter import Node as TSNode
|
|
14
|
+
|
|
15
|
+
from cgir.languages.base import (
|
|
16
|
+
AssignDesc,
|
|
17
|
+
BranchDesc,
|
|
18
|
+
CallSite,
|
|
19
|
+
CaseDesc,
|
|
20
|
+
ClassDecl,
|
|
21
|
+
Declaration,
|
|
22
|
+
FunctionDecl,
|
|
23
|
+
HandlerDesc,
|
|
24
|
+
ImportDecl,
|
|
25
|
+
LanguageAdapter,
|
|
26
|
+
LoopDesc,
|
|
27
|
+
MatchDesc,
|
|
28
|
+
ParamDecl,
|
|
29
|
+
ReturnDesc,
|
|
30
|
+
SimpleDesc,
|
|
31
|
+
StatementDesc,
|
|
32
|
+
TryDesc,
|
|
33
|
+
VariableDecl,
|
|
34
|
+
WithDesc,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# --- effect detection tables (Python stdlib + common CV/ML libs) --------------
|
|
38
|
+
|
|
39
|
+
_IO_BUILTINS: frozenset[str] = frozenset({"print", "input", "open"})
|
|
40
|
+
_IO_DOTTED_EXACT: frozenset[str] = frozenset(
|
|
41
|
+
{
|
|
42
|
+
"cv2.VideoCapture",
|
|
43
|
+
"cv2.VideoWriter",
|
|
44
|
+
"cv2.imshow",
|
|
45
|
+
"cv2.waitKey",
|
|
46
|
+
"cv2.destroyAllWindows",
|
|
47
|
+
}
|
|
48
|
+
)
|
|
49
|
+
_NET_PREFIXES: tuple[str, ...] = (
|
|
50
|
+
"requests.",
|
|
51
|
+
# urllib.parse is pure string manipulation — only the request side is net.
|
|
52
|
+
"urllib.request.",
|
|
53
|
+
"urllib.error.",
|
|
54
|
+
"socket.",
|
|
55
|
+
"http.client.",
|
|
56
|
+
"httpx.",
|
|
57
|
+
"aiohttp.",
|
|
58
|
+
)
|
|
59
|
+
_FS_PREFIXES: tuple[str, ...] = ("shutil.",)
|
|
60
|
+
_FS_EXACT: frozenset[str] = frozenset(
|
|
61
|
+
{
|
|
62
|
+
"os.remove",
|
|
63
|
+
"os.rename",
|
|
64
|
+
"os.replace",
|
|
65
|
+
"os.unlink",
|
|
66
|
+
"os.mkdir",
|
|
67
|
+
"os.makedirs",
|
|
68
|
+
"os.rmdir",
|
|
69
|
+
"os.removedirs",
|
|
70
|
+
"os.chmod",
|
|
71
|
+
"os.chown",
|
|
72
|
+
"os.symlink",
|
|
73
|
+
"os.link",
|
|
74
|
+
"os.truncate",
|
|
75
|
+
# path-taking media / model IO (CV & ML codebases)
|
|
76
|
+
"cv2.imread",
|
|
77
|
+
"cv2.imwrite",
|
|
78
|
+
"torch.load",
|
|
79
|
+
"torch.save",
|
|
80
|
+
"np.load",
|
|
81
|
+
"np.save",
|
|
82
|
+
"np.savez",
|
|
83
|
+
"numpy.load",
|
|
84
|
+
"numpy.save",
|
|
85
|
+
"numpy.savez",
|
|
86
|
+
}
|
|
87
|
+
)
|
|
88
|
+
_FS_METHOD_SUFFIXES: tuple[str, ...] = (
|
|
89
|
+
".write_text",
|
|
90
|
+
".write_bytes",
|
|
91
|
+
".read_text",
|
|
92
|
+
".read_bytes",
|
|
93
|
+
".unlink",
|
|
94
|
+
".touch",
|
|
95
|
+
)
|
|
96
|
+
_NONDETERM_PREFIXES: tuple[str, ...] = (
|
|
97
|
+
"random.",
|
|
98
|
+
"secrets.",
|
|
99
|
+
"np.random.",
|
|
100
|
+
"numpy.random.",
|
|
101
|
+
"torch.rand",
|
|
102
|
+
)
|
|
103
|
+
_NONDETERM_EXACT: frozenset[str] = frozenset(
|
|
104
|
+
{
|
|
105
|
+
"time.time",
|
|
106
|
+
"time.time_ns",
|
|
107
|
+
"time.monotonic",
|
|
108
|
+
"time.perf_counter",
|
|
109
|
+
"uuid.uuid1",
|
|
110
|
+
"uuid.uuid4",
|
|
111
|
+
"os.urandom",
|
|
112
|
+
"os.getrandom",
|
|
113
|
+
}
|
|
114
|
+
)
|
|
115
|
+
_NONDETERM_METHOD_SUFFIXES: tuple[str, ...] = (".now", ".utcnow", ".today")
|
|
116
|
+
|
|
117
|
+
_DB_RECEIVERS: frozenset[str] = frozenset(
|
|
118
|
+
{"db", "database", "session", "conn", "connection", "cursor", "engine", "tx", "txn"}
|
|
119
|
+
)
|
|
120
|
+
_DB_METHODS: frozenset[str] = frozenset(
|
|
121
|
+
{
|
|
122
|
+
"add",
|
|
123
|
+
"add_all",
|
|
124
|
+
"begin",
|
|
125
|
+
"commit",
|
|
126
|
+
"delete",
|
|
127
|
+
"execute",
|
|
128
|
+
"executemany",
|
|
129
|
+
"fetchall",
|
|
130
|
+
"fetchmany",
|
|
131
|
+
"fetchone",
|
|
132
|
+
"flush",
|
|
133
|
+
"get",
|
|
134
|
+
"merge",
|
|
135
|
+
"query",
|
|
136
|
+
"refresh",
|
|
137
|
+
"rollback",
|
|
138
|
+
"scalar",
|
|
139
|
+
"scalars",
|
|
140
|
+
}
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class PythonAdapter(LanguageAdapter):
|
|
145
|
+
name = "python"
|
|
146
|
+
file_extensions = (".py",)
|
|
147
|
+
|
|
148
|
+
def __init__(self) -> None:
|
|
149
|
+
language = Language(tree_sitter_python.language())
|
|
150
|
+
self._parser = Parser()
|
|
151
|
+
self._parser.language = language
|
|
152
|
+
|
|
153
|
+
def parse(self, source: bytes) -> TSNode:
|
|
154
|
+
return self._parser.parse(source).root_node
|
|
155
|
+
|
|
156
|
+
def locate_function(self, root: TSNode, name: str, start_row: int) -> TSNode | None:
|
|
157
|
+
stack: list[TSNode] = [root]
|
|
158
|
+
while stack:
|
|
159
|
+
node = stack.pop()
|
|
160
|
+
if node.type == "function_definition" and node.start_point[0] == start_row:
|
|
161
|
+
name_node = node.child_by_field_name("name")
|
|
162
|
+
if (
|
|
163
|
+
name_node is not None
|
|
164
|
+
and name_node.text is not None
|
|
165
|
+
and name_node.text.decode("utf-8", errors="replace") == name
|
|
166
|
+
):
|
|
167
|
+
return node
|
|
168
|
+
stack.extend(node.children)
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
def direct_effects(self, func_node: TSNode, source: bytes, aliases: dict[str, str]) -> set[str]:
|
|
172
|
+
tags: set[str] = set()
|
|
173
|
+
body = func_node.child_by_field_name("body")
|
|
174
|
+
if body is None:
|
|
175
|
+
return tags
|
|
176
|
+
stack: list[TSNode] = [body]
|
|
177
|
+
while stack:
|
|
178
|
+
node = stack.pop()
|
|
179
|
+
if node.type == "raise_statement":
|
|
180
|
+
tags.add("raise")
|
|
181
|
+
elif node.type == "call":
|
|
182
|
+
fn = node.child_by_field_name("function")
|
|
183
|
+
if fn is not None and fn.type == "identifier":
|
|
184
|
+
name = _text(fn, source)
|
|
185
|
+
if name in _IO_BUILTINS:
|
|
186
|
+
tags.add("io")
|
|
187
|
+
elif name in aliases:
|
|
188
|
+
tag = _classify_dotted_call(aliases[name])
|
|
189
|
+
if tag is not None:
|
|
190
|
+
tags.add(tag)
|
|
191
|
+
elif fn is not None and fn.type == "attribute":
|
|
192
|
+
dotted = _text(fn, source)
|
|
193
|
+
head, _, rest = dotted.partition(".")
|
|
194
|
+
if rest and head in aliases and aliases[head] != head:
|
|
195
|
+
dotted = f"{aliases[head]}.{rest}"
|
|
196
|
+
tag = _classify_dotted_call(dotted)
|
|
197
|
+
if tag is not None:
|
|
198
|
+
tags.add(tag)
|
|
199
|
+
stack.extend(node.children)
|
|
200
|
+
return tags
|
|
201
|
+
|
|
202
|
+
# --- phase 2: CFG extraction --------------------------------------------
|
|
203
|
+
|
|
204
|
+
def function_body(self, func_node: TSNode) -> TSNode | None:
|
|
205
|
+
return func_node.child_by_field_name("body")
|
|
206
|
+
|
|
207
|
+
def block_statements(self, block: TSNode) -> list[TSNode]:
|
|
208
|
+
return [c for c in block.named_children if c.type != "comment"]
|
|
209
|
+
|
|
210
|
+
def describe_statement(self, node: TSNode, source: bytes) -> StatementDesc:
|
|
211
|
+
t = node.type
|
|
212
|
+
if t in {"if_statement", "elif_clause"}:
|
|
213
|
+
return self._describe_branch(node, source)
|
|
214
|
+
if t == "for_statement":
|
|
215
|
+
left = node.child_by_field_name("left")
|
|
216
|
+
writes, _ = _split_pattern(left, source) if left is not None else ([], [])
|
|
217
|
+
return LoopDesc(
|
|
218
|
+
reads=_extract_reads(node, source),
|
|
219
|
+
writes=writes,
|
|
220
|
+
body=node.child_by_field_name("body"),
|
|
221
|
+
)
|
|
222
|
+
if t == "while_statement":
|
|
223
|
+
return LoopDesc(
|
|
224
|
+
reads=_extract_reads(node, source),
|
|
225
|
+
writes=[],
|
|
226
|
+
body=node.child_by_field_name("body"),
|
|
227
|
+
)
|
|
228
|
+
if t == "return_statement":
|
|
229
|
+
return ReturnDesc(
|
|
230
|
+
reads=_extract_reads(node, source),
|
|
231
|
+
mutates=_extract_call_mutations(node, source),
|
|
232
|
+
)
|
|
233
|
+
if t == "with_statement":
|
|
234
|
+
writes, reads = _with_targets(node, source)
|
|
235
|
+
return WithDesc(writes=writes, reads=reads, body=node.child_by_field_name("body"))
|
|
236
|
+
if t == "try_statement":
|
|
237
|
+
return self._describe_try(node, source)
|
|
238
|
+
if t == "match_statement":
|
|
239
|
+
desc = self._describe_match(node, source)
|
|
240
|
+
if desc.cases:
|
|
241
|
+
return desc
|
|
242
|
+
# No cases: degrade to an opaque statement.
|
|
243
|
+
return SimpleDesc(
|
|
244
|
+
reads=_extract_reads(node, source),
|
|
245
|
+
mutates=_extract_call_mutations(node, source),
|
|
246
|
+
)
|
|
247
|
+
if t == "expression_statement" and _is_assignment(node):
|
|
248
|
+
writes, mutates = _extract_lhs_targets(node, source)
|
|
249
|
+
for base in _extract_call_mutations(node, source):
|
|
250
|
+
if base not in mutates:
|
|
251
|
+
mutates.append(base)
|
|
252
|
+
return AssignDesc(writes=writes, mutates=mutates, reads=_extract_reads(node, source))
|
|
253
|
+
return SimpleDesc(
|
|
254
|
+
reads=_extract_reads(node, source),
|
|
255
|
+
mutates=_extract_call_mutations(node, source),
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
def _describe_branch(self, node: TSNode, source: bytes) -> BranchDesc:
|
|
259
|
+
else_block: TSNode | None = None
|
|
260
|
+
next_branch: TSNode | None = None
|
|
261
|
+
alternative = node.child_by_field_name("alternative")
|
|
262
|
+
if alternative is not None:
|
|
263
|
+
if alternative.type == "else_clause":
|
|
264
|
+
else_block = alternative.child_by_field_name("body")
|
|
265
|
+
elif alternative.type == "elif_clause":
|
|
266
|
+
next_branch = alternative
|
|
267
|
+
return BranchDesc(
|
|
268
|
+
reads=_extract_reads(node, source),
|
|
269
|
+
consequence=node.child_by_field_name("consequence"),
|
|
270
|
+
else_block=else_block,
|
|
271
|
+
next_branch=next_branch,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
def _describe_try(self, node: TSNode, source: bytes) -> TryDesc:
|
|
275
|
+
handlers: list[HandlerDesc] = []
|
|
276
|
+
else_block: TSNode | None = None
|
|
277
|
+
finally_block: TSNode | None = None
|
|
278
|
+
for child in node.named_children:
|
|
279
|
+
if child.type == "except_clause":
|
|
280
|
+
writes: list[str] = []
|
|
281
|
+
value = child.child_by_field_name("value")
|
|
282
|
+
if value is not None and value.type == "as_pattern":
|
|
283
|
+
alias = value.child_by_field_name("alias")
|
|
284
|
+
if alias is not None and alias.named_children:
|
|
285
|
+
writes, _ = _split_pattern(alias.named_children[0], source)
|
|
286
|
+
block = next((c for c in child.children if c.type == "block"), None)
|
|
287
|
+
handlers.append(HandlerDesc(node=child, writes=writes, block=block))
|
|
288
|
+
elif child.type == "else_clause":
|
|
289
|
+
else_block = child.child_by_field_name("body")
|
|
290
|
+
elif child.type == "finally_clause":
|
|
291
|
+
finally_block = next((c for c in child.children if c.type == "block"), None)
|
|
292
|
+
return TryDesc(
|
|
293
|
+
body=node.child_by_field_name("body"),
|
|
294
|
+
handlers=handlers,
|
|
295
|
+
else_block=else_block,
|
|
296
|
+
finally_block=finally_block,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
def _describe_match(self, node: TSNode, source: bytes) -> MatchDesc:
|
|
300
|
+
subject = node.child_by_field_name("subject")
|
|
301
|
+
subject_reads: list[str] = []
|
|
302
|
+
if subject is not None:
|
|
303
|
+
seen: set[str] = set()
|
|
304
|
+
_collect_reads(subject, source, subject_reads, seen)
|
|
305
|
+
body = node.child_by_field_name("body")
|
|
306
|
+
cases: list[CaseDesc] = []
|
|
307
|
+
if body is not None:
|
|
308
|
+
for case in body.named_children:
|
|
309
|
+
if case.type != "case_clause":
|
|
310
|
+
continue
|
|
311
|
+
reads = list(subject_reads)
|
|
312
|
+
guard = case.child_by_field_name("guard")
|
|
313
|
+
if guard is not None:
|
|
314
|
+
seen_g: set[str] = set(reads)
|
|
315
|
+
_collect_reads(guard, source, reads, seen_g)
|
|
316
|
+
cases.append(
|
|
317
|
+
CaseDesc(
|
|
318
|
+
node=case,
|
|
319
|
+
reads=reads,
|
|
320
|
+
consequence=case.child_by_field_name("consequence"),
|
|
321
|
+
)
|
|
322
|
+
)
|
|
323
|
+
return MatchDesc(cases=cases)
|
|
324
|
+
|
|
325
|
+
# --- phase 3: ingest extraction ------------------------------------------
|
|
326
|
+
|
|
327
|
+
def module_declarations(
|
|
328
|
+
self, root: TSNode, source: bytes, module_name: str, rel_path: str
|
|
329
|
+
) -> list[Declaration]:
|
|
330
|
+
# Python resolves relative imports against the dotted module name;
|
|
331
|
+
# rel_path is unused.
|
|
332
|
+
decls: list[Declaration] = []
|
|
333
|
+
for child in root.children:
|
|
334
|
+
decls.extend(self._top_level_decl(child, source, module_name))
|
|
335
|
+
return decls
|
|
336
|
+
|
|
337
|
+
def _top_level_decl(self, node: TSNode, source: bytes, module_name: str) -> list[Declaration]:
|
|
338
|
+
t = node.type
|
|
339
|
+
if t == "function_definition":
|
|
340
|
+
return [self._function_decl(node, source, [], is_method=False)]
|
|
341
|
+
if t == "class_definition":
|
|
342
|
+
return [self._class_decl(node, source)]
|
|
343
|
+
if t == "decorated_definition":
|
|
344
|
+
inner = _undecorated(node)
|
|
345
|
+
if inner is None:
|
|
346
|
+
return []
|
|
347
|
+
decorators = _decorator_texts(node, source)
|
|
348
|
+
if inner.type == "function_definition":
|
|
349
|
+
return [self._function_decl(inner, source, decorators, is_method=False)]
|
|
350
|
+
if inner.type == "class_definition":
|
|
351
|
+
return [self._class_decl(inner, source)]
|
|
352
|
+
return []
|
|
353
|
+
if t in {"import_statement", "import_from_statement"}:
|
|
354
|
+
return [
|
|
355
|
+
ImportDecl(node=node, target=target, alias=alias)
|
|
356
|
+
for target, alias in _import_targets(node, source, module_name)
|
|
357
|
+
]
|
|
358
|
+
if t == "expression_statement":
|
|
359
|
+
assign = next((c for c in node.children if c.type == "assignment"), None)
|
|
360
|
+
if assign is None:
|
|
361
|
+
return []
|
|
362
|
+
left = assign.child_by_field_name("left")
|
|
363
|
+
if left is None:
|
|
364
|
+
return []
|
|
365
|
+
return [
|
|
366
|
+
VariableDecl(node=node, name=name)
|
|
367
|
+
for name in _assignment_target_names(left, source)
|
|
368
|
+
]
|
|
369
|
+
return []
|
|
370
|
+
|
|
371
|
+
def _class_decl(self, node: TSNode, source: bytes) -> ClassDecl:
|
|
372
|
+
name = _identifier_text(node.child_by_field_name("name"), source) or "<anonymous>"
|
|
373
|
+
methods: list[FunctionDecl] = []
|
|
374
|
+
body = node.child_by_field_name("body")
|
|
375
|
+
if body is not None:
|
|
376
|
+
for child in body.children:
|
|
377
|
+
if child.type == "function_definition":
|
|
378
|
+
methods.append(self._function_decl(child, source, [], is_method=True))
|
|
379
|
+
elif child.type == "decorated_definition":
|
|
380
|
+
inner = _undecorated(child)
|
|
381
|
+
if inner is not None and inner.type == "function_definition":
|
|
382
|
+
methods.append(
|
|
383
|
+
self._function_decl(
|
|
384
|
+
inner, source, _decorator_texts(child, source), is_method=True
|
|
385
|
+
)
|
|
386
|
+
)
|
|
387
|
+
fields = _class_fields(body, source) if body is not None else {}
|
|
388
|
+
return ClassDecl(node=node, name=name, methods=methods, fields=fields)
|
|
389
|
+
|
|
390
|
+
def _function_decl(
|
|
391
|
+
self, node: TSNode, source: bytes, decorators: list[str], is_method: bool
|
|
392
|
+
) -> FunctionDecl:
|
|
393
|
+
name = _identifier_text(node.child_by_field_name("name"), source) or "<anonymous>"
|
|
394
|
+
params: list[ParamDecl] = []
|
|
395
|
+
params_node = node.child_by_field_name("parameters")
|
|
396
|
+
if params_node is not None:
|
|
397
|
+
for child in params_node.children:
|
|
398
|
+
param_name = _param_name(child, source)
|
|
399
|
+
if param_name is None:
|
|
400
|
+
continue
|
|
401
|
+
if is_method and param_name == "self":
|
|
402
|
+
continue
|
|
403
|
+
params.append(ParamDecl(name=param_name, node=child))
|
|
404
|
+
return FunctionDecl(
|
|
405
|
+
node=node,
|
|
406
|
+
name=name,
|
|
407
|
+
params=params,
|
|
408
|
+
signature=_signature_text(node, source),
|
|
409
|
+
returns=_return_annotation_text(node, source),
|
|
410
|
+
doc=_docstring_text(node, source),
|
|
411
|
+
raises=_raised_names(node, source),
|
|
412
|
+
decorators=list(decorators),
|
|
413
|
+
free_names=_free_names(node, source),
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
def call_sites(self, func_node: TSNode, source: bytes) -> list[CallSite]:
|
|
417
|
+
sites: list[CallSite] = []
|
|
418
|
+
body = func_node.child_by_field_name("body")
|
|
419
|
+
if body is None:
|
|
420
|
+
return sites
|
|
421
|
+
stack: list[TSNode] = [body]
|
|
422
|
+
while stack:
|
|
423
|
+
node = stack.pop()
|
|
424
|
+
if node.type == "call":
|
|
425
|
+
function_field = node.child_by_field_name("function")
|
|
426
|
+
if function_field is not None:
|
|
427
|
+
if function_field.type == "identifier":
|
|
428
|
+
decoded: str | None = _text(function_field, source)
|
|
429
|
+
elif function_field.type == "attribute":
|
|
430
|
+
decoded = _text(function_field, source)
|
|
431
|
+
if "(" in decoded or "[" in decoded or "\n" in decoded:
|
|
432
|
+
# Computed receiver: keep just the head identifier.
|
|
433
|
+
decoded = decoded.split(".", 1)[0]
|
|
434
|
+
else:
|
|
435
|
+
decoded = None
|
|
436
|
+
if decoded:
|
|
437
|
+
arguments = node.child_by_field_name("arguments")
|
|
438
|
+
args = _arg_names(arguments, source) if arguments is not None else []
|
|
439
|
+
sites.append((decoded, args, node.start_point[0] + 1))
|
|
440
|
+
stack.extend(node.children)
|
|
441
|
+
return sites
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _text(node: TSNode, source: bytes) -> str:
|
|
445
|
+
return source[node.start_byte : node.end_byte].decode("utf-8", errors="replace")
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _classify_dotted_call(dotted: str) -> str | None:
|
|
449
|
+
"""Match a dotted callee (``requests.get``, ``p.write_text``) to a tag."""
|
|
450
|
+
if any(ch in dotted for ch in "()[] \n"):
|
|
451
|
+
# A computed receiver (call/subscript chain) — skip rather than guess.
|
|
452
|
+
return None
|
|
453
|
+
parts = dotted.split(".")
|
|
454
|
+
if len(parts) >= 2 and parts[-1] in _DB_METHODS and parts[-2] in _DB_RECEIVERS:
|
|
455
|
+
return "db"
|
|
456
|
+
if dotted in _IO_DOTTED_EXACT:
|
|
457
|
+
return "io"
|
|
458
|
+
if dotted.startswith(_NET_PREFIXES):
|
|
459
|
+
return "net"
|
|
460
|
+
if (
|
|
461
|
+
dotted in _FS_EXACT
|
|
462
|
+
or dotted.startswith(_FS_PREFIXES)
|
|
463
|
+
or dotted.endswith(_FS_METHOD_SUFFIXES)
|
|
464
|
+
):
|
|
465
|
+
return "fs"
|
|
466
|
+
if (
|
|
467
|
+
dotted in _NONDETERM_EXACT
|
|
468
|
+
or dotted.startswith(_NONDETERM_PREFIXES)
|
|
469
|
+
or dotted.endswith(_NONDETERM_METHOD_SUFFIXES)
|
|
470
|
+
):
|
|
471
|
+
return "nondeterm"
|
|
472
|
+
return None
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _arg_names(args_node: TSNode, source: bytes) -> list[str]:
|
|
476
|
+
"""Data identifiers read inside a call's argument list.
|
|
477
|
+
|
|
478
|
+
Attribute names and nested callee names are excluded — only names that
|
|
479
|
+
carry data count (mirrors the CFG ``reads`` rules).
|
|
480
|
+
"""
|
|
481
|
+
names: list[str] = []
|
|
482
|
+
seen: set[str] = set()
|
|
483
|
+
|
|
484
|
+
def collect(node: TSNode) -> None:
|
|
485
|
+
if node.type == "identifier":
|
|
486
|
+
text = _text(node, source)
|
|
487
|
+
if text not in seen:
|
|
488
|
+
seen.add(text)
|
|
489
|
+
names.append(text)
|
|
490
|
+
return
|
|
491
|
+
if node.type == "attribute":
|
|
492
|
+
obj = node.child_by_field_name("object")
|
|
493
|
+
if obj is not None:
|
|
494
|
+
collect(obj)
|
|
495
|
+
return
|
|
496
|
+
if node.type == "call":
|
|
497
|
+
fn = node.child_by_field_name("function")
|
|
498
|
+
if fn is not None and fn.type == "attribute":
|
|
499
|
+
obj = fn.child_by_field_name("object")
|
|
500
|
+
if obj is not None:
|
|
501
|
+
collect(obj)
|
|
502
|
+
inner = node.child_by_field_name("arguments")
|
|
503
|
+
if inner is not None:
|
|
504
|
+
collect(inner)
|
|
505
|
+
return
|
|
506
|
+
for child in node.children:
|
|
507
|
+
collect(child)
|
|
508
|
+
|
|
509
|
+
collect(args_node)
|
|
510
|
+
return names
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
# --- CFG statement extraction (moved from analyses/cfg.py in phase 2) ----------
|
|
514
|
+
|
|
515
|
+
_ASSIGNMENT_TYPES: frozenset[str] = frozenset({"assignment", "augmented_assignment"})
|
|
516
|
+
|
|
517
|
+
_MUTATOR_METHODS: frozenset[str] = frozenset(
|
|
518
|
+
{
|
|
519
|
+
"add",
|
|
520
|
+
"append",
|
|
521
|
+
"appendleft",
|
|
522
|
+
"clear",
|
|
523
|
+
"discard",
|
|
524
|
+
"extend",
|
|
525
|
+
"extendleft",
|
|
526
|
+
"insert",
|
|
527
|
+
"pop",
|
|
528
|
+
"popitem",
|
|
529
|
+
"popleft",
|
|
530
|
+
"put",
|
|
531
|
+
"remove",
|
|
532
|
+
"reverse",
|
|
533
|
+
"setdefault",
|
|
534
|
+
"sort",
|
|
535
|
+
"update",
|
|
536
|
+
"write",
|
|
537
|
+
"writelines",
|
|
538
|
+
# DB-session style mutators (SQLAlchemy et al.) — found on real repos
|
|
539
|
+
# where `db.delete(ch)` classified pure while `db.add(ch)` didn't.
|
|
540
|
+
"delete",
|
|
541
|
+
"commit",
|
|
542
|
+
"rollback",
|
|
543
|
+
"flush",
|
|
544
|
+
"merge",
|
|
545
|
+
"expunge",
|
|
546
|
+
}
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _is_assignment(expr_stmt: TSNode) -> bool:
|
|
551
|
+
return any(child.type in _ASSIGNMENT_TYPES for child in expr_stmt.children)
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _extract_lhs_targets(expr_stmt: TSNode, source: bytes) -> tuple[list[str], list[str]]:
|
|
555
|
+
"""Split an assignment's LHS into (writes, mutates).
|
|
556
|
+
|
|
557
|
+
``self.x = 1`` records ``mutates=["self"]``; ``xs[0] = 1`` records
|
|
558
|
+
``mutates=["xs"]``; ``x, obj.y = ...`` records ``writes=["x"]`` and
|
|
559
|
+
``mutates=["obj"]``. Augmented assignments follow the same LHS rules.
|
|
560
|
+
"""
|
|
561
|
+
for child in expr_stmt.children:
|
|
562
|
+
if child.type in _ASSIGNMENT_TYPES:
|
|
563
|
+
left = child.child_by_field_name("left")
|
|
564
|
+
if left is not None:
|
|
565
|
+
return _split_pattern(left, source)
|
|
566
|
+
return [], []
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _split_pattern(ts_node: TSNode, source: bytes) -> tuple[list[str], list[str]]:
|
|
570
|
+
if ts_node.type == "identifier":
|
|
571
|
+
return [_text(ts_node, source)], []
|
|
572
|
+
if ts_node.type == "attribute":
|
|
573
|
+
obj = ts_node.child_by_field_name("object")
|
|
574
|
+
return [], _base_names(obj, source) if obj is not None else []
|
|
575
|
+
if ts_node.type == "subscript":
|
|
576
|
+
base = ts_node.child_by_field_name("value")
|
|
577
|
+
return [], _base_names(base, source) if base is not None else []
|
|
578
|
+
if ts_node.type in {"tuple_pattern", "list_pattern", "pattern_list"}:
|
|
579
|
+
writes: list[str] = []
|
|
580
|
+
mutates: list[str] = []
|
|
581
|
+
for child in ts_node.named_children:
|
|
582
|
+
w, m = _split_pattern(child, source)
|
|
583
|
+
writes.extend(w)
|
|
584
|
+
mutates.extend(m)
|
|
585
|
+
return writes, mutates
|
|
586
|
+
return [], []
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _base_names(ts_node: TSNode, source: bytes) -> list[str]:
|
|
590
|
+
"""For ``a.b.c`` return ``['a']``; for ``xs`` return ``['xs']``."""
|
|
591
|
+
cur = ts_node
|
|
592
|
+
while cur.type in {"attribute", "subscript"}:
|
|
593
|
+
nxt = cur.child_by_field_name("object" if cur.type == "attribute" else "value")
|
|
594
|
+
if nxt is None:
|
|
595
|
+
return []
|
|
596
|
+
cur = nxt
|
|
597
|
+
if cur.type == "identifier":
|
|
598
|
+
return [_text(cur, source)]
|
|
599
|
+
return []
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _extract_reads(stmt_ts: TSNode, source: bytes) -> list[str]:
|
|
603
|
+
"""Identifier names read as data by this statement.
|
|
604
|
+
|
|
605
|
+
Per stmt kind we pick the right sub-expression (RHS / condition /
|
|
606
|
+
iterable / returned value / generic). Attribute names and called
|
|
607
|
+
function names are excluded — only data identifiers count.
|
|
608
|
+
"""
|
|
609
|
+
names: list[str] = []
|
|
610
|
+
seen: set[str] = set()
|
|
611
|
+
if stmt_ts.type == "expression_statement":
|
|
612
|
+
aug = next((c for c in stmt_ts.children if c.type == "augmented_assignment"), None)
|
|
613
|
+
if aug is not None:
|
|
614
|
+
left = aug.child_by_field_name("left")
|
|
615
|
+
right = aug.child_by_field_name("right")
|
|
616
|
+
if left is not None:
|
|
617
|
+
_collect_reads(left, source, names, seen)
|
|
618
|
+
if right is not None:
|
|
619
|
+
_collect_reads(right, source, names, seen)
|
|
620
|
+
return names
|
|
621
|
+
target = _read_target(stmt_ts)
|
|
622
|
+
if target is None:
|
|
623
|
+
return []
|
|
624
|
+
_collect_reads(target, source, names, seen)
|
|
625
|
+
return names
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def _with_targets(with_ts: TSNode, source: bytes) -> tuple[list[str], list[str]]:
|
|
629
|
+
"""(writes, reads) for a ``with`` header."""
|
|
630
|
+
writes: list[str] = []
|
|
631
|
+
reads: list[str] = []
|
|
632
|
+
seen: set[str] = set()
|
|
633
|
+
clause = next((c for c in with_ts.children if c.type == "with_clause"), None)
|
|
634
|
+
if clause is None:
|
|
635
|
+
return writes, reads
|
|
636
|
+
for item in clause.named_children:
|
|
637
|
+
if item.type != "with_item":
|
|
638
|
+
continue
|
|
639
|
+
value = item.child_by_field_name("value")
|
|
640
|
+
if value is None:
|
|
641
|
+
continue
|
|
642
|
+
if value.type == "as_pattern":
|
|
643
|
+
context = value.named_children[0] if value.named_children else None
|
|
644
|
+
alias = value.child_by_field_name("alias")
|
|
645
|
+
if context is not None:
|
|
646
|
+
_collect_reads(context, source, reads, seen)
|
|
647
|
+
if alias is not None and alias.named_children:
|
|
648
|
+
w, _ = _split_pattern(alias.named_children[0], source)
|
|
649
|
+
writes.extend(w)
|
|
650
|
+
else:
|
|
651
|
+
_collect_reads(value, source, reads, seen)
|
|
652
|
+
return writes, reads
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _extract_call_mutations(stmt_ts: TSNode, source: bytes) -> list[str]:
|
|
656
|
+
"""Receiver base names mutated by mutator method calls in this statement.
|
|
657
|
+
|
|
658
|
+
Walks the whole statement subtree, so ``xs.append(x)``, assignment RHS
|
|
659
|
+
(``x = xs.pop()``), and return expressions all count.
|
|
660
|
+
"""
|
|
661
|
+
names: list[str] = []
|
|
662
|
+
stack: list[TSNode] = [stmt_ts]
|
|
663
|
+
while stack:
|
|
664
|
+
node = stack.pop()
|
|
665
|
+
if node.type == "call":
|
|
666
|
+
fn = node.child_by_field_name("function")
|
|
667
|
+
if fn is not None and fn.type == "attribute":
|
|
668
|
+
attr = fn.child_by_field_name("attribute")
|
|
669
|
+
obj = fn.child_by_field_name("object")
|
|
670
|
+
if attr is not None and obj is not None and _text(attr, source) in _MUTATOR_METHODS:
|
|
671
|
+
for base in _base_names(obj, source):
|
|
672
|
+
if base not in names:
|
|
673
|
+
names.append(base)
|
|
674
|
+
stack.extend(node.children)
|
|
675
|
+
return names
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _read_target(ts_node: TSNode) -> TSNode | None:
|
|
679
|
+
if ts_node.type == "expression_statement":
|
|
680
|
+
for child in ts_node.children:
|
|
681
|
+
if child.type == "assignment":
|
|
682
|
+
# For assignments, only the RHS contributes data reads. The
|
|
683
|
+
# LHS base (in attribute/subscript) is handled via `mutates`.
|
|
684
|
+
return child.child_by_field_name("right")
|
|
685
|
+
return ts_node
|
|
686
|
+
if ts_node.type == "return_statement":
|
|
687
|
+
for child in ts_node.named_children:
|
|
688
|
+
return child
|
|
689
|
+
return None
|
|
690
|
+
if ts_node.type in {"if_statement", "elif_clause", "while_statement"}:
|
|
691
|
+
return ts_node.child_by_field_name("condition")
|
|
692
|
+
if ts_node.type == "for_statement":
|
|
693
|
+
return ts_node.child_by_field_name("right")
|
|
694
|
+
return ts_node
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def _collect_reads(ts_node: TSNode, source: bytes, names: list[str], seen: set[str]) -> None:
|
|
698
|
+
if ts_node.type == "identifier":
|
|
699
|
+
name = _text(ts_node, source)
|
|
700
|
+
if name not in seen:
|
|
701
|
+
seen.add(name)
|
|
702
|
+
names.append(name)
|
|
703
|
+
return
|
|
704
|
+
if ts_node.type == "attribute":
|
|
705
|
+
obj = ts_node.child_by_field_name("object")
|
|
706
|
+
if obj is not None:
|
|
707
|
+
_collect_reads(obj, source, names, seen)
|
|
708
|
+
return # skip the attribute identifier
|
|
709
|
+
if ts_node.type == "call":
|
|
710
|
+
fn = ts_node.child_by_field_name("function")
|
|
711
|
+
if fn is not None and fn.type == "attribute":
|
|
712
|
+
obj = fn.child_by_field_name("object")
|
|
713
|
+
if obj is not None:
|
|
714
|
+
_collect_reads(obj, source, names, seen)
|
|
715
|
+
# else: a bare-identifier callee is not a data read; skip.
|
|
716
|
+
args = ts_node.child_by_field_name("arguments")
|
|
717
|
+
if args is not None:
|
|
718
|
+
_collect_reads(args, source, names, seen)
|
|
719
|
+
return
|
|
720
|
+
for child in ts_node.children:
|
|
721
|
+
_collect_reads(child, source, names, seen)
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
# --- module/ingest extraction (moved from sources/tree_sitter_source.py) -------
|
|
725
|
+
|
|
726
|
+
_PY_BUILTINS: frozenset[str] = frozenset(
|
|
727
|
+
{
|
|
728
|
+
"True",
|
|
729
|
+
"False",
|
|
730
|
+
"None",
|
|
731
|
+
"self",
|
|
732
|
+
"cls",
|
|
733
|
+
"print",
|
|
734
|
+
"len",
|
|
735
|
+
"range",
|
|
736
|
+
"enumerate",
|
|
737
|
+
"zip",
|
|
738
|
+
"map",
|
|
739
|
+
"filter",
|
|
740
|
+
"sorted",
|
|
741
|
+
"list",
|
|
742
|
+
"dict",
|
|
743
|
+
"set",
|
|
744
|
+
"tuple",
|
|
745
|
+
"frozenset",
|
|
746
|
+
"str",
|
|
747
|
+
"int",
|
|
748
|
+
"float",
|
|
749
|
+
"bool",
|
|
750
|
+
"bytes",
|
|
751
|
+
"type",
|
|
752
|
+
"isinstance",
|
|
753
|
+
"issubclass",
|
|
754
|
+
"hasattr",
|
|
755
|
+
"getattr",
|
|
756
|
+
"setattr",
|
|
757
|
+
"min",
|
|
758
|
+
"max",
|
|
759
|
+
"sum",
|
|
760
|
+
"abs",
|
|
761
|
+
"round",
|
|
762
|
+
"any",
|
|
763
|
+
"all",
|
|
764
|
+
"next",
|
|
765
|
+
"iter",
|
|
766
|
+
"open",
|
|
767
|
+
"super",
|
|
768
|
+
"object",
|
|
769
|
+
"Exception",
|
|
770
|
+
"ValueError",
|
|
771
|
+
"TypeError",
|
|
772
|
+
"KeyError",
|
|
773
|
+
"RuntimeError",
|
|
774
|
+
"StopIteration",
|
|
775
|
+
"repr",
|
|
776
|
+
"format",
|
|
777
|
+
"vars",
|
|
778
|
+
"dir",
|
|
779
|
+
"id",
|
|
780
|
+
"input",
|
|
781
|
+
"reversed",
|
|
782
|
+
"slice",
|
|
783
|
+
"property",
|
|
784
|
+
"staticmethod",
|
|
785
|
+
"classmethod",
|
|
786
|
+
}
|
|
787
|
+
)
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def _type_base(type_node: TSNode | None, source: bytes) -> str | None:
|
|
791
|
+
"""Base class name of an annotation: ``Svc`` from ``Svc``/``Svc[T]``.
|
|
792
|
+
|
|
793
|
+
Dotted annotations (``mod.Svc``) are skipped — they resolve through a
|
|
794
|
+
module binding, not a plain name, and guessing the tail risks false
|
|
795
|
+
positives in field-call resolution.
|
|
796
|
+
"""
|
|
797
|
+
if type_node is None:
|
|
798
|
+
return None
|
|
799
|
+
node = type_node
|
|
800
|
+
if node.type == "type" and node.named_child_count:
|
|
801
|
+
node = node.named_children[0]
|
|
802
|
+
if node.type == "identifier":
|
|
803
|
+
return _text(node, source)
|
|
804
|
+
if node.type == "generic_type" or node.type == "subscript":
|
|
805
|
+
first = node.named_children[0] if node.named_child_count else None
|
|
806
|
+
if first is not None and first.type == "identifier":
|
|
807
|
+
return _text(first, source)
|
|
808
|
+
return None
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def _class_fields(body: TSNode, source: bytes) -> dict[str, str]:
|
|
812
|
+
"""Field name -> declared type, from the DI-relevant idioms.
|
|
813
|
+
|
|
814
|
+
Class-level annotations (``svc: Svc``), ``__init__`` params stored on
|
|
815
|
+
``self`` (``self.svc = svc`` where ``svc`` is annotated), and direct
|
|
816
|
+
construction (``self.svc = Svc()``). Feeds ``self.<field>.<method>()``
|
|
817
|
+
call resolution — the Python analog of TS constructor DI.
|
|
818
|
+
"""
|
|
819
|
+
fields: dict[str, str] = {}
|
|
820
|
+
for child in body.children:
|
|
821
|
+
if child.type == "expression_statement" and child.child_count:
|
|
822
|
+
assign = child.children[0]
|
|
823
|
+
if assign.type != "assignment":
|
|
824
|
+
continue
|
|
825
|
+
left = assign.child_by_field_name("left")
|
|
826
|
+
base = _type_base(assign.child_by_field_name("type"), source)
|
|
827
|
+
if left is not None and left.type == "identifier" and base:
|
|
828
|
+
fields[_text(left, source)] = base
|
|
829
|
+
continue
|
|
830
|
+
init = child if child.type == "function_definition" else _undecorated(child)
|
|
831
|
+
if (
|
|
832
|
+
init is not None
|
|
833
|
+
and init.type == "function_definition"
|
|
834
|
+
and _identifier_text(init.child_by_field_name("name"), source) == "__init__"
|
|
835
|
+
):
|
|
836
|
+
fields.update(_init_fields(init, source))
|
|
837
|
+
return fields
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def _init_fields(init: TSNode, source: bytes) -> dict[str, str]:
|
|
841
|
+
param_types: dict[str, str] = {}
|
|
842
|
+
params = init.child_by_field_name("parameters")
|
|
843
|
+
for param in params.children if params is not None else []:
|
|
844
|
+
if param.type in ("typed_parameter", "typed_default_parameter"):
|
|
845
|
+
name_node = next((c for c in param.children if c.type == "identifier"), None)
|
|
846
|
+
base = _type_base(param.child_by_field_name("type"), source)
|
|
847
|
+
if name_node is not None and base:
|
|
848
|
+
param_types[_text(name_node, source)] = base
|
|
849
|
+
|
|
850
|
+
fields: dict[str, str] = {}
|
|
851
|
+
stack = [init.child_by_field_name("body")]
|
|
852
|
+
while stack:
|
|
853
|
+
node = stack.pop()
|
|
854
|
+
if node is None:
|
|
855
|
+
continue
|
|
856
|
+
if node.type == "assignment":
|
|
857
|
+
left = node.child_by_field_name("left")
|
|
858
|
+
right = node.child_by_field_name("right")
|
|
859
|
+
if (
|
|
860
|
+
left is not None
|
|
861
|
+
and left.type == "attribute"
|
|
862
|
+
and right is not None
|
|
863
|
+
and _identifier_text(left.child_by_field_name("object"), source) == "self"
|
|
864
|
+
):
|
|
865
|
+
attr = _identifier_text(left.child_by_field_name("attribute"), source)
|
|
866
|
+
if attr:
|
|
867
|
+
if right.type == "identifier" and _text(right, source) in param_types:
|
|
868
|
+
fields[attr] = param_types[_text(right, source)]
|
|
869
|
+
elif right.type == "call":
|
|
870
|
+
callee = right.child_by_field_name("function")
|
|
871
|
+
if callee is not None and callee.type == "identifier":
|
|
872
|
+
fields[attr] = _text(callee, source)
|
|
873
|
+
stack.extend(node.children)
|
|
874
|
+
return fields
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
def _undecorated(decorated_ts: TSNode) -> TSNode | None:
|
|
878
|
+
"""Return the function/class wrapped by a ``decorated_definition``."""
|
|
879
|
+
for child in decorated_ts.named_children:
|
|
880
|
+
if child.type in {"function_definition", "class_definition"}:
|
|
881
|
+
return child
|
|
882
|
+
return None
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
def _decorator_texts(decorated_ts: TSNode, source: bytes) -> list[str]:
|
|
886
|
+
"""Each decorator's call text, without the leading ``@``."""
|
|
887
|
+
texts: list[str] = []
|
|
888
|
+
for child in decorated_ts.named_children:
|
|
889
|
+
if child.type == "decorator":
|
|
890
|
+
raw = source[child.start_byte : child.end_byte].decode("utf-8", errors="replace")
|
|
891
|
+
texts.append(raw.lstrip("@").strip())
|
|
892
|
+
return texts
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
def _identifier_text(node: TSNode | None, source: bytes) -> str | None:
|
|
896
|
+
if node is None:
|
|
897
|
+
return None
|
|
898
|
+
return source[node.start_byte : node.end_byte].decode("utf-8", errors="replace")
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def _signature_text(func_node: TSNode, source: bytes) -> str:
|
|
902
|
+
name = _identifier_text(func_node.child_by_field_name("name"), source) or ""
|
|
903
|
+
params_node = func_node.child_by_field_name("parameters")
|
|
904
|
+
params_text = _identifier_text(params_node, source) or "()"
|
|
905
|
+
return_node = func_node.child_by_field_name("return_type")
|
|
906
|
+
return_text = _identifier_text(return_node, source)
|
|
907
|
+
sig = f"{name}{params_text}"
|
|
908
|
+
if return_text:
|
|
909
|
+
sig += f" -> {return_text}"
|
|
910
|
+
return sig
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def _return_annotation_text(func_node: TSNode, source: bytes) -> str | None:
|
|
914
|
+
"""The declared return type (``-> float`` gives ``"float"``), if any."""
|
|
915
|
+
return _identifier_text(func_node.child_by_field_name("return_type"), source)
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def _docstring_text(func_node: TSNode, source: bytes) -> str:
|
|
919
|
+
"""The function's docstring (first string statement in the body), cleaned."""
|
|
920
|
+
body = func_node.child_by_field_name("body")
|
|
921
|
+
if body is None:
|
|
922
|
+
return ""
|
|
923
|
+
for stmt in body.named_children:
|
|
924
|
+
if stmt.type == "comment":
|
|
925
|
+
continue
|
|
926
|
+
if stmt.type == "expression_statement" and stmt.named_children:
|
|
927
|
+
inner = stmt.named_children[0]
|
|
928
|
+
if inner.type == "string":
|
|
929
|
+
raw = source[inner.start_byte : inner.end_byte].decode("utf-8", errors="replace")
|
|
930
|
+
return _clean_docstring(raw)
|
|
931
|
+
return "" # first real statement isn't a string -> no docstring
|
|
932
|
+
return ""
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
def _clean_docstring(raw: str) -> str:
|
|
936
|
+
text = raw.strip()
|
|
937
|
+
for quote in ('"""', "'''", '"', "'"):
|
|
938
|
+
if text.startswith(quote):
|
|
939
|
+
text = text[len(quote) :]
|
|
940
|
+
if text.endswith(quote):
|
|
941
|
+
text = text[: -len(quote)]
|
|
942
|
+
break
|
|
943
|
+
return text.strip()
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
def _raised_names(func_node: TSNode, source: bytes) -> list[str]:
|
|
947
|
+
"""Exception class names raised in the body."""
|
|
948
|
+
body = func_node.child_by_field_name("body")
|
|
949
|
+
if body is None:
|
|
950
|
+
return []
|
|
951
|
+
names: list[str] = []
|
|
952
|
+
seen: set[str] = set()
|
|
953
|
+
stack: list[TSNode] = [body]
|
|
954
|
+
while stack:
|
|
955
|
+
node = stack.pop()
|
|
956
|
+
if node.type == "raise_statement":
|
|
957
|
+
for child in node.named_children:
|
|
958
|
+
target = child.child_by_field_name("function") if child.type == "call" else child
|
|
959
|
+
if target is None:
|
|
960
|
+
continue
|
|
961
|
+
text = _text(target, source)
|
|
962
|
+
name = text.split(".")[-1].split("(")[0].strip()
|
|
963
|
+
if name and name[0].isupper() and name not in seen:
|
|
964
|
+
seen.add(name)
|
|
965
|
+
names.append(name)
|
|
966
|
+
break
|
|
967
|
+
stack.extend(node.children)
|
|
968
|
+
return names
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
def _free_names(func_node: TSNode, source: bytes) -> list[str]:
|
|
972
|
+
"""Names referenced in the body that are not params, locals, or builtins."""
|
|
973
|
+
body = func_node.child_by_field_name("body")
|
|
974
|
+
if body is None:
|
|
975
|
+
return []
|
|
976
|
+
bound: set[str] = set()
|
|
977
|
+
params = func_node.child_by_field_name("parameters")
|
|
978
|
+
if params is not None:
|
|
979
|
+
for child in params.children:
|
|
980
|
+
name = _param_name(child, source)
|
|
981
|
+
if name:
|
|
982
|
+
bound.add(name)
|
|
983
|
+
_collect_bound(body, source, bound)
|
|
984
|
+
|
|
985
|
+
referenced: list[str] = []
|
|
986
|
+
seen: set[str] = set()
|
|
987
|
+
_collect_referenced(body, source, referenced, seen)
|
|
988
|
+
return [n for n in referenced if n not in bound and n not in _PY_BUILTINS]
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
def _collect_bound(node: TSNode, source: bytes, bound: set[str]) -> None:
|
|
992
|
+
t = node.type
|
|
993
|
+
if t in {"assignment", "augmented_assignment"} or t == "for_statement":
|
|
994
|
+
left = node.child_by_field_name("left")
|
|
995
|
+
if left is not None:
|
|
996
|
+
bound.update(_assignment_target_names(left, source))
|
|
997
|
+
elif t in {"function_definition", "class_definition"}:
|
|
998
|
+
def_name = _identifier_text(node.child_by_field_name("name"), source)
|
|
999
|
+
if def_name:
|
|
1000
|
+
bound.add(def_name)
|
|
1001
|
+
elif t == "as_pattern":
|
|
1002
|
+
alias_node = node.child_by_field_name("alias")
|
|
1003
|
+
if alias_node is not None:
|
|
1004
|
+
for ident in _all_identifiers(alias_node, source):
|
|
1005
|
+
bound.add(ident)
|
|
1006
|
+
elif t == "except_clause":
|
|
1007
|
+
for child in node.children:
|
|
1008
|
+
if child.type == "identifier":
|
|
1009
|
+
bound.add(_text(child, source))
|
|
1010
|
+
elif t == "named_expression":
|
|
1011
|
+
name_node = node.child_by_field_name("name")
|
|
1012
|
+
if name_node is not None:
|
|
1013
|
+
bound.add(_text(name_node, source))
|
|
1014
|
+
elif t in {"import_statement", "import_from_statement"}:
|
|
1015
|
+
for target, alias in _import_targets(node, source, ""):
|
|
1016
|
+
bound.add(alias or target.rsplit(".", 1)[-1])
|
|
1017
|
+
for child in node.children:
|
|
1018
|
+
_collect_bound(child, source, bound)
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
def _collect_referenced(node: TSNode, source: bytes, out: list[str], seen: set[str]) -> None:
|
|
1022
|
+
t = node.type
|
|
1023
|
+
if t == "identifier":
|
|
1024
|
+
name = _text(node, source)
|
|
1025
|
+
if name not in seen:
|
|
1026
|
+
seen.add(name)
|
|
1027
|
+
out.append(name)
|
|
1028
|
+
return
|
|
1029
|
+
if t == "attribute":
|
|
1030
|
+
obj = node.child_by_field_name("object")
|
|
1031
|
+
if obj is not None:
|
|
1032
|
+
_collect_referenced(obj, source, out, seen)
|
|
1033
|
+
return # skip the attribute suffix name
|
|
1034
|
+
if t == "keyword_argument":
|
|
1035
|
+
value = node.child_by_field_name("value")
|
|
1036
|
+
if value is not None:
|
|
1037
|
+
_collect_referenced(value, source, out, seen)
|
|
1038
|
+
return # skip the keyword name
|
|
1039
|
+
if t in {"import_statement", "import_from_statement"}:
|
|
1040
|
+
return
|
|
1041
|
+
for child in node.children:
|
|
1042
|
+
_collect_referenced(child, source, out, seen)
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
def _all_identifiers(node: TSNode, source: bytes) -> list[str]:
|
|
1046
|
+
out: list[str] = []
|
|
1047
|
+
if node.type == "identifier":
|
|
1048
|
+
return [_text(node, source)]
|
|
1049
|
+
for child in node.children:
|
|
1050
|
+
out.extend(_all_identifiers(child, source))
|
|
1051
|
+
return out
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
def _param_name(node: TSNode, source: bytes) -> str | None:
|
|
1055
|
+
if node.type == "identifier":
|
|
1056
|
+
return _identifier_text(node, source)
|
|
1057
|
+
if node.type in {
|
|
1058
|
+
"typed_parameter",
|
|
1059
|
+
"default_parameter",
|
|
1060
|
+
"typed_default_parameter",
|
|
1061
|
+
"list_splat_pattern",
|
|
1062
|
+
"dictionary_splat_pattern",
|
|
1063
|
+
}:
|
|
1064
|
+
# Search children for the identifier that names the parameter.
|
|
1065
|
+
for child in node.children:
|
|
1066
|
+
if child.type == "identifier":
|
|
1067
|
+
return _identifier_text(child, source)
|
|
1068
|
+
return None
|
|
1069
|
+
|
|
1070
|
+
|
|
1071
|
+
def _assignment_target_names(left: TSNode, source: bytes) -> list[str]:
|
|
1072
|
+
"""Bound names on an assignment LHS: ``x``, ``x, y``; attribute/subscript LHS none."""
|
|
1073
|
+
if left.type == "identifier":
|
|
1074
|
+
text = _identifier_text(left, source)
|
|
1075
|
+
return [text] if text else []
|
|
1076
|
+
if left.type in {"pattern_list", "tuple_pattern", "list_pattern"}:
|
|
1077
|
+
names: list[str] = []
|
|
1078
|
+
for child in left.named_children:
|
|
1079
|
+
names.extend(_assignment_target_names(child, source))
|
|
1080
|
+
return names
|
|
1081
|
+
return []
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
def _import_targets(
|
|
1085
|
+
node: TSNode, source: bytes, current_module: str
|
|
1086
|
+
) -> list[tuple[str, str | None]]:
|
|
1087
|
+
"""Yield ``(absolute_target, local_alias_or_None)`` per imported name."""
|
|
1088
|
+
targets: list[tuple[str, str | None]] = []
|
|
1089
|
+
if node.type == "import_statement":
|
|
1090
|
+
for child in node.children:
|
|
1091
|
+
if child.type in {"dotted_name", "aliased_import"}:
|
|
1092
|
+
name = _identifier_text(_name_child(child), source)
|
|
1093
|
+
if name:
|
|
1094
|
+
targets.append((name, _alias_text(child, source)))
|
|
1095
|
+
elif node.type == "import_from_statement":
|
|
1096
|
+
module_node = node.child_by_field_name("module_name")
|
|
1097
|
+
module = _resolve_from_module(module_node, source, current_module) if module_node else ""
|
|
1098
|
+
for child in node.children_by_field_name("name"):
|
|
1099
|
+
name = _identifier_text(_name_child(child), source)
|
|
1100
|
+
if name:
|
|
1101
|
+
target = f"{module}.{name}" if module else name
|
|
1102
|
+
targets.append((target, _alias_text(child, source)))
|
|
1103
|
+
return targets
|
|
1104
|
+
|
|
1105
|
+
|
|
1106
|
+
def _alias_text(ts_node: TSNode, source: bytes) -> str | None:
|
|
1107
|
+
"""The ``as`` alias of an ``aliased_import``, if any."""
|
|
1108
|
+
if ts_node.type != "aliased_import":
|
|
1109
|
+
return None
|
|
1110
|
+
return _identifier_text(ts_node.child_by_field_name("alias"), source)
|
|
1111
|
+
|
|
1112
|
+
|
|
1113
|
+
def _resolve_from_module(module_node: TSNode, source: bytes, current_module: str) -> str:
|
|
1114
|
+
"""Absolute dotted name of an ``import_from_statement`` module (incl. relative)."""
|
|
1115
|
+
if module_node.type == "relative_import":
|
|
1116
|
+
dots = 0
|
|
1117
|
+
sub_name = ""
|
|
1118
|
+
for child in module_node.children:
|
|
1119
|
+
if child.type == "import_prefix":
|
|
1120
|
+
# import_prefix's text is one or more "." characters.
|
|
1121
|
+
raw = source[child.start_byte : child.end_byte]
|
|
1122
|
+
dots = raw.count(b".")
|
|
1123
|
+
elif child.type == "dotted_name":
|
|
1124
|
+
sub_name = _identifier_text(child, source) or ""
|
|
1125
|
+
if dots == 0:
|
|
1126
|
+
return sub_name
|
|
1127
|
+
parts = current_module.split(".")
|
|
1128
|
+
# Drop the module itself; each extra dot peels off one more package level.
|
|
1129
|
+
package_parts = parts[:-1]
|
|
1130
|
+
up = dots - 1
|
|
1131
|
+
if up > 0:
|
|
1132
|
+
package_parts = package_parts[:-up] if up <= len(package_parts) else []
|
|
1133
|
+
absolute = list(package_parts)
|
|
1134
|
+
if sub_name:
|
|
1135
|
+
absolute.extend(sub_name.split("."))
|
|
1136
|
+
return ".".join(absolute)
|
|
1137
|
+
return _identifier_text(module_node, source) or ""
|
|
1138
|
+
|
|
1139
|
+
|
|
1140
|
+
def _name_child(node: TSNode) -> TSNode:
|
|
1141
|
+
if node.type == "aliased_import":
|
|
1142
|
+
sub = node.child_by_field_name("name")
|
|
1143
|
+
if sub is not None:
|
|
1144
|
+
return sub
|
|
1145
|
+
return node
|