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.
- codegraph/__init__.py +5 -0
- codegraph/__main__.py +6 -0
- codegraph/cli.py +475 -0
- codegraph/config.py +55 -0
- codegraph/graph_store.py +455 -0
- codegraph/models.py +87 -0
- codegraph/parser/__init__.py +27 -0
- codegraph/parser/base.py +114 -0
- codegraph/parser/lang_go.py +524 -0
- codegraph/parser/lang_python.py +458 -0
- codegraph/parser/lang_typescript.py +554 -0
- codegraph/parser/links.py +314 -0
- codegraph/parser/queries.py +114 -0
- codegraph/parser/raw_core.py +66 -0
- codegraph/parser/rows.py +191 -0
- codegraph/pipeline.py +370 -0
- codegraph/query.py +125 -0
- codegraph/tree.py +230 -0
- codegraph/walk.py +114 -0
- sentinel_codegraph-0.3.0.dist-info/METADATA +251 -0
- sentinel_codegraph-0.3.0.dist-info/RECORD +23 -0
- sentinel_codegraph-0.3.0.dist-info/WHEEL +4 -0
- sentinel_codegraph-0.3.0.dist-info/entry_points.txt +2 -0
codegraph/graph_store.py
ADDED
|
@@ -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
|
+
]
|
codegraph/parser/base.py
ADDED
|
@@ -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
|
+
]
|