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
|
@@ -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)
|