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.
Files changed (63) hide show
  1. cgir/__init__.py +6 -0
  2. cgir/analyses/__init__.py +7 -0
  3. cgir/analyses/call_graph.py +114 -0
  4. cgir/analyses/cfg.py +320 -0
  5. cgir/analyses/effects.py +95 -0
  6. cgir/analyses/entrypoints.py +55 -0
  7. cgir/analyses/param_flow.py +75 -0
  8. cgir/analyses/pdg.py +75 -0
  9. cgir/analyses/purity.py +37 -0
  10. cgir/analyses/reaching_defs.py +115 -0
  11. cgir/analyses/symbols.py +106 -0
  12. cgir/api/__init__.py +1 -0
  13. cgir/api/mcp_server.py +172 -0
  14. cgir/api/server.py +107 -0
  15. cgir/cli.py +688 -0
  16. cgir/config.py +22 -0
  17. cgir/export/__init__.py +5 -0
  18. cgir/export/graphml.py +52 -0
  19. cgir/export/html_viz.py +1087 -0
  20. cgir/export/json_export.py +37 -0
  21. cgir/export/mermaid.py +57 -0
  22. cgir/export/neo4j.py +11 -0
  23. cgir/hooks.py +189 -0
  24. cgir/ir/__init__.py +16 -0
  25. cgir/ir/component_spec.py +100 -0
  26. cgir/ir/edges.py +31 -0
  27. cgir/ir/graph.py +132 -0
  28. cgir/ir/nodes.py +38 -0
  29. cgir/languages/__init__.py +25 -0
  30. cgir/languages/base.py +221 -0
  31. cgir/languages/cache.py +73 -0
  32. cgir/languages/python.py +1145 -0
  33. cgir/languages/registry.py +24 -0
  34. cgir/languages/typescript.py +853 -0
  35. cgir/manifest.py +77 -0
  36. cgir/pipeline.py +54 -0
  37. cgir/py.typed +0 -0
  38. cgir/regenerate/__init__.py +6 -0
  39. cgir/regenerate/prompt_pack.py +16 -0
  40. cgir/regenerate/regenerator.py +108 -0
  41. cgir/report/__init__.py +5 -0
  42. cgir/report/diff.py +226 -0
  43. cgir/report/flow.py +84 -0
  44. cgir/report/impact.py +234 -0
  45. cgir/report/lint.py +84 -0
  46. cgir/report/pack.py +271 -0
  47. cgir/report/stats.py +121 -0
  48. cgir/slicing/__init__.py +5 -0
  49. cgir/slicing/slicer.py +174 -0
  50. cgir/sources/__init__.py +6 -0
  51. cgir/sources/base.py +14 -0
  52. cgir/sources/codeql_source.py +13 -0
  53. cgir/sources/joern_source.py +13 -0
  54. cgir/sources/tree_sitter_source.py +270 -0
  55. cgir/trace/__init__.py +5 -0
  56. cgir/trace/trace_map.py +83 -0
  57. cgir/verify.py +173 -0
  58. cgir/watch.py +171 -0
  59. codegraph_ir-0.1.0.dist-info/METADATA +143 -0
  60. codegraph_ir-0.1.0.dist-info/RECORD +63 -0
  61. codegraph_ir-0.1.0.dist-info/WHEEL +4 -0
  62. codegraph_ir-0.1.0.dist-info/entry_points.txt +2 -0
  63. codegraph_ir-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -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