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,853 @@
1
+ """TypeScriptAdapter — the TypeScript implementation of :class:`LanguageAdapter`.
2
+
3
+ Maps the tree-sitter-typescript grammar to CGIR's normalized descriptors.
4
+ Covers function/arrow/method declarations, classes, ES module imports
5
+ (with relative-specifier resolution), the common statement forms
6
+ (if/for-of/for/while/return/try/switch/throw + const/let/var + assignment),
7
+ call sites, and a JS/TS-flavoured effect table.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import posixpath
13
+ from collections.abc import Iterator
14
+
15
+ import tree_sitter_typescript as tsts
16
+ from tree_sitter import Language, Parser
17
+ from tree_sitter import Node as TSNode
18
+
19
+ from cgir.languages.base import (
20
+ AssignDesc,
21
+ BranchDesc,
22
+ CallSite,
23
+ CaseDesc,
24
+ ClassDecl,
25
+ Declaration,
26
+ FunctionDecl,
27
+ HandlerDesc,
28
+ ImportDecl,
29
+ LanguageAdapter,
30
+ LoopDesc,
31
+ MatchDesc,
32
+ ParamDecl,
33
+ ReturnDesc,
34
+ SimpleDesc,
35
+ StatementDesc,
36
+ TryDesc,
37
+ VariableDecl,
38
+ )
39
+
40
+ # --- effect tables (JS/TS runtime + common libs) ------------------------------
41
+
42
+ _IO_PREFIXES: tuple[str, ...] = ("console.",)
43
+ _NET_EXACT: frozenset[str] = frozenset({"fetch"})
44
+ _NET_PREFIXES: tuple[str, ...] = ("axios.", "http.", "https.", "got.", "superagent.")
45
+ # HTTP-client method calls: `<receiver>.get(...)` where the receiver names an
46
+ # HTTP client (Angular DI: `this.http.get`, `this.httpClient.post`).
47
+ _NET_RECEIVERS: frozenset[str] = frozenset({"http", "https", "httpClient", "httpclient", "$http"})
48
+ _HTTP_VERBS: frozenset[str] = frozenset(
49
+ {"get", "post", "put", "delete", "patch", "head", "options", "request"}
50
+ )
51
+ _FS_PREFIXES: tuple[str, ...] = ("fs.", "fsPromises.", "fs.promises.")
52
+ _FS_METHOD_SUFFIXES: tuple[str, ...] = (
53
+ ".readFile",
54
+ ".writeFile",
55
+ ".readFileSync",
56
+ ".writeFileSync",
57
+ ".appendFile",
58
+ ".unlink",
59
+ ".mkdir",
60
+ ".rmdir",
61
+ ".rm",
62
+ )
63
+ _NONDETERM_EXACT: frozenset[str] = frozenset(
64
+ {"Math.random", "Date.now", "crypto.randomUUID", "crypto.randomBytes", "performance.now"}
65
+ )
66
+ _DB_RECEIVERS: frozenset[str] = frozenset(
67
+ {
68
+ "db",
69
+ "database",
70
+ "pool",
71
+ "client",
72
+ "conn",
73
+ "connection",
74
+ "knex",
75
+ "prisma",
76
+ "repo",
77
+ "repository",
78
+ "queryRunner",
79
+ }
80
+ )
81
+ _DB_METHODS: frozenset[str] = frozenset(
82
+ {
83
+ "query",
84
+ "execute",
85
+ "insert",
86
+ "update",
87
+ "delete",
88
+ "save",
89
+ "find",
90
+ "findOne",
91
+ "findMany",
92
+ "create",
93
+ "commit",
94
+ "rollback",
95
+ "transaction",
96
+ "raw",
97
+ }
98
+ )
99
+
100
+ _MUTATOR_METHODS: frozenset[str] = frozenset(
101
+ {
102
+ "push",
103
+ "pop",
104
+ "shift",
105
+ "unshift",
106
+ "splice",
107
+ "sort",
108
+ "reverse",
109
+ "fill",
110
+ "copyWithin",
111
+ "set",
112
+ "add",
113
+ "delete",
114
+ "clear",
115
+ }
116
+ )
117
+
118
+ _JS_GLOBALS: frozenset[str] = frozenset(
119
+ {
120
+ "true",
121
+ "false",
122
+ "null",
123
+ "undefined",
124
+ "this",
125
+ "super",
126
+ "console",
127
+ "Math",
128
+ "Date",
129
+ "JSON",
130
+ "Object",
131
+ "Array",
132
+ "String",
133
+ "Number",
134
+ "Boolean",
135
+ "Promise",
136
+ "Error",
137
+ "Map",
138
+ "Set",
139
+ "Symbol",
140
+ "RegExp",
141
+ "parseInt",
142
+ "parseFloat",
143
+ "isNaN",
144
+ "Infinity",
145
+ "NaN",
146
+ "require",
147
+ "module",
148
+ "exports",
149
+ "process",
150
+ "window",
151
+ "document",
152
+ "globalThis",
153
+ "Reflect",
154
+ "Proxy",
155
+ "WeakMap",
156
+ "WeakSet",
157
+ }
158
+ )
159
+
160
+ _FUNCTION_VALUES: frozenset[str] = frozenset({"arrow_function", "function_expression", "function"})
161
+
162
+
163
+ class TypeScriptAdapter(LanguageAdapter):
164
+ name = "typescript"
165
+ file_extensions = (".ts", ".tsx")
166
+
167
+ def __init__(self) -> None:
168
+ self._parser = Parser(Language(tsts.language_typescript()))
169
+
170
+ def parse(self, source: bytes) -> TSNode:
171
+ return self._parser.parse(source).root_node
172
+
173
+ # --- location ------------------------------------------------------------
174
+
175
+ def locate_function(self, root: TSNode, name: str, start_row: int) -> TSNode | None:
176
+ stack: list[TSNode] = [root]
177
+ while stack:
178
+ node = stack.pop()
179
+ if node.start_point[0] == start_row:
180
+ if node.type in {
181
+ "function_declaration",
182
+ "method_definition",
183
+ "function_expression",
184
+ }:
185
+ if _node_name(node) == name:
186
+ return node
187
+ elif node.type in {"lexical_declaration", "variable_declaration"}:
188
+ # arrow/function assigned to const/let — return the value node
189
+ # (which carries the body) so downstream passes see the function.
190
+ for decl in node.named_children:
191
+ if decl.type != "variable_declarator":
192
+ continue
193
+ nn = decl.child_by_field_name("name")
194
+ val = decl.child_by_field_name("value")
195
+ if (
196
+ nn is not None
197
+ and _node_text(nn) == name
198
+ and val is not None
199
+ and val.type in _FUNCTION_VALUES
200
+ ):
201
+ return val
202
+ stack.extend(node.children)
203
+ return None
204
+
205
+ # --- effects -------------------------------------------------------------
206
+
207
+ def direct_effects(self, func_node: TSNode, source: bytes, aliases: dict[str, str]) -> set[str]:
208
+ tags: set[str] = set()
209
+ body = self.function_body(func_node)
210
+ if body is None:
211
+ return tags
212
+ stack: list[TSNode] = [body]
213
+ while stack:
214
+ node = stack.pop()
215
+ if node.type == "throw_statement":
216
+ tags.add("raise")
217
+ elif node.type == "call_expression":
218
+ fn = node.child_by_field_name("function")
219
+ if fn is not None:
220
+ dotted = _text(fn, source)
221
+ head, _, rest = dotted.partition(".")
222
+ if rest and head in aliases and aliases[head] != head:
223
+ dotted = f"{aliases[head]}.{rest}"
224
+ elif dotted in aliases:
225
+ dotted = aliases[dotted]
226
+ tag = _classify_call(dotted)
227
+ if tag is not None:
228
+ tags.add(tag)
229
+ stack.extend(node.children)
230
+ return tags
231
+
232
+ def call_sites(self, func_node: TSNode, source: bytes) -> list[CallSite]:
233
+ sites: list[CallSite] = []
234
+ body = self.function_body(func_node)
235
+ if body is None:
236
+ return sites
237
+ stack: list[TSNode] = [body]
238
+ while stack:
239
+ node = stack.pop()
240
+ if node.type == "call_expression":
241
+ fn = node.child_by_field_name("function")
242
+ if fn is not None and fn.type in {"identifier", "member_expression"}:
243
+ decoded = _text(fn, source)
244
+ if "(" in decoded or "[" in decoded or "\n" in decoded:
245
+ decoded = decoded.split(".", 1)[0]
246
+ args = node.child_by_field_name("arguments")
247
+ names = _arg_names(args, source) if args is not None else []
248
+ sites.append((decoded, names, node.start_point[0] + 1))
249
+ stack.extend(node.children)
250
+ return sites
251
+
252
+ # --- CFG -----------------------------------------------------------------
253
+
254
+ def function_body(self, func_node: TSNode) -> TSNode | None:
255
+ body = func_node.child_by_field_name("body")
256
+ # arrow with an expression body has no block → no CFG
257
+ return body if body is not None and body.type == "statement_block" else None
258
+
259
+ def block_statements(self, block: TSNode) -> list[TSNode]:
260
+ if block.type in {"switch_case", "switch_default"}:
261
+ return [
262
+ c
263
+ for c in block.children_by_field_name("body")
264
+ if c.type not in {"break_statement", "comment"}
265
+ ]
266
+ return [c for c in block.named_children if c.type != "comment"]
267
+
268
+ def describe_statement(self, node: TSNode, source: bytes) -> StatementDesc:
269
+ t = node.type
270
+ if t == "if_statement":
271
+ return self._describe_if(node, source)
272
+ if t == "for_in_statement": # for-of / for-in
273
+ left = node.child_by_field_name("left")
274
+ writes = _target_names(left, source) if left is not None else []
275
+ return LoopDesc(
276
+ reads=_reads_of(node.child_by_field_name("right"), source),
277
+ writes=writes,
278
+ body=node.child_by_field_name("body"),
279
+ )
280
+ if t == "for_statement":
281
+ return LoopDesc(
282
+ reads=_reads_of(node.child_by_field_name("condition"), source),
283
+ writes=[],
284
+ body=node.child_by_field_name("body"),
285
+ )
286
+ if t == "while_statement":
287
+ return LoopDesc(
288
+ reads=_reads_of(node.child_by_field_name("condition"), source),
289
+ writes=[],
290
+ body=node.child_by_field_name("body"),
291
+ )
292
+ if t == "return_statement":
293
+ expr = next((c for c in node.named_children), None)
294
+ return ReturnDesc(reads=_reads_of(expr, source), mutates=_call_mutations(node, source))
295
+ if t == "try_statement":
296
+ return self._describe_try(node)
297
+ if t == "switch_statement":
298
+ desc = self._describe_switch(node, source)
299
+ if desc.cases:
300
+ return desc
301
+ return SimpleDesc(reads=_reads_of(node, source), mutates=_call_mutations(node, source))
302
+ if t in {"lexical_declaration", "variable_declaration"}:
303
+ return self._describe_declaration(node, source)
304
+ if t == "expression_statement":
305
+ inner = next((c for c in node.named_children), None)
306
+ if inner is not None and inner.type == "assignment_expression":
307
+ left = inner.child_by_field_name("left")
308
+ right = inner.child_by_field_name("right")
309
+ writes, mutates = _lhs_split(left, source) if left is not None else ([], [])
310
+ for base in _call_mutations(node, source):
311
+ if base not in mutates:
312
+ mutates.append(base)
313
+ return AssignDesc(writes=writes, mutates=mutates, reads=_reads_of(right, source))
314
+ return SimpleDesc(reads=_reads_of(node, source), mutates=_call_mutations(node, source))
315
+ return SimpleDesc(reads=_reads_of(node, source), mutates=_call_mutations(node, source))
316
+
317
+ def _describe_if(self, node: TSNode, source: bytes) -> BranchDesc:
318
+ else_block: TSNode | None = None
319
+ next_branch: TSNode | None = None
320
+ alt = node.child_by_field_name("alternative")
321
+ if alt is not None and alt.type == "else_clause":
322
+ inner = next((c for c in alt.named_children), None)
323
+ if inner is not None and inner.type == "if_statement":
324
+ next_branch = inner
325
+ else:
326
+ else_block = inner
327
+ return BranchDesc(
328
+ reads=_reads_of(node.child_by_field_name("condition"), source),
329
+ consequence=node.child_by_field_name("consequence"),
330
+ else_block=else_block,
331
+ next_branch=next_branch,
332
+ )
333
+
334
+ def _describe_declaration(self, node: TSNode, source: bytes) -> AssignDesc:
335
+ writes: list[str] = []
336
+ reads: list[str] = []
337
+ mutates: list[str] = []
338
+ seen: set[str] = set()
339
+ for decl in node.named_children:
340
+ if decl.type != "variable_declarator":
341
+ continue
342
+ name = decl.child_by_field_name("name")
343
+ if name is not None and name.type == "identifier":
344
+ writes.append(_text(name, source))
345
+ value = decl.child_by_field_name("value")
346
+ if value is not None:
347
+ _collect_reads(value, source, reads, seen)
348
+ for base in _call_mutations(node, source):
349
+ if base not in mutates:
350
+ mutates.append(base)
351
+ return AssignDesc(writes=writes, mutates=mutates, reads=reads)
352
+
353
+ def _describe_try(self, node: TSNode) -> TryDesc:
354
+ handlers: list[HandlerDesc] = []
355
+ handler = node.child_by_field_name("handler")
356
+ if handler is not None:
357
+ param = handler.child_by_field_name("parameter")
358
+ writes = [param.text.decode()] if param is not None and param.text else []
359
+ handlers.append(
360
+ HandlerDesc(node=handler, writes=writes, block=handler.child_by_field_name("body"))
361
+ )
362
+ finalizer = node.child_by_field_name("finalizer")
363
+ finally_block = finalizer.child_by_field_name("body") if finalizer is not None else None
364
+ return TryDesc(
365
+ body=node.child_by_field_name("body"),
366
+ handlers=handlers,
367
+ else_block=None,
368
+ finally_block=finally_block,
369
+ )
370
+
371
+ def _describe_switch(self, node: TSNode, source: bytes) -> MatchDesc:
372
+ value = node.child_by_field_name("value")
373
+ subject_reads = _reads_of(value, source)
374
+ body = node.child_by_field_name("body")
375
+ cases: list[CaseDesc] = []
376
+ if body is not None:
377
+ for case in body.named_children:
378
+ if case.type not in {"switch_case", "switch_default"}:
379
+ continue
380
+ cases.append(CaseDesc(node=case, reads=list(subject_reads), consequence=case))
381
+ return MatchDesc(cases=cases)
382
+
383
+ # --- ingest --------------------------------------------------------------
384
+
385
+ def module_declarations(
386
+ self, root: TSNode, source: bytes, module_name: str, rel_path: str
387
+ ) -> list[Declaration]:
388
+ decls: list[Declaration] = []
389
+ for child in root.children:
390
+ decls.extend(self._top_level(child, source, rel_path))
391
+ return decls
392
+
393
+ def _top_level(self, node: TSNode, source: bytes, rel_path: str) -> list[Declaration]:
394
+ t = node.type
395
+ if t == "export_statement":
396
+ inner = node.child_by_field_name("declaration")
397
+ return self._top_level(inner, source, rel_path) if inner is not None else []
398
+ if t == "function_declaration":
399
+ return [self._function_decl(node, source, is_method=False)]
400
+ if t == "class_declaration":
401
+ return [self._class_decl(node, source)]
402
+ if t in {"lexical_declaration", "variable_declaration"}:
403
+ out: list[Declaration] = []
404
+ for decl in node.named_children:
405
+ if decl.type != "variable_declarator":
406
+ continue
407
+ name_node = decl.child_by_field_name("name")
408
+ value = decl.child_by_field_name("value")
409
+ if name_node is None or name_node.type != "identifier":
410
+ continue
411
+ name = _text(name_node, source)
412
+ if value is not None and value.type in _FUNCTION_VALUES:
413
+ out.append(self._arrow_decl(node, value, name, source))
414
+ else:
415
+ out.append(VariableDecl(node=node, name=name))
416
+ return out
417
+ if t == "import_statement":
418
+ return list(self._imports(node, source, rel_path))
419
+ return []
420
+
421
+ def _class_decl(self, node: TSNode, source: bytes) -> ClassDecl:
422
+ name = (
423
+ _text(node.child_by_field_name("name"), source)
424
+ if node.child_by_field_name("name")
425
+ else "<anonymous>"
426
+ )
427
+ methods: list[FunctionDecl] = []
428
+ fields: dict[str, str] = {}
429
+ body = node.child_by_field_name("body")
430
+ if body is not None:
431
+ for child in body.named_children:
432
+ if child.type == "method_definition":
433
+ methods.append(self._function_decl(child, source, is_method=True))
434
+ if _node_name(child) == "constructor":
435
+ fields.update(_di_fields(child, source))
436
+ elif child.type == "public_field_definition":
437
+ fname = child.child_by_field_name("name")
438
+ tname = _type_base(child.child_by_field_name("type"), source)
439
+ if fname is not None and tname:
440
+ fields[_text(fname, source)] = tname
441
+ return ClassDecl(node=node, name=name, methods=methods, fields=fields)
442
+
443
+ def _function_decl(self, node: TSNode, source: bytes, is_method: bool) -> FunctionDecl:
444
+ name = _fn_name(node, source)
445
+ return FunctionDecl(
446
+ node=node,
447
+ name=name,
448
+ params=_params(node, source, is_method),
449
+ signature=_signature(node, source, name),
450
+ returns=_return_type(node, source),
451
+ doc=_leading_doc(node, source),
452
+ raises=_thrown_names(node, source),
453
+ decorators=[],
454
+ free_names=_free_names(node, source),
455
+ )
456
+
457
+ def _arrow_decl(
458
+ self, decl_node: TSNode, arrow: TSNode, name: str, source: bytes
459
+ ) -> FunctionDecl:
460
+ return FunctionDecl(
461
+ node=decl_node,
462
+ name=name,
463
+ params=_params(arrow, source, is_method=False),
464
+ signature=_signature(arrow, source, name),
465
+ returns=_return_type(arrow, source),
466
+ doc=_leading_doc(decl_node, source),
467
+ raises=_thrown_names(arrow, source),
468
+ decorators=[],
469
+ free_names=_free_names(arrow, source),
470
+ )
471
+
472
+ def _imports(self, node: TSNode, source: bytes, rel_path: str) -> Iterator[ImportDecl]:
473
+ src_node = node.child_by_field_name("source")
474
+ if src_node is None:
475
+ return
476
+ specifier = _string_value(src_node, source)
477
+ base = _resolve_specifier(specifier, rel_path)
478
+ clause = next((c for c in node.named_children if c.type == "import_clause"), None)
479
+ if clause is None:
480
+ return
481
+ for c in clause.named_children:
482
+ if c.type == "identifier": # default import
483
+ yield ImportDecl(node=node, target=f"{base}.default", alias=_text(c, source))
484
+ elif c.type == "named_imports":
485
+ for spec in c.named_children:
486
+ if spec.type != "import_specifier":
487
+ continue
488
+ name_node = spec.child_by_field_name("name")
489
+ alias_node = spec.child_by_field_name("alias")
490
+ if name_node is None:
491
+ continue
492
+ imported = _text(name_node, source)
493
+ local: str | None = (
494
+ _text(alias_node, source) if alias_node is not None else None
495
+ )
496
+ yield ImportDecl(node=node, target=f"{base}.{imported}", alias=local)
497
+ elif c.type == "namespace_import":
498
+ ident = next((x for x in c.named_children if x.type == "identifier"), None)
499
+ if ident is not None:
500
+ yield ImportDecl(node=node, target=base, alias=_text(ident, source))
501
+
502
+
503
+ # --- module-level helpers -----------------------------------------------------
504
+
505
+
506
+ def _text(node: TSNode | None, source: bytes) -> str:
507
+ if node is None:
508
+ return ""
509
+ return source[node.start_byte : node.end_byte].decode("utf-8", errors="replace")
510
+
511
+
512
+ def _node_text(node: TSNode | None) -> str:
513
+ """Node's own source text via tree-sitter (offset-safe, no file needed)."""
514
+ return node.text.decode("utf-8", errors="replace") if node is not None and node.text else ""
515
+
516
+
517
+ def _di_fields(ctor: TSNode, source: bytes) -> dict[str, str]:
518
+ """Constructor params with an access modifier become class fields.
519
+
520
+ ``constructor(private svc: ChaptersService)`` → ``{"svc": "ChaptersService"}``.
521
+ Plain params (no modifier) are locals, not fields.
522
+ """
523
+ out: dict[str, str] = {}
524
+ params = ctor.child_by_field_name("parameters")
525
+ if params is None:
526
+ return out
527
+ for p in params.named_children:
528
+ if p.type not in {"required_parameter", "optional_parameter"}:
529
+ continue
530
+ has_modifier = any(
531
+ c.type in {"accessibility_modifier", "readonly", "override_modifier"}
532
+ for c in p.children
533
+ )
534
+ if not has_modifier:
535
+ continue
536
+ pattern = p.child_by_field_name("pattern")
537
+ tname = _type_base(p.child_by_field_name("type"), source)
538
+ if pattern is not None and pattern.type == "identifier" and tname:
539
+ out[_text(pattern, source)] = tname
540
+ return out
541
+
542
+
543
+ def _type_base(type_annotation: TSNode | None, source: bytes) -> str | None:
544
+ """Base class name of a ``: T`` annotation (``Chapter[]`` → ``Chapter``)."""
545
+ if type_annotation is None:
546
+ return None
547
+ inner = next((c for c in type_annotation.named_children), None)
548
+ if inner is None:
549
+ return None
550
+ if inner.type == "type_identifier":
551
+ return _text(inner, source)
552
+ if inner.type == "array_type":
553
+ return _type_base_of_node(inner.named_children[0], source) if inner.named_children else None
554
+ if inner.type == "generic_type":
555
+ base = inner.child_by_field_name("name") or (
556
+ inner.named_children[0] if inner.named_children else None
557
+ )
558
+ return _text(base, source).split("<")[0] if base is not None else None
559
+ return None
560
+
561
+
562
+ def _type_base_of_node(node: TSNode, source: bytes) -> str | None:
563
+ return _text(node, source) if node.type == "type_identifier" else None
564
+
565
+
566
+ def _node_name(node: TSNode) -> str:
567
+ return _node_text(node.child_by_field_name("name"))
568
+
569
+
570
+ def _fn_name(node: TSNode, source: bytes) -> str:
571
+ if node.type in {"function_declaration", "method_definition", "function_expression"}:
572
+ return _text(node.child_by_field_name("name"), source)
573
+ return ""
574
+
575
+
576
+ def _string_value(node: TSNode, source: bytes) -> str:
577
+ for child in node.named_children:
578
+ if child.type == "string_fragment":
579
+ return _text(child, source)
580
+ return _text(node, source).strip("\"'")
581
+
582
+
583
+ def _resolve_specifier(specifier: str, rel_path: str) -> str:
584
+ """Map an import specifier to a module qualname (relative ones resolved).
585
+
586
+ Resolved against the importing file's *path* (not its dotted module
587
+ name) so Angular-style dotted filenames (``reader.component.ts``) don't
588
+ get mis-split. ``"./chapters.service"`` from ``core/api/reader.component.ts``
589
+ → ``core.api.chapters.service``.
590
+ """
591
+ if not specifier.startswith("."):
592
+ return specifier # bare package — stays opaque (third-party)
593
+ base_dir = posixpath.dirname(rel_path.replace("\\", "/"))
594
+ resolved = posixpath.normpath(posixpath.join(base_dir, specifier))
595
+ for ext in (".ts", ".tsx", ".js"):
596
+ resolved = resolved.removesuffix(ext)
597
+ return resolved.strip("/").replace("/", ".")
598
+
599
+
600
+ def _classify_call(dotted: str) -> str | None:
601
+ if any(ch in dotted for ch in "()[] \n"):
602
+ return None
603
+ parts = dotted.split(".")
604
+ if len(parts) >= 2 and parts[-1] in _DB_METHODS and parts[-2] in _DB_RECEIVERS:
605
+ return "db"
606
+ if len(parts) >= 2 and parts[-1] in _HTTP_VERBS and parts[-2] in _NET_RECEIVERS:
607
+ return "net"
608
+ if dotted.startswith(_IO_PREFIXES):
609
+ return "io"
610
+ if dotted in _NET_EXACT or dotted.startswith(_NET_PREFIXES):
611
+ return "net"
612
+ if dotted.startswith(_FS_PREFIXES) or dotted.endswith(_FS_METHOD_SUFFIXES):
613
+ return "fs"
614
+ if dotted in _NONDETERM_EXACT:
615
+ return "nondeterm"
616
+ return None
617
+
618
+
619
+ def _member_base(node: TSNode, source: bytes) -> list[str]:
620
+ cur = node
621
+ while cur.type in {"member_expression", "subscript_expression"}:
622
+ nxt = cur.child_by_field_name("object")
623
+ if nxt is None:
624
+ return []
625
+ cur = nxt
626
+ return [_text(cur, source)] if cur.type == "identifier" else []
627
+
628
+
629
+ def _target_names(node: TSNode | None, source: bytes) -> list[str]:
630
+ if node is None:
631
+ return []
632
+ if node.type == "identifier":
633
+ return [_text(node, source)]
634
+ if node.type in {"array_pattern", "object_pattern"}:
635
+ out: list[str] = []
636
+ for c in node.named_children:
637
+ out.extend(_target_names(c, source))
638
+ return out
639
+ return []
640
+
641
+
642
+ def _lhs_split(left: TSNode, source: bytes) -> tuple[list[str], list[str]]:
643
+ if left.type == "identifier":
644
+ return [_text(left, source)], []
645
+ if left.type in {"member_expression", "subscript_expression"}:
646
+ return [], _member_base(left, source)
647
+ if left.type in {"array_pattern", "object_pattern"}:
648
+ return _target_names(left, source), []
649
+ return [], []
650
+
651
+
652
+ def _reads_of(node: TSNode | None, source: bytes) -> list[str]:
653
+ if node is None:
654
+ return []
655
+ if node.type == "parenthesized_expression":
656
+ node = next((c for c in node.named_children), node)
657
+ names: list[str] = []
658
+ seen: set[str] = set()
659
+ _collect_reads(node, source, names, seen)
660
+ return names
661
+
662
+
663
+ def _collect_reads(node: TSNode, source: bytes, names: list[str], seen: set[str]) -> None:
664
+ t = node.type
665
+ if t == "identifier":
666
+ name = _text(node, source)
667
+ if name not in seen and name not in _JS_GLOBALS:
668
+ seen.add(name)
669
+ names.append(name)
670
+ return
671
+ if t == "member_expression":
672
+ obj = node.child_by_field_name("object")
673
+ if obj is not None:
674
+ _collect_reads(obj, source, names, seen)
675
+ return # skip the property name
676
+ if t == "call_expression":
677
+ fn = node.child_by_field_name("function")
678
+ if fn is not None and fn.type == "member_expression":
679
+ obj = fn.child_by_field_name("object")
680
+ if obj is not None:
681
+ _collect_reads(obj, source, names, seen)
682
+ args = node.child_by_field_name("arguments")
683
+ if args is not None:
684
+ _collect_reads(args, source, names, seen)
685
+ return
686
+ for child in node.children:
687
+ _collect_reads(child, source, names, seen)
688
+
689
+
690
+ def _call_mutations(stmt: TSNode, source: bytes) -> list[str]:
691
+ names: list[str] = []
692
+ stack: list[TSNode] = [stmt]
693
+ while stack:
694
+ node = stack.pop()
695
+ if node.type == "call_expression":
696
+ fn = node.child_by_field_name("function")
697
+ if fn is not None and fn.type == "member_expression":
698
+ prop = fn.child_by_field_name("property")
699
+ obj = fn.child_by_field_name("object")
700
+ if prop is not None and obj is not None and _text(prop, source) in _MUTATOR_METHODS:
701
+ for base in _member_base(obj, source) or (
702
+ [_text(obj, source)] if obj.type == "identifier" else []
703
+ ):
704
+ if base not in names:
705
+ names.append(base)
706
+ stack.extend(node.children)
707
+ return names
708
+
709
+
710
+ def _arg_names(args_node: TSNode, source: bytes) -> list[str]:
711
+ names: list[str] = []
712
+ seen: set[str] = set()
713
+ _collect_reads(args_node, source, names, seen)
714
+ return names
715
+
716
+
717
+ def _params(node: TSNode, source: bytes, is_method: bool) -> list[ParamDecl]:
718
+ params_node = node.child_by_field_name("parameters")
719
+ out: list[ParamDecl] = []
720
+ if params_node is not None:
721
+ for child in params_node.named_children:
722
+ name = _param_name(child, source)
723
+ if name and not (is_method and name == "this"):
724
+ out.append(ParamDecl(name=name, node=child))
725
+ else:
726
+ single = node.child_by_field_name("parameter") # arrow with one bare param
727
+ if single is not None and single.type == "identifier":
728
+ out.append(ParamDecl(name=_text(single, source), node=single))
729
+ return out
730
+
731
+
732
+ def _param_name(node: TSNode, source: bytes) -> str | None:
733
+ if node.type == "identifier":
734
+ return _text(node, source)
735
+ if node.type in {"required_parameter", "optional_parameter"}:
736
+ pat = node.child_by_field_name("pattern")
737
+ if pat is not None and pat.type == "identifier":
738
+ return _text(pat, source)
739
+ for child in node.named_children:
740
+ if child.type == "identifier":
741
+ return _text(child, source)
742
+ return None
743
+
744
+
745
+ def _return_type(node: TSNode, source: bytes) -> str | None:
746
+ rt = node.child_by_field_name("return_type")
747
+ if rt is None:
748
+ return None
749
+ inner = next((c for c in rt.named_children), None)
750
+ return _text(inner, source) if inner is not None else None
751
+
752
+
753
+ def _signature(node: TSNode, source: bytes, name: str) -> str:
754
+ params_node = node.child_by_field_name("parameters")
755
+ params_text = _text(params_node, source) if params_node is not None else "()"
756
+ ret = _return_type(node, source)
757
+ sig = f"{name}{params_text}"
758
+ if ret:
759
+ sig += f": {ret}"
760
+ return sig.replace("\n", " ")
761
+
762
+
763
+ def _leading_doc(node: TSNode, source: bytes) -> str:
764
+ """A ``/** ... */`` JSDoc comment immediately preceding the declaration."""
765
+ prev = node.prev_sibling
766
+ if prev is not None and prev.type == "comment":
767
+ raw = _text(prev, source).strip()
768
+ if raw.startswith("/**"):
769
+ body = raw.removeprefix("/**").removesuffix("*/")
770
+ lines = [ln.strip().lstrip("*").strip() for ln in body.splitlines()]
771
+ return " ".join(ln for ln in lines if ln).strip()
772
+ return ""
773
+
774
+
775
+ def _thrown_names(node: TSNode, source: bytes) -> list[str]:
776
+ body = node.child_by_field_name("body")
777
+ if body is None:
778
+ return []
779
+ names: list[str] = []
780
+ seen: set[str] = set()
781
+ stack: list[TSNode] = [body]
782
+ while stack:
783
+ n = stack.pop()
784
+ if n.type == "throw_statement":
785
+ expr = next((c for c in n.named_children), None)
786
+ if expr is not None and expr.type == "new_expression":
787
+ ctor = expr.child_by_field_name("constructor")
788
+ if ctor is not None:
789
+ name = _text(ctor, source).split(".")[-1]
790
+ if name and name not in seen:
791
+ seen.add(name)
792
+ names.append(name)
793
+ stack.extend(n.children)
794
+ return names
795
+
796
+
797
+ def _free_names(node: TSNode, source: bytes) -> list[str]:
798
+ body = node.child_by_field_name("body")
799
+ if body is None or body.type != "statement_block":
800
+ return []
801
+ bound: set[str] = set()
802
+ for p in _params(node, source, is_method=False):
803
+ bound.add(p.name)
804
+ _collect_bound(body, source, bound)
805
+ referenced: list[str] = []
806
+ seen: set[str] = set()
807
+ _collect_referenced(body, source, referenced, seen)
808
+ return [n for n in referenced if n not in bound and n not in _JS_GLOBALS]
809
+
810
+
811
+ def _collect_bound(node: TSNode, source: bytes, bound: set[str]) -> None:
812
+ t = node.type
813
+ if t == "variable_declarator":
814
+ name = node.child_by_field_name("name")
815
+ if name is not None:
816
+ bound.update(_target_names(name, source))
817
+ elif t == "for_in_statement":
818
+ left = node.child_by_field_name("left")
819
+ if left is not None:
820
+ bound.update(_target_names(left, source))
821
+ elif t in {"function_declaration", "class_declaration"}:
822
+ name = node.child_by_field_name("name")
823
+ if name is not None:
824
+ bound.add(_text(name, source))
825
+ elif t == "catch_clause":
826
+ param = node.child_by_field_name("parameter")
827
+ if param is not None and param.type == "identifier":
828
+ bound.add(_text(param, source))
829
+ elif t == "required_parameter" or t == "optional_parameter":
830
+ pn = _param_name(node, source)
831
+ if pn:
832
+ bound.add(pn)
833
+ for child in node.children:
834
+ _collect_bound(child, source, bound)
835
+
836
+
837
+ def _collect_referenced(node: TSNode, source: bytes, out: list[str], seen: set[str]) -> None:
838
+ t = node.type
839
+ if t == "identifier":
840
+ name = _text(node, source)
841
+ if name not in seen:
842
+ seen.add(name)
843
+ out.append(name)
844
+ return
845
+ if t == "member_expression":
846
+ obj = node.child_by_field_name("object")
847
+ if obj is not None:
848
+ _collect_referenced(obj, source, out, seen)
849
+ return
850
+ if t in {"import_statement"}:
851
+ return
852
+ for child in node.children:
853
+ _collect_referenced(child, source, out, seen)