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,455 @@
1
+ """Ladybug persistence for the code graph.
2
+
3
+ Embedded property-graph backend. Schema (created once via
4
+ :meth:`LadybugStore.create_all`):
5
+
6
+ - ``CodeNode`` — one row per structural node (``id`` primary key).
7
+ ``is_placeholder`` is retained on the column for old databases but
8
+ new builds never write placeholder rows: unresolvable call sites
9
+ are dropped at build time, so every stored edge has both endpoints.
10
+ - ``Contains`` / ``Imports`` / ``Calls`` — rel tables between
11
+ ``CodeNode`` rows (``site_line`` on ``Calls``, ``target_module`` on
12
+ ``Imports``).
13
+
14
+ Bulk ingest uses single-call ``execute`` + ``UNWIND $batch`` (no
15
+ dataframe dependency). Callers never touch the connection directly
16
+ beyond :func:`create_store`.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from collections.abc import Sequence
22
+ from typing import Any
23
+
24
+ import ladybug as lb
25
+
26
+ from codegraph.models import Edge, EdgeKind, Node, NodeKind
27
+
28
+
29
+ async def _fetch(
30
+ conn: lb.AsyncConnection, query: str, params: dict[str, Any] | None = None
31
+ ) -> list[tuple[Any, ...]]:
32
+ """Run ``query`` and return rows as plain tuples.
33
+
34
+ Ladybug rows index positionally at runtime, but the shipped stubs
35
+ type ``QueryResult.__getitem__`` as str-keyed only — so normalise
36
+ through ``tuple()`` once, here, and keep the rest of the module
37
+ strictly typed.
38
+ """
39
+ result: Any = await conn.execute(query, params or {})
40
+ return [tuple(row) for row in result]
41
+
42
+
43
+ _NODE_FIELDS: tuple[str, ...] = (
44
+ "id",
45
+ "root",
46
+ "file_path",
47
+ "kind",
48
+ "name",
49
+ "language",
50
+ "start_line",
51
+ "end_line",
52
+ "parent_id",
53
+ "is_placeholder",
54
+ )
55
+ """CodeNode columns in canonical order (select + mapping share it)."""
56
+
57
+
58
+ def _node_cols(alias: str) -> str:
59
+ """Return the comma-separated ``alias.col`` select list for a node."""
60
+ return ", ".join(f"{alias}.{field}" for field in _NODE_FIELDS)
61
+
62
+
63
+ def _node_from_row(row: tuple[Any, ...]) -> Node:
64
+ """Map the first ten columns of a result row onto a :class:`Node`."""
65
+ return Node(
66
+ id=str(row[0]),
67
+ root=str(row[1]),
68
+ file_path=str(row[2]),
69
+ kind=NodeKind(str(row[3])),
70
+ name=str(row[4]),
71
+ language=str(row[5]),
72
+ start_line=int(row[6]),
73
+ end_line=int(row[7]),
74
+ parent_id=str(row[8]) if row[8] is not None else None,
75
+ is_placeholder=bool(row[9]),
76
+ )
77
+
78
+
79
+ class LadybugStore:
80
+ """Owns one Ladybug database + async connection."""
81
+
82
+ def __init__(self, path: str) -> None:
83
+ self._path: str = path
84
+ self._db: lb.Database = lb.Database(path)
85
+ self._conn: lb.AsyncConnection = lb.AsyncConnection(self._db)
86
+
87
+ async def create_all(self) -> None:
88
+ """Create node/rel tables when they do not exist yet."""
89
+ await self._conn.execute(
90
+ "CREATE NODE TABLE IF NOT EXISTS CodeNode("
91
+ "id STRING PRIMARY KEY, root STRING, file_path STRING, "
92
+ "kind STRING, name STRING, language STRING, "
93
+ "start_line INT64, end_line INT64, "
94
+ "parent_id STRING, is_placeholder BOOLEAN DEFAULT false)"
95
+ )
96
+ await self._conn.execute(
97
+ "CREATE REL TABLE IF NOT EXISTS Contains(FROM CodeNode TO CodeNode)"
98
+ )
99
+ await self._conn.execute(
100
+ "CREATE REL TABLE IF NOT EXISTS Imports("
101
+ "FROM CodeNode TO CodeNode, target_module STRING)"
102
+ )
103
+ await self._conn.execute(
104
+ "CREATE REL TABLE IF NOT EXISTS Calls("
105
+ "FROM CodeNode TO CodeNode, site_line INT64)"
106
+ )
107
+
108
+ async def clear_all(self) -> None:
109
+ """Delete every node/edge row in the database (full flush)."""
110
+ await self._conn.execute("MATCH (n:CodeNode) DETACH DELETE n")
111
+
112
+ async def clear_root(self, root: str) -> None:
113
+ """Delete every node/edge previously indexed under ``root``."""
114
+ await self._conn.execute(
115
+ "MATCH (n:CodeNode) WHERE n.root = $root DETACH DELETE n",
116
+ {"root": root},
117
+ )
118
+
119
+ async def add_all(self, nodes: Sequence[Node], edges: Sequence[Edge]) -> None:
120
+ """Bulk-insert one batch of nodes plus their edges.
121
+
122
+ Straight persist: every edge endpoint is a stored node id (the
123
+ build drops unresolvable call sites instead of emitting
124
+ dangling refs), so no rewriting or stub synthesis happens here.
125
+ """
126
+ node_rows: list[dict[str, Any]] = [
127
+ {
128
+ "id": n.id,
129
+ "root": n.root,
130
+ "file_path": n.file_path,
131
+ "kind": n.kind.value,
132
+ "name": n.name,
133
+ "language": n.language,
134
+ "start_line": n.start_line,
135
+ "end_line": n.end_line,
136
+ "parent_id": n.parent_id,
137
+ "is_placeholder": n.is_placeholder,
138
+ }
139
+ for n in nodes
140
+ ]
141
+ contains_rows: list[dict[str, Any]] = []
142
+ imports_rows: list[dict[str, Any]] = []
143
+ calls_rows: list[dict[str, Any]] = []
144
+ for e in edges:
145
+ if e.kind == EdgeKind.CONTAINS:
146
+ contains_rows.append({"src": e.src_id, "dst": e.dst_id})
147
+ elif e.kind == EdgeKind.IMPORTS:
148
+ imports_rows.append(
149
+ {"src": e.src_id, "dst": e.dst_id, "mod": e.target_module}
150
+ )
151
+ else:
152
+ calls_rows.append(
153
+ {"src": e.src_id, "dst": e.dst_id, "site": e.site_line}
154
+ )
155
+ if node_rows:
156
+ await self._conn.execute(
157
+ "UNWIND $batch AS row CREATE (n:CodeNode {"
158
+ "id: row.id, root: row.root, file_path: row.file_path, "
159
+ "kind: row.kind, name: row.name, language: row.language, "
160
+ "start_line: row.start_line, end_line: row.end_line, "
161
+ "parent_id: row.parent_id, is_placeholder: row.is_placeholder})",
162
+ {"batch": node_rows},
163
+ )
164
+ if contains_rows:
165
+ await self._conn.execute(
166
+ "UNWIND $batch AS row "
167
+ "MATCH (a:CodeNode {id: row.src}), (b:CodeNode {id: row.dst}) "
168
+ "CREATE (a)-[:Contains]->(b)",
169
+ {"batch": contains_rows},
170
+ )
171
+ if imports_rows:
172
+ await self._conn.execute(
173
+ "UNWIND $batch AS row "
174
+ "MATCH (a:CodeNode {id: row.src}), (b:CodeNode {id: row.dst}) "
175
+ "CREATE (a)-[:Imports {target_module: row.mod}]->(b)",
176
+ {"batch": imports_rows},
177
+ )
178
+ if calls_rows:
179
+ await self._conn.execute(
180
+ "UNWIND $batch AS row "
181
+ "MATCH (a:CodeNode {id: row.src}), (b:CodeNode {id: row.dst}) "
182
+ "CREATE (a)-[:Calls {site_line: row.site}]->(b)",
183
+ {"batch": calls_rows},
184
+ )
185
+
186
+ async def count_by_node_kind(self, root: str | None = None) -> dict[str, int]:
187
+ """Return row counts grouped by node kind (keys are kind values)."""
188
+ query: str = "MATCH (n:CodeNode)"
189
+ params: dict[str, Any] = {}
190
+ if root is not None:
191
+ query += " WHERE n.root = $root"
192
+ params["root"] = root
193
+ query += " RETURN n.kind AS k, COUNT(*) AS c"
194
+ return {str(k): int(c) for k, c in await _fetch(self._conn, query, params)}
195
+
196
+ async def count_by_edge_kind(self, root: str | None = None) -> dict[str, int]:
197
+ """Return row counts grouped by edge kind (keys are kind values)."""
198
+ out: dict[str, int] = {}
199
+ for label in ("Contains", "Imports", "Calls"):
200
+ query: str = f"MATCH (a:CodeNode)-[e:{label}]->(:CodeNode)"
201
+ params: dict[str, Any] = {}
202
+ if root is not None:
203
+ query += " WHERE a.root = $root"
204
+ params["root"] = root
205
+ rows: list[tuple[Any, ...]] = await _fetch(
206
+ self._conn, query + " RETURN COUNT(*)", params
207
+ )
208
+ out[label.lower()] = int(rows[0][0])
209
+ return out
210
+
211
+ async def count_by_language(self, root: str | None = None) -> dict[str, int]:
212
+ """Return file-node counts grouped by language."""
213
+ query: str = "MATCH (n:CodeNode) WHERE n.kind = 'file'"
214
+ params: dict[str, Any] = {}
215
+ if root is not None:
216
+ query += " AND n.root = $root"
217
+ params["root"] = root
218
+ query += " RETURN n.language AS l, COUNT(*) AS c"
219
+ return {str(l): int(c) for l, c in await _fetch(self._conn, query, params)}
220
+
221
+ async def total_counts(self, root: str | None = None) -> tuple[int, int, int]:
222
+ """Return ``(files, nodes, edges)`` totals."""
223
+ params: dict[str, Any] = {"root": root} if root is not None else {}
224
+ root_and: str = " AND n.root = $root" if root is not None else ""
225
+ root_where_a: str = " WHERE a.root = $root" if root is not None else ""
226
+ files_rows: list[tuple[Any, ...]] = await _fetch(
227
+ self._conn,
228
+ "MATCH (n:CodeNode) WHERE n.kind = 'file'" + root_and + " RETURN COUNT(*)",
229
+ params,
230
+ )
231
+ nodes_rows: list[tuple[Any, ...]] = await _fetch(
232
+ self._conn,
233
+ "MATCH (n:CodeNode)"
234
+ + (" WHERE n.root = $root" if root is not None else "")
235
+ + " RETURN COUNT(*)",
236
+ params,
237
+ )
238
+ edge_total: int = 0
239
+ for label in ("Contains", "Imports", "Calls"):
240
+ rel_rows: list[tuple[Any, ...]] = await _fetch(
241
+ self._conn,
242
+ f"MATCH (a:CodeNode)-[:{label}]->(:CodeNode)"
243
+ + root_where_a
244
+ + " RETURN COUNT(*)",
245
+ params,
246
+ )
247
+ edge_total += int(rel_rows[0][0])
248
+ return (int(files_rows[0][0]), int(nodes_rows[0][0]), edge_total)
249
+
250
+ async def top_importers(self, limit: int = 10) -> list[tuple[str, int]]:
251
+ """Return ``(file_path, import_count)`` ordered by import count."""
252
+ rows: list[tuple[Any, ...]] = await _fetch(
253
+ self._conn,
254
+ "MATCH (a:CodeNode)-[:Imports]->(:CodeNode) "
255
+ "RETURN a.file_path AS p, COUNT(*) AS c "
256
+ "ORDER BY c DESC LIMIT $limit",
257
+ {"limit": limit},
258
+ )
259
+ return [(str(path), int(count)) for path, count in rows]
260
+
261
+ async def list_nodes(self, root: str | None = None) -> list[Node]:
262
+ """Return nodes, optionally scoped to ``root``, in stable order."""
263
+ query: str = (
264
+ "MATCH (n:CodeNode)"
265
+ + (" WHERE n.root = $root" if root is not None else "")
266
+ + f" RETURN {_node_cols('n')} "
267
+ "ORDER BY n.file_path, n.kind, n.start_line"
268
+ )
269
+ params: dict[str, Any] = {"root": root} if root is not None else {}
270
+ return [_node_from_row(row) for row in await _fetch(self._conn, query, params)]
271
+
272
+ async def get_node(self, node_id: str, root: str | None = None) -> Node | None:
273
+ """Return one node by id (``None`` when absent). Pure read."""
274
+ query: str = "MATCH (n:CodeNode) WHERE n.id = $id"
275
+ params: dict[str, Any] = {"id": node_id}
276
+ if root is not None:
277
+ query += " AND n.root = $root"
278
+ params["root"] = root
279
+ query += f" RETURN {_node_cols('n')}"
280
+ rows: list[tuple[Any, ...]] = await _fetch(self._conn, query, params)
281
+ return _node_from_row(rows[0]) if rows else None
282
+
283
+ async def callees(
284
+ self, node_id: str, root: str | None = None
285
+ ) -> list[tuple[Node, int | None]]:
286
+ """Return ``(node, site_line)`` for outgoing ``Calls``, in call order.
287
+
288
+ Sorted by call-site line (unknown lines last), then callee
289
+ span — never worse than alphabetical. Pure read.
290
+ """
291
+ query: str = "MATCH (c:CodeNode {id: $id})-[e:Calls]->(d:CodeNode)"
292
+ params: dict[str, Any] = {"id": node_id}
293
+ if root is not None:
294
+ query += " WHERE c.root = $root"
295
+ params["root"] = root
296
+ query += f" RETURN {_node_cols('d')}, e.site_line"
297
+ found: list[tuple[Node, int | None]] = [
298
+ (
299
+ _node_from_row(row[: len(_NODE_FIELDS)]),
300
+ int(row[len(_NODE_FIELDS)]) if row[len(_NODE_FIELDS)] is not None else None,
301
+ )
302
+ for row in await _fetch(self._conn, query, params)
303
+ ]
304
+ found.sort(
305
+ key=lambda item: (
306
+ item[1] is None,
307
+ item[1] if item[1] is not None else 0,
308
+ item[0].start_line,
309
+ item[0].name,
310
+ )
311
+ )
312
+ return found
313
+
314
+ async def callers(
315
+ self, node_id: str, root: str | None = None
316
+ ) -> list[tuple[Node, int | None]]:
317
+ """Return ``(node, site_line)`` for incoming ``Calls``, in call order.
318
+
319
+ Pure read; same ordering as :meth:`callees`.
320
+ """
321
+ query: str = "MATCH (s:CodeNode)-[e:Calls]->(c:CodeNode {id: $id})"
322
+ params: dict[str, Any] = {"id": node_id}
323
+ if root is not None:
324
+ query += " WHERE c.root = $root"
325
+ params["root"] = root
326
+ query += f" RETURN {_node_cols('s')}, e.site_line"
327
+ found: list[tuple[Node, int | None]] = [
328
+ (
329
+ _node_from_row(row[: len(_NODE_FIELDS)]),
330
+ int(row[len(_NODE_FIELDS)]) if row[len(_NODE_FIELDS)] is not None else None,
331
+ )
332
+ for row in await _fetch(self._conn, query, params)
333
+ ]
334
+ found.sort(
335
+ key=lambda item: (
336
+ item[1] is None,
337
+ item[1] if item[1] is not None else 0,
338
+ item[0].start_line,
339
+ item[0].name,
340
+ )
341
+ )
342
+ return found
343
+
344
+ async def children(
345
+ self, node_id: str, root: str | None = None
346
+ ) -> list[Node]:
347
+ """Return outgoing ``Contains`` targets in span order. Pure read."""
348
+ query: str = "MATCH (p:CodeNode {id: $id})-[:Contains]->(d:CodeNode)"
349
+ params: dict[str, Any] = {"id": node_id}
350
+ if root is not None:
351
+ query += " WHERE p.root = $root"
352
+ params["root"] = root
353
+ query += f" RETURN {_node_cols('d')} ORDER BY d.start_line, d.name"
354
+ return [_node_from_row(row) for row in await _fetch(self._conn, query, params)]
355
+
356
+ async def file_imports(
357
+ self, file_rel: str, root: str | None = None
358
+ ) -> list[tuple[Node, str | None]]:
359
+ """Return ``(import node, target_module)`` for a file, in line order.
360
+
361
+ The file node id is its ``rel_path``. Pure read.
362
+ """
363
+ query: str = "MATCH (f:CodeNode {id: $id})-[e:Imports]->(i:CodeNode)"
364
+ params: dict[str, Any] = {"id": file_rel}
365
+ if root is not None:
366
+ query += " WHERE f.root = $root"
367
+ params["root"] = root
368
+ query += f" RETURN {_node_cols('i')}, e.target_module ORDER BY i.start_line"
369
+ return [
370
+ (
371
+ _node_from_row(row[: len(_NODE_FIELDS)]),
372
+ str(row[len(_NODE_FIELDS)]) if row[len(_NODE_FIELDS)] is not None else None,
373
+ )
374
+ for row in await _fetch(self._conn, query, params)
375
+ ]
376
+
377
+ async def list_files(self, root: str | None = None) -> list[Node]:
378
+ """Return file nodes, optionally scoped to ``root``, by path. Pure read."""
379
+ query: str = "MATCH (n:CodeNode) WHERE n.kind = 'file'"
380
+ params: dict[str, Any] = {}
381
+ if root is not None:
382
+ query += " AND n.root = $root"
383
+ params["root"] = root
384
+ query += f" RETURN {_node_cols('n')} ORDER BY n.file_path"
385
+ return [_node_from_row(row) for row in await _fetch(self._conn, query, params)]
386
+
387
+ async def list_edges(self, root: str | None = None) -> list[Edge]:
388
+ """Return edges, optionally scoped to ``root``, in stable order."""
389
+ params: dict[str, Any] = {"root": root} if root is not None else {}
390
+ where: str = " WHERE a.root = $root" if root is not None else ""
391
+ found: list[Edge] = []
392
+ for row in await _fetch(
393
+ self._conn,
394
+ "MATCH (a:CodeNode)-[:Contains]->(b:CodeNode)"
395
+ + where
396
+ + " RETURN a.root, a.id, b.id",
397
+ params,
398
+ ):
399
+ found.append(
400
+ Edge(
401
+ id=f"{row[1]}::contains::{row[2]}",
402
+ root=str(row[0]),
403
+ src_id=str(row[1]),
404
+ dst_id=str(row[2]),
405
+ kind=EdgeKind.CONTAINS,
406
+ )
407
+ )
408
+ for row in await _fetch(
409
+ self._conn,
410
+ "MATCH (a:CodeNode)-[e:Imports]->(b:CodeNode)"
411
+ + where
412
+ + " RETURN a.root, a.id, b.id, e.target_module",
413
+ params,
414
+ ):
415
+ found.append(
416
+ Edge(
417
+ id=f"{row[1]}::imports::{row[2]}",
418
+ root=str(row[0]),
419
+ src_id=str(row[1]),
420
+ dst_id=str(row[2]),
421
+ kind=EdgeKind.IMPORTS,
422
+ target_module=str(row[3]) if row[3] is not None else None,
423
+ )
424
+ )
425
+ for row in await _fetch(
426
+ self._conn,
427
+ "MATCH (a:CodeNode)-[e:Calls]->(b:CodeNode)"
428
+ + where
429
+ + " RETURN a.root, a.id, b.id, e.site_line",
430
+ params,
431
+ ):
432
+ found.append(
433
+ Edge(
434
+ id=f"{row[1]}::calls::{row[2]}",
435
+ root=str(row[0]),
436
+ src_id=str(row[1]),
437
+ dst_id=str(row[2]),
438
+ kind=EdgeKind.CALLS,
439
+ site_line=int(row[3]) if row[3] is not None else None,
440
+ )
441
+ )
442
+ found.sort(key=lambda e: (e.src_id, e.kind.value))
443
+ return found
444
+
445
+ async def dispose(self) -> None:
446
+ """Release the connection (embedded DB needs no pool teardown)."""
447
+ return None
448
+
449
+
450
+ def create_store(path: str) -> LadybugStore:
451
+ """Create a :class:`LadybugStore` bound to ``path`` (or ``:memory:``)."""
452
+ return LadybugStore(path)
453
+
454
+
455
+ __all__ = ["EdgeKind", "LadybugStore", "NodeKind", "create_store"]
codegraph/models.py ADDED
@@ -0,0 +1,87 @@
1
+ """Plain dataclasses for the code graph.
2
+
3
+ Single source of truth for the row shapes. Persistence lives in
4
+ :mod:`codegraph.graph_store` (Ladybug); these types never touch the
5
+ database layer.
6
+
7
+ Graph contract (two-pass build: Python, TypeScript/JavaScript, Go):
8
+
9
+ - ``Node`` kinds: file | class | function | method | interface |
10
+ type | import. Interfaces own their method signatures; type aliases
11
+ are leaves.
12
+ - ``Edge`` kinds: contains (file -> def, class/interface -> method,
13
+ function -> nested def) | imports (file -> import, carrying the raw
14
+ ``target_module`` string) | calls (function|method ->
15
+ function|method|class, bare-name call sites only, real node ids).
16
+ - Unresolvable call sites are dropped at build time: every stored
17
+ ``calls`` edge has both endpoints, and no placeholder rows are
18
+ written (``is_placeholder`` stays on the schema for old databases).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import enum
24
+ import uuid
25
+ from dataclasses import dataclass, field
26
+
27
+
28
+ def new_id() -> str:
29
+ """Return a random hex id for a graph row."""
30
+ return uuid.uuid4().hex
31
+
32
+
33
+ class NodeKind(str, enum.Enum):
34
+ """Structural node types extracted by the raw parsers."""
35
+
36
+ FILE = "file"
37
+ CLASS = "class"
38
+ FUNCTION = "function"
39
+ METHOD = "method"
40
+ INTERFACE = "interface"
41
+ TYPE = "type"
42
+ IMPORT = "import"
43
+
44
+
45
+ class EdgeKind(str, enum.Enum):
46
+ """Structural edge types between nodes."""
47
+
48
+ CONTAINS = "contains"
49
+ IMPORTS = "imports"
50
+ CALLS = "calls"
51
+
52
+
53
+ @dataclass(slots=True)
54
+ class Node:
55
+ """One structural element of a scanned codebase."""
56
+
57
+ id: str = field(default_factory=new_id)
58
+ root: str = ""
59
+ file_path: str = ""
60
+ kind: NodeKind = NodeKind.FILE
61
+ name: str = ""
62
+ language: str = ""
63
+ start_line: int = 1
64
+ end_line: int = 1
65
+ parent_id: str | None = None
66
+ is_placeholder: bool = False
67
+
68
+
69
+ @dataclass(slots=True)
70
+ class Edge:
71
+ """One structural relation between two nodes.
72
+
73
+ Both ``src_id`` and ``dst_id`` always point at stored nodes; the
74
+ link phase drops call sites that resolve to nothing instead of
75
+ emitting dangling refs.
76
+ """
77
+
78
+ id: str = field(default_factory=new_id)
79
+ root: str = ""
80
+ src_id: str = ""
81
+ dst_id: str = ""
82
+ kind: EdgeKind = EdgeKind.CONTAINS
83
+ target_module: str | None = None
84
+ site_line: int | None = None
85
+
86
+
87
+ __all__ = ["Edge", "EdgeKind", "Node", "NodeKind", "new_id"]
@@ -0,0 +1,27 @@
1
+ """Parser barrel: re-exports only, no logic.
2
+
3
+ Per-file rows live in :mod:`codegraph.parser.rows`, cross-file
4
+ linking (import map + call join) in :mod:`codegraph.parser.links`,
5
+ the Python collect phase in :mod:`codegraph.parser.lang_python`;
6
+ ``lang_go`` / ``lang_typescript`` are kept, currently unwired.
7
+ """
8
+
9
+ from codegraph.parser.links import (
10
+ ImportEntry,
11
+ build_dot_import_targets,
12
+ build_file_import_map,
13
+ build_import_index,
14
+ resolve_call_edges,
15
+ )
16
+ from codegraph.parser.rows import FileRows, build_file_rows, build_python_file_rows
17
+
18
+ __all__ = [
19
+ "FileRows",
20
+ "ImportEntry",
21
+ "build_dot_import_targets",
22
+ "build_file_import_map",
23
+ "build_file_rows",
24
+ "build_import_index",
25
+ "build_python_file_rows",
26
+ "resolve_call_edges",
27
+ ]
@@ -0,0 +1,114 @@
1
+ """Shared intermediate representation for the raw parsers.
2
+
3
+ Each language extractor turns a tree-sitter tree into a :class:`ParsedFile`
4
+ — flat lists of definitions, imports, and call sites with 1-based line
5
+ spans. The graph builder (``parser/__init__.py``) then converts the IR
6
+ into ``Node`` / ``Edge`` rows, so parsers never touch the database layer.
7
+ All IR constructors are pure.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, field
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class ParsedDefinition:
17
+ """A class, function, or method found in a source file."""
18
+
19
+ kind: str # "class" | "function" | "method"
20
+ name: str
21
+ start_line: int # 1-based, inclusive
22
+ end_line: int # 1-based, inclusive
23
+ parent: str | None = None # nearest enclosing def name (any kind)
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class ParsedImport:
28
+ """A single imported name (one row per name, not per statement)."""
29
+
30
+ module: str # raw module specifier, e.g. "os" | "pkg.utils" | ".sibling"
31
+ name: str # bound symbol in the importing file, or "*" for star imports
32
+ start_line: int # 1-based
33
+ end_line: int # 1-based
34
+ original: str = "" # name in the defining module; "" means same as ``name``
35
+ # ``from utils import helper as h`` -> name="h", original="helper".
36
+ # ``from utils import helper`` -> name="helper", original="".
37
+ # ``import os`` -> name="os", original="".
38
+
39
+ @property
40
+ def effective_original(self) -> str:
41
+ """Return the defining-module name (falls back to the bound name)."""
42
+ return self.original or self.name
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class ParsedCall:
47
+ """A bare-name call site: ``caller`` calls ``callee``.
48
+
49
+ ``caller`` is the innermost enclosing named definition; module-level
50
+ call sites are dropped, so ``caller`` is never empty. ``callee`` is
51
+ the called bare name — resolution to a node happens in the link
52
+ phase, which drops names that resolve to nothing indexed.
53
+ """
54
+
55
+ caller: str
56
+ callee: str
57
+ site_line: int | None = None # 1-based call-site line (None = unknown)
58
+
59
+
60
+ @dataclass(frozen=True, slots=True)
61
+ class ParsedFile:
62
+ """Parser output for one source file."""
63
+
64
+ language: str
65
+ definitions: tuple[ParsedDefinition, ...] = ()
66
+ imports: tuple[ParsedImport, ...] = ()
67
+ calls: tuple[ParsedCall, ...] = ()
68
+ total_lines: int = 1
69
+ has_error: bool = False
70
+
71
+
72
+ def empty_file(language: str, total_lines: int = 1) -> ParsedFile:
73
+ """Return an empty :class:`ParsedFile` (unparseable or blank source)."""
74
+ return ParsedFile(
75
+ language=language,
76
+ definitions=(),
77
+ imports=(),
78
+ calls=(),
79
+ total_lines=max(total_lines, 1),
80
+ has_error=True,
81
+ )
82
+
83
+
84
+ @dataclass(slots=True)
85
+ class FileAccumulator:
86
+ """Mutable builder collected during a tree walk."""
87
+
88
+ language: str
89
+ total_lines: int = 1
90
+ has_error: bool = False
91
+ definitions: list[ParsedDefinition] = field(default_factory=list)
92
+ imports: list[ParsedImport] = field(default_factory=list)
93
+ calls: list[ParsedCall] = field(default_factory=list)
94
+
95
+ def build(self) -> ParsedFile:
96
+ """Freeze the accumulator into a :class:`ParsedFile`."""
97
+ return ParsedFile(
98
+ language=self.language,
99
+ definitions=tuple(self.definitions),
100
+ imports=tuple(self.imports),
101
+ calls=tuple(self.calls),
102
+ total_lines=max(self.total_lines, 1),
103
+ has_error=self.has_error,
104
+ )
105
+
106
+
107
+ __all__ = [
108
+ "FileAccumulator",
109
+ "ParsedCall",
110
+ "ParsedDefinition",
111
+ "ParsedFile",
112
+ "ParsedImport",
113
+ "empty_file",
114
+ ]