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,524 @@
1
+ """Go structural extraction from a raw tree-sitter tree.
2
+
3
+ Collect phase (:func:`collect_go_file`): walks once, emits struct
4
+ types (``class``), interfaces (``interface``), other named types
5
+ (``type``), functions, and methods (reparented to their receiver
6
+ struct), plus imports — dot imports keep ``.`` as the bound name so
7
+ the link phase can resolve their unqualified call sites. Bare-name
8
+ call sites buffer unresolved for the link phase
9
+ (:mod:`codegraph.parser.links`).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import Mapping
15
+ from dataclasses import dataclass
16
+ from dataclasses import field as dc_field
17
+ from pathlib import Path
18
+ from typing import TYPE_CHECKING
19
+
20
+ from tree_sitter import Node
21
+
22
+ from codegraph.models import Edge, EdgeKind
23
+ from codegraph.models import Node as GraphNode
24
+ from codegraph.models import NodeKind
25
+ from codegraph.parser.base import ParsedCall, ParsedDefinition, ParsedImport
26
+ from codegraph.parser.raw_core import field_text, node_text, parse, span, walk
27
+
28
+ if TYPE_CHECKING:
29
+ from codegraph.parser.rows import FileRows
30
+
31
+ LANGUAGE: str = "go"
32
+
33
+
34
+ def extract_definitions(root: Node) -> list[ParsedDefinition]:
35
+ """Collect struct types, functions, and methods in source order (preorder)."""
36
+ found: list[ParsedDefinition] = []
37
+ scope: list[tuple[str, str]] = [] # enclosing (kind, name)
38
+ stack: list[tuple[Node, bool]] = [(root, False)] # True = exit, pops scope
39
+ while stack:
40
+ node, exiting = stack.pop()
41
+ if exiting:
42
+ scope.pop()
43
+ continue
44
+ parent: str | None = scope[-1][1] if scope else None
45
+ if node.type == "function_declaration":
46
+ name: str = field_text(node, "name")
47
+ kind: str = "function"
48
+ elif node.type == "method_declaration":
49
+ name_node: Node | None = node.child_by_field_name("name")
50
+ name = node_text(name_node) if name_node is not None else ""
51
+ kind = "method"
52
+ if parent is None:
53
+ parent = _receiver_base(node)
54
+ elif node.type == "type_spec":
55
+ name = field_text(node, "name")
56
+ kind = "class"
57
+ else:
58
+ for child in reversed(node.named_children):
59
+ stack.append((child, False))
60
+ continue
61
+ if not name:
62
+ for child in reversed(node.named_children):
63
+ stack.append((child, False))
64
+ continue
65
+ start, end = span(node)
66
+ found.append(
67
+ ParsedDefinition(
68
+ kind=kind,
69
+ name=name,
70
+ start_line=start,
71
+ end_line=end,
72
+ parent=parent,
73
+ )
74
+ )
75
+ scope.append(("class" if kind == "class" else "function", name))
76
+ stack.append((node, True))
77
+ for child in reversed(node.named_children):
78
+ stack.append((child, False))
79
+ return found
80
+
81
+
82
+ def _receiver_base(node: Node) -> str | None:
83
+ """Return the receiver's base type name (``*S`` -> ``S``), if any."""
84
+ receiver: Node | None = node.child_by_field_name("receiver")
85
+ if receiver is None:
86
+ return None
87
+ candidates: list[str] = []
88
+ stack: list[Node] = [receiver]
89
+ while stack:
90
+ probe: Node = stack.pop()
91
+ if probe.type == "type_identifier":
92
+ candidates.append(node_text(probe))
93
+ stack.extend(probe.named_children)
94
+ return candidates[-1] if candidates else None
95
+
96
+
97
+ def _import_name(module: str, explicit: str) -> str:
98
+ if explicit == "_":
99
+ return "*"
100
+ if explicit == ".":
101
+ # Dot import: the package's exported names enter this file's
102
+ # scope unqualified. ``.`` marks the row so the link phase can
103
+ # resolve bare sites against the target file's definitions.
104
+ return "."
105
+ if explicit:
106
+ return explicit
107
+ # Default: base of the module path (quoted path without quotes).
108
+ base: str = module.rsplit("/", 1)[-1]
109
+ return base or "*"
110
+
111
+
112
+ def extract_imports(root: Node) -> list[ParsedImport]:
113
+ """Collect imports from ``import_spec`` nodes."""
114
+ found: list[ParsedImport] = []
115
+ for node in walk(root):
116
+ if node.type != "import_spec":
117
+ continue
118
+ module: str = field_text(node, "path").strip().strip('"').strip("`").strip()
119
+ if not module:
120
+ continue
121
+ explicit: str = field_text(node, "name").strip()
122
+ name: str = _import_name(module, explicit)
123
+ start, end = span(node)
124
+ found.append(
125
+ ParsedImport(module=module, name=name, start_line=start, end_line=end)
126
+ )
127
+ return found
128
+
129
+
130
+ @dataclass(slots=True)
131
+ class _ActiveDefinition:
132
+ """One live entry on the definition nesting stack."""
133
+
134
+ def_kind: str # "class" | "function"
135
+ def_name: str
136
+ node_id: str
137
+ end_byte: int
138
+
139
+
140
+ @dataclass(slots=True)
141
+ class ExtractedFileGraph:
142
+ """Per-file collect output: rows plus unresolved call sites.
143
+
144
+ ``definitions`` maps def name -> node id (first registration wins);
145
+ ``imports`` / ``calls`` are the raw IR the link phase resolves.
146
+ No ``calls`` edges are emitted here.
147
+ """
148
+
149
+ rel_path: str
150
+ language: str
151
+ total_lines: int
152
+ nodes: dict[str, GraphNode] = dc_field(
153
+ default_factory=lambda: dict[str, GraphNode]()
154
+ )
155
+ edges: list[Edge] = dc_field(default_factory=lambda: list[Edge]())
156
+ definitions: dict[str, str] = dc_field(
157
+ default_factory=lambda: dict[str, str]()
158
+ )
159
+ imports: list[ParsedImport] = dc_field(
160
+ default_factory=lambda: list[ParsedImport]()
161
+ )
162
+ calls: list[ParsedCall] = dc_field(default_factory=lambda: list[ParsedCall]())
163
+
164
+
165
+ def build_defined_node_id(
166
+ rel_path: str, def_name: str, start_line: int, end_line: int
167
+ ) -> str:
168
+ """Return the symbolic def id ``base:name:start:end``."""
169
+ return f"{rel_path}:{def_name}:{start_line}:{end_line}"
170
+
171
+
172
+ def _pop_finished_definitions(
173
+ active_definition_stack: list[_ActiveDefinition], byte_offset: int
174
+ ) -> None:
175
+ """Pop stack tops whose def ended before byte offset ``byte_offset``."""
176
+ while active_definition_stack and byte_offset > active_definition_stack[-1].end_byte:
177
+ active_definition_stack.pop()
178
+
179
+
180
+ def _register_node_once(
181
+ nodes_by_id: dict[str, GraphNode],
182
+ *,
183
+ node_id: str,
184
+ graph_root: str,
185
+ rel_path: str,
186
+ language: str,
187
+ kind: NodeKind,
188
+ name: str,
189
+ start_line: int,
190
+ end_line: int,
191
+ parent_id: str | None,
192
+ ) -> None:
193
+ """Insert a node row (first registration wins)."""
194
+ if node_id in nodes_by_id:
195
+ return
196
+ nodes_by_id[node_id] = GraphNode(
197
+ id=node_id,
198
+ root=graph_root,
199
+ file_path=rel_path,
200
+ kind=kind,
201
+ name=name,
202
+ language=language,
203
+ start_line=start_line,
204
+ end_line=end_line,
205
+ parent_id=parent_id,
206
+ )
207
+
208
+
209
+ def _register_edge_once(
210
+ emitted_edges: list[Edge],
211
+ emitted_edge_keys: set[tuple[str, str, str]],
212
+ *,
213
+ graph_root: str,
214
+ src_id: str,
215
+ dst_id: str,
216
+ kind: EdgeKind,
217
+ target_module: str | None = None,
218
+ site_line: int | None = None,
219
+ ) -> None:
220
+ """Append an edge row, deduped by ``(src, dst, kind)``."""
221
+ key: tuple[str, str, str] = (src_id, dst_id, kind.value)
222
+ if key in emitted_edge_keys:
223
+ return
224
+ emitted_edge_keys.add(key)
225
+ emitted_edges.append(
226
+ Edge(
227
+ id=f"{src_id}::{kind.value}::{dst_id}",
228
+ root=graph_root,
229
+ src_id=src_id,
230
+ dst_id=dst_id,
231
+ kind=kind,
232
+ target_module=target_module,
233
+ site_line=site_line,
234
+ )
235
+ )
236
+
237
+
238
+ def _is_struct_spec(syntax_node: Node) -> bool:
239
+ """Return True when a ``type_spec`` declares a struct type."""
240
+ for child in syntax_node.named_children:
241
+ if child.type == "struct_type":
242
+ return True
243
+ return False
244
+
245
+
246
+ def _is_interface_spec(syntax_node: Node) -> bool:
247
+ """Return True when a ``type_spec`` declares an interface type."""
248
+ for child in syntax_node.named_children:
249
+ if child.type == "interface_type":
250
+ return True
251
+ return False
252
+
253
+
254
+ def _bare_callee_name(syntax_node: Node) -> str | None:
255
+ """Return the callee name for a bare-name call, else None.
256
+
257
+ Selector calls (``pkg.Fn()``) never match by construction.
258
+ """
259
+ if syntax_node.type != "call_expression":
260
+ return None
261
+ function_node: Node | None = syntax_node.child_by_field_name("function")
262
+ if function_node is None or function_node.type != "identifier":
263
+ return None
264
+ return node_text(function_node) or None
265
+
266
+
267
+ def collect_go_file(
268
+ rel_path: str,
269
+ syntax_root: Node,
270
+ graph_root: str,
271
+ total_lines: int,
272
+ ) -> ExtractedFileGraph:
273
+ """Walk one Go file once, collecting defs + imports + call sites.
274
+
275
+ - struct ``type_spec`` → ``Node(CLASS)``, interface ``type_spec`` →
276
+ ``Node(INTERFACE)`` (with ``method_signature`` children as
277
+ ``METHOD``), other named ``type_spec`` → ``Node(TYPE)``;
278
+ funcs/methods → ``Node`` + ``Edge(CONTAINS)`` with
279
+ ``base:name:start:end`` ids. Methods reparent to their receiver
280
+ struct when it is defined in the same file.
281
+ - ``import_spec`` → ``Node(IMPORT)`` + ``Edge(IMPORTS)`` (blank
282
+ imports bind ``*``, dot imports bind ``.``).
283
+ - bare calls with a live enclosing def → buffered as
284
+ ``ParsedCall(caller=name, callee, site_line)`` for the link
285
+ phase. Package-level call sites are dropped here.
286
+
287
+ Pure given the parsed tree: no module resolution, no ``calls``
288
+ edges.
289
+ """
290
+ language: str = LANGUAGE
291
+ extracted_file = ExtractedFileGraph(
292
+ rel_path=rel_path, language=language, total_lines=total_lines
293
+ )
294
+ _register_node_once(
295
+ extracted_file.nodes,
296
+ node_id=rel_path,
297
+ graph_root=graph_root,
298
+ rel_path=rel_path,
299
+ language=language,
300
+ kind=NodeKind.FILE,
301
+ name=Path(rel_path).name,
302
+ start_line=1,
303
+ end_line=total_lines,
304
+ parent_id=None,
305
+ )
306
+ active_definition_stack: list[_ActiveDefinition] = []
307
+ emitted_edge_keys: set[tuple[str, str, str]] = set()
308
+ first_def_node_id_by_name: dict[str, str] = {}
309
+ struct_node_id_by_name: dict[str, str] = {}
310
+ method_receiver_by_node_id: dict[str, str] = {}
311
+
312
+ for syntax_node in walk(syntax_root):
313
+ if syntax_node.type in (
314
+ "function_declaration",
315
+ "method_declaration",
316
+ "method_elem",
317
+ "type_spec",
318
+ ):
319
+ _pop_finished_definitions(
320
+ active_definition_stack, syntax_node.start_byte
321
+ )
322
+ if syntax_node.type == "type_spec":
323
+ if _is_struct_spec(syntax_node):
324
+ graph_node_kind: NodeKind = NodeKind.CLASS
325
+ stack_kind: str = "class"
326
+ elif _is_interface_spec(syntax_node):
327
+ graph_node_kind = NodeKind.INTERFACE
328
+ stack_kind = "class"
329
+ else:
330
+ graph_node_kind = NodeKind.TYPE
331
+ stack_kind = "function"
332
+ elif syntax_node.type in ("method_declaration", "method_elem"):
333
+ graph_node_kind = NodeKind.METHOD
334
+ stack_kind = "function"
335
+ else:
336
+ graph_node_kind = NodeKind.FUNCTION
337
+ stack_kind = "function"
338
+ def_name: str = field_text(syntax_node, "name")
339
+ if not def_name:
340
+ continue
341
+ start_line, end_line = span(syntax_node)
342
+ definition_node_id: str = build_defined_node_id(
343
+ rel_path, def_name, start_line, end_line
344
+ )
345
+ parent_node_id: str = (
346
+ active_definition_stack[-1].node_id
347
+ if active_definition_stack
348
+ else rel_path
349
+ )
350
+ _register_node_once(
351
+ extracted_file.nodes,
352
+ node_id=definition_node_id,
353
+ graph_root=graph_root,
354
+ rel_path=rel_path,
355
+ language=language,
356
+ kind=graph_node_kind,
357
+ name=def_name,
358
+ start_line=start_line,
359
+ end_line=end_line,
360
+ parent_id=parent_node_id,
361
+ )
362
+ _register_edge_once(
363
+ extracted_file.edges,
364
+ emitted_edge_keys,
365
+ graph_root=graph_root,
366
+ src_id=parent_node_id,
367
+ dst_id=definition_node_id,
368
+ kind=EdgeKind.CONTAINS,
369
+ )
370
+ first_def_node_id_by_name.setdefault(def_name, definition_node_id)
371
+ if syntax_node.type == "type_spec":
372
+ struct_node_id_by_name.setdefault(def_name, definition_node_id)
373
+ elif syntax_node.type == "method_declaration":
374
+ receiver: str | None = _receiver_base(syntax_node)
375
+ if receiver:
376
+ method_receiver_by_node_id.setdefault(
377
+ definition_node_id, receiver
378
+ )
379
+ active_definition_stack.append(
380
+ _ActiveDefinition(
381
+ def_kind=stack_kind,
382
+ def_name=def_name,
383
+ node_id=definition_node_id,
384
+ end_byte=syntax_node.end_byte,
385
+ )
386
+ )
387
+ elif syntax_node.type == "import_spec":
388
+ module: str = (
389
+ field_text(syntax_node, "path").strip().strip('"').strip("`").strip()
390
+ )
391
+ if not module:
392
+ continue
393
+ explicit: str = field_text(syntax_node, "name").strip()
394
+ bound_name: str = _import_name(module, explicit)
395
+ if not bound_name:
396
+ continue
397
+ start_line, end_line = span(syntax_node)
398
+ import_node_id: str = f"{rel_path}:import:{bound_name}:{start_line}"
399
+ _register_node_once(
400
+ extracted_file.nodes,
401
+ node_id=import_node_id,
402
+ graph_root=graph_root,
403
+ rel_path=rel_path,
404
+ language=language,
405
+ kind=NodeKind.IMPORT,
406
+ name=bound_name,
407
+ start_line=start_line,
408
+ end_line=end_line,
409
+ parent_id=rel_path,
410
+ )
411
+ _register_edge_once(
412
+ extracted_file.edges,
413
+ emitted_edge_keys,
414
+ graph_root=graph_root,
415
+ src_id=rel_path,
416
+ dst_id=import_node_id,
417
+ kind=EdgeKind.IMPORTS,
418
+ target_module=module,
419
+ )
420
+ extracted_file.imports.append(
421
+ ParsedImport(
422
+ module=module,
423
+ name=bound_name,
424
+ start_line=start_line,
425
+ end_line=end_line,
426
+ )
427
+ )
428
+ else:
429
+ callee_name: str | None = _bare_callee_name(syntax_node)
430
+ if callee_name is None:
431
+ continue
432
+ _pop_finished_definitions(
433
+ active_definition_stack, syntax_node.start_byte
434
+ )
435
+ if not active_definition_stack:
436
+ continue # package-level call: drop
437
+ call_site_line: int = span(syntax_node)[0]
438
+ extracted_file.calls.append(
439
+ ParsedCall(
440
+ caller=active_definition_stack[-1].def_name,
441
+ callee=callee_name,
442
+ site_line=call_site_line,
443
+ )
444
+ )
445
+
446
+ for method_node_id, receiver_name in method_receiver_by_node_id.items():
447
+ struct_node_id: str | None = struct_node_id_by_name.get(receiver_name)
448
+ if struct_node_id is None:
449
+ continue
450
+ method_node: GraphNode | None = extracted_file.nodes.get(method_node_id)
451
+ if method_node is None or method_node.parent_id != rel_path:
452
+ continue
453
+ method_node.parent_id = struct_node_id
454
+ for edge in extracted_file.edges:
455
+ if (
456
+ edge.kind == EdgeKind.CONTAINS
457
+ and edge.dst_id == method_node_id
458
+ and edge.src_id == rel_path
459
+ ):
460
+ edge.src_id = struct_node_id
461
+ edge.id = f"{struct_node_id}::{edge.kind.value}::{method_node_id}"
462
+ break
463
+
464
+ extracted_file.definitions.update(first_def_node_id_by_name)
465
+ return extracted_file
466
+
467
+
468
+ def build_go_file_rows(
469
+ root: str,
470
+ rel_path: str,
471
+ source_text: str,
472
+ _import_index: Mapping[str, str],
473
+ ) -> FileRows:
474
+ """Build one Go file's rows via the collect phase.
475
+
476
+ Nodes + ``contains`` / ``imports`` edges come out of
477
+ :func:`collect_go_file` directly; ``calls`` edges are never
478
+ emitted here — ``rows.calls`` carries the buffered call sites for
479
+ :func:`codegraph.parser.links.resolve_call_edges`.
480
+ ``_import_index`` is accepted for builder-signature uniformity and
481
+ ignored: the link phase resolves modules from the full file set.
482
+ """
483
+ from codegraph.parser.rows import FileRows
484
+
485
+ total_lines: int = max(source_text.count("\n") + 1, 1)
486
+ rows = FileRows(rel_path=rel_path, language=LANGUAGE)
487
+ if not source_text.strip():
488
+ rows.nodes.append(
489
+ GraphNode(
490
+ id=rel_path,
491
+ root=root,
492
+ file_path=rel_path,
493
+ kind=NodeKind.FILE,
494
+ name=Path(rel_path).name,
495
+ language=LANGUAGE,
496
+ start_line=1,
497
+ end_line=total_lines,
498
+ parent_id=None,
499
+ )
500
+ )
501
+ return rows
502
+ tree = parse(LANGUAGE, source_text.encode("utf-8"))
503
+ out: ExtractedFileGraph = collect_go_file(
504
+ rel_path, tree.root_node, root, total_lines
505
+ )
506
+ rows.nodes.extend(out.nodes.values())
507
+ rows.edges.extend(out.edges)
508
+ rows.definitions.update(out.definitions)
509
+ for parsed_import in out.imports:
510
+ rows.imports.setdefault(parsed_import.name, parsed_import.module)
511
+ rows.import_details.extend(out.imports)
512
+ rows.calls.extend(out.calls)
513
+ return rows
514
+
515
+
516
+ __all__ = [
517
+ "ExtractedFileGraph",
518
+ "LANGUAGE",
519
+ "build_defined_node_id",
520
+ "build_go_file_rows",
521
+ "collect_go_file",
522
+ "extract_definitions",
523
+ "extract_imports",
524
+ ]