repograph-codeintel 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.
repograph/extract.py ADDED
@@ -0,0 +1,616 @@
1
+ """Tree-sitter based extraction of symbols, imports, references and routes.
2
+
3
+ Extraction is deliberately *syntactic*. It never executes repository code and
4
+ never guesses types. Every extracted item keeps the exact source line span so
5
+ later layers can attach evidence.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from functools import lru_cache
12
+
13
+ from tree_sitter import Language, Node, Parser
14
+
15
+ import tree_sitter_javascript as tsjs
16
+ import tree_sitter_python as tspy
17
+ import tree_sitter_typescript as tsts
18
+
19
+
20
+ @dataclass
21
+ class Symbol:
22
+ name: str
23
+ qualified_name: str
24
+ kind: str # function | class | method | interface | type | enum | model
25
+ start_line: int
26
+ end_line: int
27
+ start_col: int
28
+ end_col: int
29
+ tags: list[str] = field(default_factory=list)
30
+
31
+
32
+ @dataclass
33
+ class Import:
34
+ raw: str
35
+ module: str
36
+ level: int
37
+ names: list[tuple[str, str]] # (imported name, local binding); ("*", "*") for star
38
+ line: int
39
+ kind: str = "static" # static | require | dynamic | reexport
40
+
41
+
42
+ @dataclass
43
+ class Ref:
44
+ name: str
45
+ line: int
46
+ col: int
47
+ kind: str # call | attr_call | inherits | new
48
+ receiver: str | None = None
49
+
50
+
51
+ @dataclass
52
+ class Route:
53
+ method: str
54
+ path: str
55
+ handler: str
56
+ line: int
57
+ framework: str
58
+
59
+
60
+ @dataclass
61
+ class ParseResult:
62
+ symbols: list[Symbol] = field(default_factory=list)
63
+ imports: list[Import] = field(default_factory=list)
64
+ refs: list[Ref] = field(default_factory=list)
65
+ routes: list[Route] = field(default_factory=list)
66
+ errors: list[str] = field(default_factory=list)
67
+
68
+
69
+ @lru_cache(maxsize=8)
70
+ def _parser(lang: str) -> Parser:
71
+ if lang == "python":
72
+ language = Language(tspy.language())
73
+ elif lang in ("typescript", "tsx"):
74
+ language = Language(tsts.language_tsx() if lang == "tsx" else tsts.language_typescript())
75
+ elif lang in ("javascript", "jsx"):
76
+ language = Language(tsjs.language())
77
+ else: # pragma: no cover - guarded by scanner
78
+ raise ValueError(f"unsupported language: {lang}")
79
+ return Parser(language)
80
+
81
+
82
+ def _text(node: Node) -> str:
83
+ return node.text.decode("utf-8", "replace")
84
+
85
+
86
+ def _field(node: Node, name: str) -> Node | None:
87
+ return node.child_by_field_name(name)
88
+
89
+
90
+ ATTR_KINDS = {
91
+ "decorated_definition",
92
+ "export_statement",
93
+ "expression_statement",
94
+ }
95
+
96
+
97
+ # --------------------------------------------------------------------------
98
+ # Python
99
+ # --------------------------------------------------------------------------
100
+
101
+ PY_VERBS = {"get", "post", "put", "patch", "delete", "head", "options"}
102
+ PY_MODEL_BASES = {"Base", "Model", "DeclarativeBase", "Document", "models.Model"}
103
+
104
+
105
+ def _string_value(node: Node) -> str | None:
106
+ """First string literal inside a node, without quotes."""
107
+ if node.type in ("string", "string_fragment"):
108
+ if node.type == "string":
109
+ for c in node.named_children:
110
+ if c.type == "string_content":
111
+ return _text(c)
112
+ return _text(node).strip("'\"")
113
+ return _text(node)
114
+ for c in node.named_children:
115
+ v = _string_value(c)
116
+ if v is not None:
117
+ return v
118
+ return None
119
+
120
+
121
+ def _py_routes(decorators: list[Node], handler: str) -> list[Route]:
122
+ routes: list[Route] = []
123
+ for dec in decorators:
124
+ target = dec.named_children[0] if dec.named_children else None
125
+ if target is None:
126
+ continue
127
+ if target.type == "call":
128
+ fn = _field(target, "function")
129
+ args = _field(target, "arguments")
130
+ if fn is None or args is None:
131
+ continue
132
+ path = None
133
+ method = None
134
+ if fn.type == "attribute":
135
+ attr = _field(fn, "attribute")
136
+ name = _text(attr) if attr else ""
137
+ if name in PY_VERBS:
138
+ method = name.upper()
139
+ elif name == "route":
140
+ method = "GET"
141
+ for arg in args.named_children:
142
+ if arg.type == "keyword_argument":
143
+ k = _field(arg, "name")
144
+ v = _field(arg, "value")
145
+ if k is not None and _text(k) == "methods" and v is not None:
146
+ methods = [_string_value(x) for x in v.named_children]
147
+ first = next((m.upper() for m in methods if m), None)
148
+ method = first or method
149
+ else:
150
+ continue
151
+ path = _string_value(args)
152
+ elif fn.type == "identifier" and _text(fn) == "route":
153
+ method, path = "GET", _string_value(args)
154
+ if method and path is not None:
155
+ routes.append(Route(method, path, handler, dec.start_point[0] + 1, "python-web"))
156
+ return routes
157
+
158
+
159
+ def _walk_python(node: Node, out: ParseResult, scope: list[str], in_class: bool = False) -> None:
160
+ for child in node.named_children:
161
+ typ = child.type
162
+
163
+ if typ == "decorated_definition":
164
+ decorators = [c for c in child.children if c.type == "decorator"]
165
+ definition = _field(child, "definition")
166
+ if definition is not None:
167
+ _walk_python_definition(definition, out, scope, decorators, in_class)
168
+ continue
169
+
170
+ if typ == "import_statement":
171
+ _py_import(child, out)
172
+ continue
173
+ if typ == "import_from_statement":
174
+ _py_import_from(child, out)
175
+ continue
176
+ if typ == "call":
177
+ fn = _field(child, "function")
178
+ if fn is not None:
179
+ if fn.type == "identifier":
180
+ name = _text(fn)
181
+ if name == "__import__":
182
+ args = _field(child, "arguments")
183
+ module = _string_value(args) if args is not None else None
184
+ out.imports.append(Import(_text(child), module or "", 0, [("*", "*")],
185
+ child.start_point[0] + 1, "dynamic"))
186
+ else:
187
+ out.refs.append(Ref(name, child.start_point[0] + 1, fn.start_point[1], "call"))
188
+ elif fn.type == "attribute":
189
+ attr = _field(fn, "attribute")
190
+ if attr is not None:
191
+ attr_name = _text(attr)
192
+ if attr_name == "import_module":
193
+ args = _field(child, "arguments")
194
+ module = _string_value(args) if args is not None else None
195
+ out.imports.append(Import(_text(child), module or "", 0, [("*", "*")],
196
+ child.start_point[0] + 1, "dynamic"))
197
+ else:
198
+ obj = _field(fn, "object")
199
+ receiver = _text(obj) if obj is not None and obj.type == "identifier" else None
200
+ out.refs.append(Ref(attr_name, child.start_point[0] + 1,
201
+ attr.start_point[1], "attr_call", receiver))
202
+ _walk_python(child, out, scope, in_class)
203
+ continue
204
+
205
+ _walk_python_definition(child, out, scope, [], in_class)
206
+
207
+
208
+ def _walk_python_definition(
209
+ node: Node, out: ParseResult, scope: list[str], decorators: list[Node], in_class: bool = False
210
+ ) -> None:
211
+ typ = node.type
212
+ if typ == "class_definition":
213
+ name_node = _field(node, "name")
214
+ if name_node is None:
215
+ return
216
+ name = _text(name_node)
217
+ qname = ".".join([*scope, name])
218
+ bases = _field(node, "superclasses")
219
+ tags: list[str] = []
220
+ if bases is not None:
221
+ for b in bases.named_children:
222
+ base = _text(b)
223
+ out.refs.append(Ref(base.split(".")[-1], b.start_point[0] + 1, b.start_point[1], "inherits"))
224
+ if base in PY_MODEL_BASES or base.endswith("Base") or base.endswith("Model"):
225
+ tags.append("db_model")
226
+ body = _field(node, "body")
227
+ out.symbols.append(
228
+ Symbol(name, qname, "class", node.start_point[0] + 1, node.end_point[0] + 1,
229
+ node.start_point[1], node.end_point[1], tags)
230
+ )
231
+ if body is not None:
232
+ for c in body.named_children:
233
+ if c.type == "decorated_definition":
234
+ decs = [x for x in c.children if x.type == "decorator"]
235
+ d = _field(c, "definition")
236
+ if d is not None:
237
+ _walk_python_definition(d, out, [*scope, name], decs, True)
238
+ elif c.type in ("function_definition", "class_definition"):
239
+ _walk_python_definition(c, out, [*scope, name], [], True)
240
+ return
241
+
242
+ if typ in ("function_definition", "lambda"):
243
+ name_node = _field(node, "name")
244
+ if name_node is None:
245
+ return
246
+ name = _text(name_node)
247
+ qname = ".".join([*scope, name])
248
+ out.symbols.append(
249
+ Symbol(name, qname, "method" if in_class else "function", node.start_point[0] + 1,
250
+ node.end_point[0] + 1, node.start_point[1], node.end_point[1])
251
+ )
252
+ out.routes.extend(_py_routes(decorators, name))
253
+ body = _field(node, "body")
254
+ if body is not None:
255
+ _walk_python(body, out, [*scope, name], False)
256
+ return
257
+
258
+ _walk_python(node, out, scope)
259
+
260
+
261
+ def _py_import(node: Node, out: ParseResult) -> None:
262
+ for name_node in [c for c in node.children if c.type in ("dotted_name", "aliased_import")]:
263
+ if name_node.type == "aliased_import":
264
+ mod = _field(name_node, "name")
265
+ alias = _field(name_node, "alias")
266
+ if mod is None:
267
+ continue
268
+ module = _text(mod)
269
+ local = _text(alias) if alias is not None else module.split(".")[0]
270
+ else:
271
+ module = _text(name_node)
272
+ local = module.split(".")[0]
273
+ out.imports.append(
274
+ Import(_text(node), module, 0, [(module, local)], node.start_point[0] + 1)
275
+ )
276
+
277
+
278
+ def _py_import_from(node: Node, out: ParseResult) -> None:
279
+ module_node = _field(node, "module_name")
280
+ module = ""
281
+ level = 0
282
+ if module_node is not None:
283
+ if module_node.type == "relative_import":
284
+ prefix = next((c for c in module_node.named_children if c.type == "import_prefix"), None)
285
+ level = _text(prefix).count(".") if prefix is not None else 1
286
+ dotted = next((c for c in module_node.named_children if c.type == "dotted_name"), None)
287
+ module = _text(dotted) if dotted is not None else ""
288
+ else:
289
+ module = _text(module_node)
290
+
291
+ names: list[tuple[str, str]] = []
292
+
293
+ def collect(n: Node) -> None:
294
+ for child in n.named_children:
295
+ if module_node is not None and (child.start_byte, child.end_byte) == (
296
+ module_node.start_byte,
297
+ module_node.end_byte,
298
+ ):
299
+ continue
300
+ if child.type == "dotted_name":
301
+ name = _text(child)
302
+ names.append((name, name.split(".")[0]))
303
+ elif child.type == "aliased_import":
304
+ name_node = _field(child, "name")
305
+ alias = _field(child, "alias")
306
+ if name_node is not None:
307
+ names.append((_text(name_node), _text(alias) if alias is not None
308
+ else _text(name_node).split(".")[0]))
309
+ elif child.type == "wildcard_import":
310
+ names.append(("*", "*"))
311
+ else:
312
+ collect(child)
313
+
314
+ collect(node)
315
+ out.imports.append(Import(_text(node), module, level, names, node.start_point[0] + 1))
316
+
317
+
318
+ # --------------------------------------------------------------------------
319
+ # TypeScript / JavaScript
320
+ # --------------------------------------------------------------------------
321
+
322
+ TS_HTTP = {"Get", "Post", "Put", "Patch", "Delete", "Head", "Options"}
323
+
324
+
325
+ def _ts_class_prefix(node: Node) -> str:
326
+ prefix = ""
327
+ for c in node.children:
328
+ if c.type == "decorator":
329
+ target = c.named_children[0] if c.named_children else None
330
+ if target is not None and target.type == "call_expression":
331
+ fn = _field(target, "function")
332
+ args = _field(target, "arguments")
333
+ if fn is not None and _text(fn) == "Controller" and args is not None:
334
+ prefix = _string_value(args) or ""
335
+ return prefix
336
+
337
+
338
+ def _ts_routes_from(decorators: list[Node], handler: str, prefix: str) -> list[Route]:
339
+ routes: list[Route] = []
340
+ for c in decorators:
341
+ target = c.named_children[0] if c.named_children else None
342
+ if target is None or target.type != "call_expression":
343
+ continue
344
+ fn = _field(target, "function")
345
+ args = _field(target, "arguments")
346
+ if fn is None or args is None:
347
+ continue
348
+ name = _text(fn)
349
+ if name not in TS_HTTP and name != "Route":
350
+ continue
351
+ path = _string_value(args) or ""
352
+ method = "GET" if name == "Route" else name.upper()
353
+ routes.append(Route(method, (prefix + path) if path else prefix, handler,
354
+ c.start_point[0] + 1, "typescript-decorator"))
355
+ return routes
356
+
357
+
358
+ def _ts_import(node: Node, out: ParseResult, kind: str = "static") -> None:
359
+ source = _field(node, "source")
360
+ if source is None:
361
+ return
362
+ module = _string_value(source)
363
+ if module is None:
364
+ return
365
+ names: list[tuple[str, str]] = []
366
+ clause = next((c for c in node.named_children if c.type == "import_clause"), None)
367
+ if clause is not None:
368
+ for c in clause.named_children:
369
+ if c.type == "identifier":
370
+ names.append(("default", _text(c)))
371
+ elif c.type == "namespace_import":
372
+ local = next((x for x in c.named_children if x.type == "identifier"), None)
373
+ if local is not None:
374
+ names.append(("*", _text(local)))
375
+ elif c.type == "named_imports":
376
+ for spec in c.named_children:
377
+ if spec.type != "import_specifier":
378
+ continue
379
+ n = _field(spec, "name")
380
+ a = _field(spec, "alias")
381
+ if n is not None:
382
+ names.append((_text(n), _text(a) if a is not None else _text(n)))
383
+ if not names:
384
+ names = [("*", "*")]
385
+ out.imports.append(Import(_text(node), module, 0, names, node.start_point[0] + 1, kind))
386
+
387
+
388
+ def _ts_require(node: Node, out: ParseResult) -> None:
389
+ declarator = node.parent
390
+ if declarator is None or declarator.type != "variable_declarator":
391
+ return
392
+ value = _field(declarator, "value")
393
+ if value is None:
394
+ return
395
+ call = value
396
+ if call.type != "call_expression":
397
+ return
398
+ fn = _field(call, "function")
399
+ if fn is None or _text(fn) != "require":
400
+ return
401
+ args = _field(call, "arguments")
402
+ module = _string_value(args) if args is not None else None
403
+ if module is None:
404
+ return
405
+ name_node = _field(declarator, "name")
406
+ names: list[tuple[str, str]] = []
407
+ if name_node is not None:
408
+ if name_node.type == "identifier":
409
+ names.append(("default", _text(name_node)))
410
+ elif name_node.type == "object_pattern":
411
+ for p in name_node.named_children:
412
+ if p.type == "shorthand_property_identifier_pattern":
413
+ names.append((_text(p), _text(p)))
414
+ elif p.type == "pair_pattern":
415
+ k = _field(p, "key")
416
+ v = _field(p, "value")
417
+ if k is not None and v is not None:
418
+ names.append((_text(k), _text(v)))
419
+ if not names:
420
+ names = [("default", module.split("/")[-1])]
421
+ out.imports.append(Import(_text(node), module, 0, names, node.start_point[0] + 1, "require"))
422
+
423
+
424
+ def _ts_walk(node: Node, out: ParseResult, scope: list[str]) -> None:
425
+ for child in node.named_children:
426
+ typ = child.type
427
+
428
+ if typ == "import_statement":
429
+ _ts_import(child, out)
430
+ continue
431
+ if typ == "export_statement":
432
+ if _field(child, "source") is not None:
433
+ _ts_import(child, out, "reexport")
434
+ decl = _field(child, "declaration")
435
+ if decl is not None:
436
+ _ts_definition(decl, out, scope)
437
+ continue
438
+ if typ == "call_expression":
439
+ fn = _field(child, "function")
440
+ if fn is not None:
441
+ if fn.type == "import":
442
+ args = _field(child, "arguments")
443
+ module = _string_value(args) if args is not None else None
444
+ out.imports.append(Import(_text(child), module or "", 0, [("*", "*")],
445
+ child.start_point[0] + 1, "dynamic"))
446
+ elif fn.type == "identifier":
447
+ name = _text(fn)
448
+ if name == "require":
449
+ _ts_require(child, out)
450
+ elif name == "import":
451
+ args = _field(child, "arguments")
452
+ module = _string_value(args) if args is not None else None
453
+ out.imports.append(Import(_text(child), module or "", 0, [("*", "*")],
454
+ child.start_point[0] + 1, "dynamic"))
455
+ else:
456
+ out.refs.append(Ref(name, child.start_point[0] + 1, fn.start_point[1], "call"))
457
+ elif fn.type == "member_expression":
458
+ prop = _field(fn, "property")
459
+ obj = _field(fn, "object")
460
+ receiver = None
461
+ if obj is not None:
462
+ if obj.type in ("identifier", "this"):
463
+ receiver = _text(obj)
464
+ elif obj.type == "new_expression":
465
+ ctor = _field(obj, "constructor")
466
+ if ctor is not None:
467
+ receiver = _text(ctor).split(".")[-1]
468
+ if prop is not None:
469
+ out.refs.append(Ref(_text(prop), child.start_point[0] + 1,
470
+ prop.start_point[1], "attr_call", receiver))
471
+ _ts_walk(child, out, scope)
472
+ continue
473
+ if typ == "new_expression":
474
+ ctor = _field(child, "constructor")
475
+ if ctor is not None:
476
+ out.refs.append(Ref(_text(ctor).split(".")[-1], child.start_point[0] + 1,
477
+ ctor.start_point[1], "new"))
478
+ _ts_walk(child, out, scope)
479
+ continue
480
+
481
+ _ts_definition(child, out, scope)
482
+
483
+
484
+ def _ts_definition(node: Node, out: ParseResult, scope: list[str]) -> None:
485
+ typ = node.type
486
+ if typ in ("function_declaration", "generator_function_declaration"):
487
+ name_node = _field(node, "name")
488
+ if name_node is None:
489
+ return
490
+ name = _text(name_node)
491
+ out.symbols.append(
492
+ Symbol(name, ".".join([*scope, name]), "function", node.start_point[0] + 1,
493
+ node.end_point[0] + 1, node.start_point[1], node.end_point[1])
494
+ )
495
+ body = _field(node, "body")
496
+ if body is not None:
497
+ _ts_walk(body, out, [*scope, name])
498
+ return
499
+
500
+ if typ in ("lexical_declaration", "variable_declaration"):
501
+ for d in node.named_children:
502
+ if d.type != "variable_declarator":
503
+ continue
504
+ name_node = _field(d, "name")
505
+ value = _field(d, "value")
506
+ if name_node is None or value is None:
507
+ continue
508
+ if value.type not in ("arrow_function", "function_expression", "function"):
509
+ _ts_walk(d, out, scope)
510
+ continue
511
+ name = _text(name_node)
512
+ out.symbols.append(
513
+ Symbol(name, ".".join([*scope, name]), "function", node.start_point[0] + 1,
514
+ node.end_point[0] + 1, node.start_point[1], node.end_point[1])
515
+ )
516
+ body = _field(value, "body")
517
+ if body is not None:
518
+ _ts_walk(body, out, [*scope, name])
519
+ return
520
+
521
+ if typ == "class_declaration":
522
+ name_node = _field(node, "name")
523
+ if name_node is None:
524
+ return
525
+ name = _text(name_node)
526
+ prefix = _ts_class_prefix(node)
527
+ heritage = next((c for c in node.named_children if c.type == "class_heritage"), None)
528
+ if heritage is not None:
529
+ for c in heritage.named_children:
530
+ if c.type == "extends_clause":
531
+ for v in c.named_children:
532
+ out.refs.append(Ref(_text(v).split(".")[-1], v.start_point[0] + 1,
533
+ v.start_point[1], "inherits"))
534
+ out.symbols.append(
535
+ Symbol(name, ".".join([*scope, name]), "class", node.start_point[0] + 1,
536
+ node.end_point[0] + 1, node.start_point[1], node.end_point[1])
537
+ )
538
+ body = _field(node, "body")
539
+ if body is not None:
540
+ pending: list[Node] = []
541
+ for c in body.named_children:
542
+ if c.type == "decorator":
543
+ pending.append(c)
544
+ continue
545
+ if c.type == "method_definition":
546
+ mn = _field(c, "name")
547
+ if mn is None:
548
+ continue
549
+ mname = _text(mn)
550
+ out.symbols.append(
551
+ Symbol(mname, f"{'.'.join([*scope, name])}.{mname}", "method",
552
+ c.start_point[0] + 1, c.end_point[0] + 1,
553
+ c.start_point[1], c.end_point[1])
554
+ )
555
+ out.routes.extend(_ts_routes_from(pending, f"{name}.{mname}", prefix))
556
+ pending = []
557
+ mb = _field(c, "body")
558
+ if mb is not None:
559
+ _ts_walk(mb, out, [*scope, name, mname])
560
+ elif c.type == "class_declaration":
561
+ _ts_definition(c, out, [*scope, name])
562
+ elif c.type == "method_signature":
563
+ pending = []
564
+ out.routes.extend(_ts_routes_from([c for c in node.children if c.type == "decorator"],
565
+ name, prefix))
566
+ return
567
+
568
+ if typ in ("interface_declaration", "type_alias_declaration", "enum_declaration"):
569
+ name_node = _field(node, "name")
570
+ if name_node is None:
571
+ return
572
+ kind = {"interface_declaration": "interface", "type_alias_declaration": "type",
573
+ "enum_declaration": "enum"}[typ]
574
+ name = _text(name_node)
575
+ out.symbols.append(
576
+ Symbol(name, ".".join([*scope, name]), kind, node.start_point[0] + 1,
577
+ node.end_point[0] + 1, node.start_point[1], node.end_point[1])
578
+ )
579
+ return
580
+
581
+ # Container nodes (blocks, statements, arguments) are walked so calls and
582
+ # requires nested inside them are not missed.
583
+ if typ not in ATTR_KINDS:
584
+ _ts_walk(node, out, scope)
585
+
586
+
587
+ # --------------------------------------------------------------------------
588
+ # Prisma
589
+ # --------------------------------------------------------------------------
590
+
591
+ def _parse_prisma(text: str) -> ParseResult:
592
+ import re
593
+
594
+ out = ParseResult()
595
+ for i, line in enumerate(text.splitlines(), start=1):
596
+ m = re.match(r"\s*model\s+([A-Za-z_][A-Za-z0-9_]*)", line)
597
+ if m:
598
+ out.symbols.append(Symbol(m.group(1), m.group(1), "model", i, i, 0, len(line), ["db_model"]))
599
+ return out
600
+
601
+
602
+ def extract(rel_path: str, lang: str, source: bytes) -> ParseResult:
603
+ if lang == "prisma":
604
+ return _parse_prisma(source.decode("utf-8", "replace"))
605
+ if lang == "sql":
606
+ return ParseResult()
607
+ parser = _parser(lang)
608
+ tree = parser.parse(source)
609
+ out = ParseResult()
610
+ if tree.root_node.has_error:
611
+ out.errors.append("syntax errors present; extraction is partial")
612
+ if lang == "python":
613
+ _walk_python(tree.root_node, out, [])
614
+ else:
615
+ _ts_walk(tree.root_node, out, [])
616
+ return out