sentinel-codegraph 0.3.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.
@@ -0,0 +1,554 @@
1
+ """TypeScript / JavaScript structural extraction from a raw tree.
2
+
3
+ Collect phase (:func:`collect_ts_file`): walks once, emits defs
4
+ (classes, functions, methods, interfaces, type aliases, arrow-bound
5
+ consts) + imports with alias originals, and buffers bare-name call
6
+ sites unresolved. Call resolution happens later in the link phase
7
+ (:mod:`codegraph.parser.links`). Covers both the ``typescript`` and
8
+ ``javascript`` grammars, which share structure shapes (interfaces and
9
+ type aliases only occur in TypeScript).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from collections.abc import Mapping
16
+ from dataclasses import dataclass
17
+ from dataclasses import field as dc_field
18
+ from pathlib import Path
19
+ from typing import TYPE_CHECKING
20
+
21
+ from tree_sitter import Node
22
+
23
+ if TYPE_CHECKING:
24
+ from codegraph.parser.rows import FileRows
25
+
26
+ from codegraph.models import Edge, EdgeKind
27
+ from codegraph.models import Node as GraphNode
28
+ from codegraph.models import NodeKind
29
+ from codegraph.parser.base import ParsedCall, ParsedDefinition, ParsedImport
30
+ from codegraph.parser.raw_core import field_text, node_text, parse, span, walk
31
+
32
+ TYPESCRIPT: str = "typescript"
33
+ JAVASCRIPT: str = "javascript"
34
+ LANGUAGE: str = TYPESCRIPT
35
+
36
+ _MODULE_RE: re.Pattern[str] = re.compile(r"""["']([^"']+)["']""")
37
+
38
+
39
+ def _clause_symbols(clause: str) -> list[tuple[str, str]]:
40
+ """Extract ``(bound, original)`` pairs from the import clause.
41
+
42
+ ``{a as b}`` binds ``b`` for the defining-module name ``a``;
43
+ default imports bind with no original (the link phase joins them
44
+ on the bound name); ``* as ns`` binds a namespace (attribute
45
+ calls only, so it never resolves a bare site).
46
+ """
47
+ symbols: list[tuple[str, str]] = []
48
+ text: str = clause.strip()
49
+ if text.startswith("type "):
50
+ text = text[5:].strip()
51
+ if not text:
52
+ return symbols
53
+ if text.startswith("{"):
54
+ inner: str = text[1:]
55
+ if "}" in inner:
56
+ inner = inner[: inner.index("}")]
57
+ for raw in inner.split(","):
58
+ symbol: str = raw.strip()
59
+ if not symbol or symbol.startswith("//"):
60
+ continue
61
+ if symbol.startswith("type "):
62
+ symbol = symbol[5:].strip()
63
+ original, sep, alias = symbol.partition(" as ")
64
+ original = original.strip()
65
+ alias = alias.strip()
66
+ if not original:
67
+ continue
68
+ bound: str = alias if sep and alias else original
69
+ if bound:
70
+ symbols.append((bound, original))
71
+ elif text.startswith("*"):
72
+ _, sep, alias = text.partition(" as ")
73
+ alias = alias.strip()
74
+ symbols.append((alias if sep and alias else "*", ""))
75
+ else:
76
+ head, sep, tail = text.partition(",")
77
+ default: str = head.strip()
78
+ if default and default not in ("*", "{"):
79
+ symbols.append((default, ""))
80
+ if sep and tail.strip():
81
+ symbols.extend(_clause_symbols(tail.strip()))
82
+ return symbols
83
+
84
+
85
+ def _imports_from_statement(
86
+ source: str, start_line: int, end_line: int
87
+ ) -> list[ParsedImport]:
88
+ """Parse one TS/JS import statement into per-name import rows."""
89
+ match: re.Match[str] | None = _MODULE_RE.search(source)
90
+ if match is None:
91
+ return []
92
+ module: str = match.group(1).strip()
93
+ if not module:
94
+ return []
95
+ head: str = source[: match.start()].strip()
96
+ if head.startswith("import"):
97
+ head = head[6:].strip()
98
+ else:
99
+ return []
100
+ head = re.sub(r"\bfrom\s*$", "", head).strip()
101
+ symbols: list[tuple[str, str]] = _clause_symbols(head) if head else [("*", "")]
102
+ if not symbols:
103
+ symbols = [("*", "")]
104
+ return [
105
+ ParsedImport(
106
+ module=module,
107
+ name=bound,
108
+ start_line=start_line,
109
+ end_line=end_line,
110
+ original="" if bound == original else original,
111
+ )
112
+ for bound, original in symbols
113
+ ]
114
+
115
+
116
+ def extract_imports(root: Node) -> list[ParsedImport]:
117
+ """Collect imports from ``import_statement`` nodes."""
118
+ found: list[ParsedImport] = []
119
+ for node in walk(root):
120
+ if node.type != "import_statement":
121
+ continue
122
+ text: str = node_text(node)
123
+ if not text.strip().startswith("import"):
124
+ continue
125
+ start, end = span(node)
126
+ found.extend(_imports_from_statement(text, start, end))
127
+ return found
128
+
129
+
130
+ def _bound_variable_name(node: Node) -> str:
131
+ """Return the variable name binding an arrow/function expression."""
132
+ parent: Node | None = node.parent
133
+ if parent is not None and parent.type == "variable_declarator":
134
+ return field_text(parent, "name")
135
+ return ""
136
+
137
+
138
+ def extract_definitions(root: Node) -> list[ParsedDefinition]:
139
+ """Collect classes, functions, methods, and arrow-bound consts (preorder)."""
140
+ found: list[ParsedDefinition] = []
141
+ scope: list[tuple[str, str]] = [] # enclosing (kind, name)
142
+ stack: list[tuple[Node, bool]] = [(root, False)] # True = exit, pops scope
143
+ while stack:
144
+ node, exiting = stack.pop()
145
+ if exiting:
146
+ scope.pop()
147
+ continue
148
+ if node.type == "class_declaration":
149
+ kind: str = "class"
150
+ name: str = field_text(node, "name")
151
+ elif node.type == "function_declaration":
152
+ kind = "method" if scope and scope[-1][0] == "class" else "function"
153
+ name = field_text(node, "name")
154
+ elif node.type == "method_definition":
155
+ kind = "method"
156
+ name = field_text(node, "name")
157
+ elif node.type in ("arrow_function", "function_expression"):
158
+ kind = "function"
159
+ name = _bound_variable_name(node)
160
+ else:
161
+ for child in reversed(node.named_children):
162
+ stack.append((child, False))
163
+ continue
164
+ if not name:
165
+ for child in reversed(node.named_children):
166
+ stack.append((child, False))
167
+ continue
168
+ start, end = span(node)
169
+ found.append(
170
+ ParsedDefinition(
171
+ kind=kind,
172
+ name=name,
173
+ start_line=start,
174
+ end_line=end,
175
+ parent=scope[-1][1] if scope else None,
176
+ )
177
+ )
178
+ scope.append(("class" if kind == "class" else "function", name))
179
+ stack.append((node, True))
180
+ for child in reversed(node.named_children):
181
+ stack.append((child, False))
182
+ return found
183
+
184
+
185
+ @dataclass(slots=True)
186
+ class _ActiveDefinition:
187
+ """One live entry on the definition nesting stack."""
188
+
189
+ def_kind: str # "class" | "function"
190
+ def_name: str
191
+ node_id: str
192
+ end_byte: int
193
+
194
+
195
+ @dataclass(slots=True)
196
+ class ExtractedFileGraph:
197
+ """Per-file collect output: rows plus unresolved call sites.
198
+
199
+ ``definitions`` maps def name -> node id (first registration wins);
200
+ ``imports`` / ``calls`` are the raw IR the link phase resolves.
201
+ No ``calls`` edges are emitted here.
202
+ """
203
+
204
+ rel_path: str
205
+ language: str
206
+ total_lines: int
207
+ nodes: dict[str, GraphNode] = dc_field(
208
+ default_factory=lambda: dict[str, GraphNode]()
209
+ )
210
+ edges: list[Edge] = dc_field(default_factory=lambda: list[Edge]())
211
+ definitions: dict[str, str] = dc_field(
212
+ default_factory=lambda: dict[str, str]()
213
+ )
214
+ imports: list[ParsedImport] = dc_field(
215
+ default_factory=lambda: list[ParsedImport]()
216
+ )
217
+ calls: list[ParsedCall] = dc_field(default_factory=lambda: list[ParsedCall]())
218
+
219
+
220
+ def build_defined_node_id(
221
+ rel_path: str, def_name: str, start_line: int, end_line: int
222
+ ) -> str:
223
+ """Return the symbolic def id ``base:name:start:end``."""
224
+ return f"{rel_path}:{def_name}:{start_line}:{end_line}"
225
+
226
+
227
+ def _pop_finished_definitions(
228
+ active_definition_stack: list[_ActiveDefinition], byte_offset: int
229
+ ) -> None:
230
+ """Pop stack tops whose def ended before byte offset ``byte_offset``."""
231
+ while active_definition_stack and byte_offset > active_definition_stack[-1].end_byte:
232
+ active_definition_stack.pop()
233
+
234
+
235
+ def _register_node_once(
236
+ nodes_by_id: dict[str, GraphNode],
237
+ *,
238
+ node_id: str,
239
+ graph_root: str,
240
+ rel_path: str,
241
+ language: str,
242
+ kind: NodeKind,
243
+ name: str,
244
+ start_line: int,
245
+ end_line: int,
246
+ parent_id: str | None,
247
+ ) -> None:
248
+ """Insert a node row (first registration wins)."""
249
+ if node_id in nodes_by_id:
250
+ return
251
+ nodes_by_id[node_id] = GraphNode(
252
+ id=node_id,
253
+ root=graph_root,
254
+ file_path=rel_path,
255
+ kind=kind,
256
+ name=name,
257
+ language=language,
258
+ start_line=start_line,
259
+ end_line=end_line,
260
+ parent_id=parent_id,
261
+ )
262
+
263
+
264
+ def _register_edge_once(
265
+ emitted_edges: list[Edge],
266
+ emitted_edge_keys: set[tuple[str, str, str]],
267
+ *,
268
+ graph_root: str,
269
+ src_id: str,
270
+ dst_id: str,
271
+ kind: EdgeKind,
272
+ target_module: str | None = None,
273
+ site_line: int | None = None,
274
+ ) -> None:
275
+ """Append an edge row, deduped by ``(src, dst, kind)``."""
276
+ key: tuple[str, str, str] = (src_id, dst_id, kind.value)
277
+ if key in emitted_edge_keys:
278
+ return
279
+ emitted_edge_keys.add(key)
280
+ emitted_edges.append(
281
+ Edge(
282
+ id=f"{src_id}::{kind.value}::{dst_id}",
283
+ root=graph_root,
284
+ src_id=src_id,
285
+ dst_id=dst_id,
286
+ kind=kind,
287
+ target_module=target_module,
288
+ site_line=site_line,
289
+ )
290
+ )
291
+
292
+
293
+ def _bare_callee_name(syntax_node: Node) -> str | None:
294
+ """Return the callee name for a bare-name call or construction.
295
+
296
+ Covers ``f()`` (``call_expression``) and ``new C()``
297
+ (``new_expression`` — construction is a call). Member calls
298
+ (``obj.m()``) never match by construction.
299
+ """
300
+ if syntax_node.type not in ("call_expression", "new_expression"):
301
+ return None
302
+ function_node: Node | None = syntax_node.child_by_field_name("function")
303
+ if function_node is None:
304
+ function_node = syntax_node.child_by_field_name("constructor")
305
+ if function_node is None or function_node.type != "identifier":
306
+ return None
307
+ return node_text(function_node) or None
308
+
309
+
310
+ def collect_ts_file(
311
+ rel_path: str,
312
+ syntax_root: Node,
313
+ graph_root: str,
314
+ total_lines: int,
315
+ ) -> ExtractedFileGraph:
316
+ """Walk one TS/JS file once, collecting defs + imports + call sites.
317
+
318
+ - defs (classes, functions, methods, interfaces, type aliases,
319
+ arrow-bound consts; ``export`` wrappers are transparent since the
320
+ walk visits nested nodes) → ``Node`` + ``Edge(CONTAINS)`` with
321
+ ``base:name:start:end`` ids.
322
+ - imports → ``Node(IMPORT)`` + ``Edge(IMPORTS)`` (existing
323
+ statement rules, now keeping alias originals).
324
+ - bare calls / constructions with a live enclosing def → buffered
325
+ as ``ParsedCall(caller=name, callee, site_line)`` for the link
326
+ phase. Module-level sites are dropped here.
327
+
328
+ Pure given the parsed tree: no module resolution, no ``calls``
329
+ edges.
330
+ """
331
+ suffix: str = Path(rel_path).suffix.lower()
332
+ language: str = JAVASCRIPT if suffix in (".js", ".jsx") else TYPESCRIPT
333
+ extracted_file = ExtractedFileGraph(
334
+ rel_path=rel_path, language=language, total_lines=total_lines
335
+ )
336
+ _register_node_once(
337
+ extracted_file.nodes,
338
+ node_id=rel_path,
339
+ graph_root=graph_root,
340
+ rel_path=rel_path,
341
+ language=language,
342
+ kind=NodeKind.FILE,
343
+ name=Path(rel_path).name,
344
+ start_line=1,
345
+ end_line=total_lines,
346
+ parent_id=None,
347
+ )
348
+ active_definition_stack: list[_ActiveDefinition] = []
349
+ emitted_edge_keys: set[tuple[str, str, str]] = set()
350
+ first_def_node_id_by_name: dict[str, str] = {}
351
+
352
+ for syntax_node in walk(syntax_root):
353
+ if syntax_node.type in (
354
+ "class_declaration",
355
+ "function_declaration",
356
+ "method_definition",
357
+ "method_signature",
358
+ "interface_declaration",
359
+ "type_alias_declaration",
360
+ "arrow_function",
361
+ "function_expression",
362
+ ):
363
+ _pop_finished_definitions(
364
+ active_definition_stack, syntax_node.start_byte
365
+ )
366
+ if syntax_node.type == "class_declaration":
367
+ def_name: str = field_text(syntax_node, "name")
368
+ graph_node_kind: NodeKind = NodeKind.CLASS
369
+ stack_kind: str = "class"
370
+ elif syntax_node.type == "interface_declaration":
371
+ def_name = field_text(syntax_node, "name")
372
+ graph_node_kind = NodeKind.INTERFACE
373
+ stack_kind = "class"
374
+ elif syntax_node.type == "type_alias_declaration":
375
+ def_name = field_text(syntax_node, "name")
376
+ graph_node_kind = NodeKind.TYPE
377
+ stack_kind = "function"
378
+ elif syntax_node.type == "method_signature":
379
+ def_name = field_text(syntax_node, "name")
380
+ graph_node_kind = NodeKind.METHOD
381
+ stack_kind = "function"
382
+ elif syntax_node.type == "function_declaration":
383
+ def_name = field_text(syntax_node, "name")
384
+ is_method: bool = bool(
385
+ active_definition_stack
386
+ and active_definition_stack[-1].def_kind == "class"
387
+ )
388
+ graph_node_kind = NodeKind.METHOD if is_method else NodeKind.FUNCTION
389
+ stack_kind = "function"
390
+ elif syntax_node.type == "method_definition":
391
+ def_name = field_text(syntax_node, "name")
392
+ graph_node_kind = NodeKind.METHOD
393
+ stack_kind = "function"
394
+ else:
395
+ def_name = _bound_variable_name(syntax_node)
396
+ graph_node_kind = NodeKind.FUNCTION
397
+ stack_kind = "function"
398
+ if not def_name:
399
+ continue
400
+ start_line, end_line = span(syntax_node)
401
+ definition_node_id: str = build_defined_node_id(
402
+ rel_path, def_name, start_line, end_line
403
+ )
404
+ parent_node_id: str = (
405
+ active_definition_stack[-1].node_id
406
+ if active_definition_stack
407
+ else rel_path
408
+ )
409
+ _register_node_once(
410
+ extracted_file.nodes,
411
+ node_id=definition_node_id,
412
+ graph_root=graph_root,
413
+ rel_path=rel_path,
414
+ language=language,
415
+ kind=graph_node_kind,
416
+ name=def_name,
417
+ start_line=start_line,
418
+ end_line=end_line,
419
+ parent_id=parent_node_id,
420
+ )
421
+ _register_edge_once(
422
+ extracted_file.edges,
423
+ emitted_edge_keys,
424
+ graph_root=graph_root,
425
+ src_id=parent_node_id,
426
+ dst_id=definition_node_id,
427
+ kind=EdgeKind.CONTAINS,
428
+ )
429
+ first_def_node_id_by_name.setdefault(def_name, definition_node_id)
430
+ active_definition_stack.append(
431
+ _ActiveDefinition(
432
+ def_kind=stack_kind,
433
+ def_name=def_name,
434
+ node_id=definition_node_id,
435
+ end_byte=syntax_node.end_byte,
436
+ )
437
+ )
438
+ elif syntax_node.type == "import_statement":
439
+ import_statement_text: str = node_text(syntax_node)
440
+ if not import_statement_text.strip().startswith("import"):
441
+ continue
442
+ start_line, end_line = span(syntax_node)
443
+ for parsed_import in _imports_from_statement(
444
+ import_statement_text, start_line, end_line
445
+ ):
446
+ import_node_id: str = (
447
+ f"{rel_path}:import:{parsed_import.name}:{start_line}"
448
+ )
449
+ _register_node_once(
450
+ extracted_file.nodes,
451
+ node_id=import_node_id,
452
+ graph_root=graph_root,
453
+ rel_path=rel_path,
454
+ language=language,
455
+ kind=NodeKind.IMPORT,
456
+ name=parsed_import.name,
457
+ start_line=start_line,
458
+ end_line=end_line,
459
+ parent_id=rel_path,
460
+ )
461
+ _register_edge_once(
462
+ extracted_file.edges,
463
+ emitted_edge_keys,
464
+ graph_root=graph_root,
465
+ src_id=rel_path,
466
+ dst_id=import_node_id,
467
+ kind=EdgeKind.IMPORTS,
468
+ target_module=parsed_import.module,
469
+ )
470
+ extracted_file.imports.append(parsed_import)
471
+ else:
472
+ callee_name: str | None = _bare_callee_name(syntax_node)
473
+ if callee_name is None:
474
+ continue
475
+ _pop_finished_definitions(
476
+ active_definition_stack, syntax_node.start_byte
477
+ )
478
+ if not active_definition_stack:
479
+ continue # module-level call: drop
480
+ call_site_line: int = span(syntax_node)[0]
481
+ extracted_file.calls.append(
482
+ ParsedCall(
483
+ caller=active_definition_stack[-1].def_name,
484
+ callee=callee_name,
485
+ site_line=call_site_line,
486
+ )
487
+ )
488
+
489
+ extracted_file.definitions.update(first_def_node_id_by_name)
490
+ return extracted_file
491
+
492
+
493
+ def build_ts_file_rows(
494
+ root: str,
495
+ rel_path: str,
496
+ source_text: str,
497
+ _import_index: Mapping[str, str],
498
+ ) -> FileRows:
499
+ """Build one TS/JS file's rows via the collect phase.
500
+
501
+ Nodes + ``contains`` / ``imports`` edges come out of
502
+ :func:`collect_ts_file` directly; ``calls`` edges are never
503
+ emitted here — ``rows.calls`` carries the buffered call sites for
504
+ :func:`codegraph.parser.links.resolve_call_edges`.
505
+ ``_import_index`` is accepted for builder-signature uniformity and
506
+ ignored: the link phase resolves modules from the full file set.
507
+ """
508
+ from codegraph.parser.rows import FileRows
509
+
510
+ suffix: str = Path(rel_path).suffix.lower()
511
+ language: str = JAVASCRIPT if suffix in (".js", ".jsx") else TYPESCRIPT
512
+ total_lines: int = max(source_text.count("\n") + 1, 1)
513
+ rows = FileRows(rel_path=rel_path, language=language)
514
+ if not source_text.strip():
515
+ rows.nodes.append(
516
+ GraphNode(
517
+ id=rel_path,
518
+ root=root,
519
+ file_path=rel_path,
520
+ kind=NodeKind.FILE,
521
+ name=Path(rel_path).name,
522
+ language=language,
523
+ start_line=1,
524
+ end_line=total_lines,
525
+ parent_id=None,
526
+ )
527
+ )
528
+ return rows
529
+ grammar: str = language
530
+ tree = parse(grammar, source_text.encode("utf-8"))
531
+ out: ExtractedFileGraph = collect_ts_file(
532
+ rel_path, tree.root_node, root, total_lines
533
+ )
534
+ rows.nodes.extend(out.nodes.values())
535
+ rows.edges.extend(out.edges)
536
+ rows.definitions.update(out.definitions)
537
+ for parsed_import in out.imports:
538
+ rows.imports.setdefault(parsed_import.name, parsed_import.module)
539
+ rows.import_details.extend(out.imports)
540
+ rows.calls.extend(out.calls)
541
+ return rows
542
+
543
+
544
+ __all__ = [
545
+ "ExtractedFileGraph",
546
+ "JAVASCRIPT",
547
+ "LANGUAGE",
548
+ "TYPESCRIPT",
549
+ "build_defined_node_id",
550
+ "build_ts_file_rows",
551
+ "collect_ts_file",
552
+ "extract_definitions",
553
+ "extract_imports",
554
+ ]