pycode-kg 0.16.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.
- pycode_kg/.DS_Store +0 -0
- pycode_kg/__init__.py +91 -0
- pycode_kg/__main__.py +11 -0
- pycode_kg/analysis/__init__.py +15 -0
- pycode_kg/analysis/bridge.py +108 -0
- pycode_kg/analysis/centrality.py +412 -0
- pycode_kg/analysis/framework_detector.py +103 -0
- pycode_kg/analysis/hybrid_rank.py +53 -0
- pycode_kg/app.py +1335 -0
- pycode_kg/architecture.py +624 -0
- pycode_kg/build_pycodekg_lancedb.py +15 -0
- pycode_kg/build_pycodekg_sqlite.py +15 -0
- pycode_kg/cli/__init__.py +29 -0
- pycode_kg/cli/cmd_analyze.py +96 -0
- pycode_kg/cli/cmd_architecture.py +150 -0
- pycode_kg/cli/cmd_bridges.py +26 -0
- pycode_kg/cli/cmd_build.py +154 -0
- pycode_kg/cli/cmd_build_full.py +242 -0
- pycode_kg/cli/cmd_centrality.py +131 -0
- pycode_kg/cli/cmd_explain.py +180 -0
- pycode_kg/cli/cmd_framework_nodes.py +18 -0
- pycode_kg/cli/cmd_hooks.py +137 -0
- pycode_kg/cli/cmd_init.py +312 -0
- pycode_kg/cli/cmd_mcp.py +71 -0
- pycode_kg/cli/cmd_model.py +53 -0
- pycode_kg/cli/cmd_query.py +211 -0
- pycode_kg/cli/cmd_snapshot.py +421 -0
- pycode_kg/cli/cmd_viz.py +180 -0
- pycode_kg/cli/main.py +23 -0
- pycode_kg/cli/options.py +63 -0
- pycode_kg/config.py +78 -0
- pycode_kg/graph.py +125 -0
- pycode_kg/index.py +542 -0
- pycode_kg/kg.py +220 -0
- pycode_kg/layout3d.py +470 -0
- pycode_kg/mcp/bridge_tools.py +19 -0
- pycode_kg/mcp/framework_tools.py +18 -0
- pycode_kg/mcp_server.py +1965 -0
- pycode_kg/module/__init__.py +83 -0
- pycode_kg/module/base.py +720 -0
- pycode_kg/module/extractor.py +276 -0
- pycode_kg/module/types.py +532 -0
- pycode_kg/pycodekg.py +543 -0
- pycode_kg/pycodekg_query.py +1 -0
- pycode_kg/pycodekg_snippet_packer.py +1 -0
- pycode_kg/pycodekg_thorough_analysis.py +2751 -0
- pycode_kg/pycodekg_viz.py +1 -0
- pycode_kg/pycodekg_viz3d.py +1 -0
- pycode_kg/ranking/__init__.py +1 -0
- pycode_kg/ranking/cli_rank.py +92 -0
- pycode_kg/ranking/coderank.py +555 -0
- pycode_kg/snapshots.py +612 -0
- pycode_kg/sql/004_add_centrality_table.sql +12 -0
- pycode_kg/store.py +766 -0
- pycode_kg/utils.py +39 -0
- pycode_kg/visitor.py +413 -0
- pycode_kg/viz3d.py +1353 -0
- pycode_kg/viz3d_timeline.py +364 -0
- pycode_kg-0.16.0.dist-info/METADATA +305 -0
- pycode_kg-0.16.0.dist-info/RECORD +63 -0
- pycode_kg-0.16.0.dist-info/WHEEL +4 -0
- pycode_kg-0.16.0.dist-info/entry_points.txt +19 -0
- pycode_kg-0.16.0.dist-info/licenses/LICENSE +94 -0
pycode_kg/store.py
ADDED
|
@@ -0,0 +1,766 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
store.py
|
|
4
|
+
|
|
5
|
+
GraphStore — SQLite persistence layer for the Code Knowledge Graph.
|
|
6
|
+
|
|
7
|
+
SQLite is the authoritative, canonical store.
|
|
8
|
+
No embeddings, no LanceDB, no AST.
|
|
9
|
+
|
|
10
|
+
Author: Eric G. Suchanek, PhD
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import sqlite3
|
|
17
|
+
from collections.abc import Iterable, Sequence
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from pycode_kg.pycodekg import Edge, Node
|
|
21
|
+
from pycode_kg.utils import node_id
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# Schema
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
_SCHEMA_SQL = """
|
|
28
|
+
PRAGMA journal_mode=WAL;
|
|
29
|
+
PRAGMA synchronous=NORMAL;
|
|
30
|
+
|
|
31
|
+
CREATE TABLE IF NOT EXISTS nodes (
|
|
32
|
+
id TEXT PRIMARY KEY,
|
|
33
|
+
kind TEXT NOT NULL,
|
|
34
|
+
name TEXT NOT NULL,
|
|
35
|
+
qualname TEXT,
|
|
36
|
+
module_path TEXT,
|
|
37
|
+
lineno INTEGER,
|
|
38
|
+
end_lineno INTEGER,
|
|
39
|
+
docstring TEXT
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
CREATE TABLE IF NOT EXISTS edges (
|
|
43
|
+
src TEXT NOT NULL,
|
|
44
|
+
rel TEXT NOT NULL,
|
|
45
|
+
dst TEXT NOT NULL,
|
|
46
|
+
evidence TEXT,
|
|
47
|
+
PRIMARY KEY (src, rel, dst)
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
CREATE INDEX IF NOT EXISTS idx_nodes_kind ON nodes(kind);
|
|
51
|
+
CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
|
|
52
|
+
CREATE INDEX IF NOT EXISTS idx_nodes_module ON nodes(module_path);
|
|
53
|
+
|
|
54
|
+
CREATE INDEX IF NOT EXISTS idx_edges_src ON edges(src);
|
|
55
|
+
CREATE INDEX IF NOT EXISTS idx_edges_dst ON edges(dst);
|
|
56
|
+
CREATE INDEX IF NOT EXISTS idx_edges_rel ON edges(rel);
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
# Default edge types used for graph expansion
|
|
60
|
+
DEFAULT_RELS: tuple[str, ...] = ("CONTAINS", "CALLS", "IMPORTS", "INHERITS")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _module_to_dotted_variants(module_path: str | None) -> tuple[str, ...]:
|
|
64
|
+
"""Convert module path to dotted notation, including common src-layout alias.
|
|
65
|
+
|
|
66
|
+
:param module_path: Path like ``src/pkg/mod.py``.
|
|
67
|
+
:return: Tuple of dotted variants, e.g. ``("src.pkg.mod", "pkg.mod")``.
|
|
68
|
+
"""
|
|
69
|
+
if not module_path:
|
|
70
|
+
return ()
|
|
71
|
+
if module_path.endswith(".py"):
|
|
72
|
+
module_path = module_path[:-3]
|
|
73
|
+
dotted = module_path.replace("/", ".")
|
|
74
|
+
variants = [dotted]
|
|
75
|
+
if dotted.startswith("src."):
|
|
76
|
+
variants.append(dotted[4:])
|
|
77
|
+
return tuple(dict.fromkeys(v for v in variants if v))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
# Provenance metadata returned by expand()
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class ProvMeta:
|
|
86
|
+
"""
|
|
87
|
+
Provenance metadata for a node returned by :meth:`GraphStore.expand`.
|
|
88
|
+
|
|
89
|
+
:param best_hop: Minimum hop distance from any seed node.
|
|
90
|
+
:param via_seed: ID of the seed node that yielded the shortest path.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
__slots__ = ("best_hop", "via_seed")
|
|
94
|
+
|
|
95
|
+
def __init__(self, best_hop: int, via_seed: str) -> None:
|
|
96
|
+
"""Initialise provenance metadata for a graph node.
|
|
97
|
+
|
|
98
|
+
:param best_hop: Minimum hop distance from any seed node.
|
|
99
|
+
:param via_seed: ID of the seed node that yielded the shortest path.
|
|
100
|
+
"""
|
|
101
|
+
self.best_hop = best_hop
|
|
102
|
+
self.via_seed = via_seed
|
|
103
|
+
|
|
104
|
+
def __repr__(self) -> str:
|
|
105
|
+
"""Return a developer-readable representation of this ProvMeta.
|
|
106
|
+
|
|
107
|
+
:return: String of the form ``ProvMeta(best_hop=..., via_seed=...)``.
|
|
108
|
+
"""
|
|
109
|
+
return f"ProvMeta(best_hop={self.best_hop}, via_seed={self.via_seed!r})"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
# GraphStore
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class GraphStore:
|
|
118
|
+
"""
|
|
119
|
+
SQLite-backed authoritative store for the Code Knowledge Graph.
|
|
120
|
+
|
|
121
|
+
Manages the ``nodes`` and ``edges`` tables and provides graph
|
|
122
|
+
traversal primitives used by the query layer.
|
|
123
|
+
|
|
124
|
+
Example::
|
|
125
|
+
|
|
126
|
+
store = GraphStore("pycodekg.sqlite")
|
|
127
|
+
store.write(nodes, edges, wipe=True)
|
|
128
|
+
print(store.stats())
|
|
129
|
+
|
|
130
|
+
# fetch a single node
|
|
131
|
+
n = store.node("fn:src/foo.py:bar")
|
|
132
|
+
|
|
133
|
+
# expand from seeds
|
|
134
|
+
meta = store.expand({"fn:src/foo.py:bar"}, hop=2)
|
|
135
|
+
|
|
136
|
+
:param db_path: Path to the SQLite database file (created if absent).
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
def __init__(self, db_path: str | Path) -> None:
|
|
140
|
+
"""Initialise the store against a SQLite database file.
|
|
141
|
+
|
|
142
|
+
:param db_path: Path to the SQLite database file (created if absent).
|
|
143
|
+
"""
|
|
144
|
+
self.db_path = Path(db_path)
|
|
145
|
+
self._con: sqlite3.Connection | None = None
|
|
146
|
+
|
|
147
|
+
# ------------------------------------------------------------------
|
|
148
|
+
# Connection management
|
|
149
|
+
# ------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def con(self) -> sqlite3.Connection:
|
|
153
|
+
"""Lazy SQLite connection (created on first access)."""
|
|
154
|
+
if self._con is None:
|
|
155
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
156
|
+
self._con = sqlite3.connect(
|
|
157
|
+
str(self.db_path),
|
|
158
|
+
check_same_thread=False, # safe: read-heavy; writes serialised by SQLite WAL
|
|
159
|
+
)
|
|
160
|
+
self._con.executescript(_SCHEMA_SQL)
|
|
161
|
+
return self._con
|
|
162
|
+
|
|
163
|
+
def close(self) -> None:
|
|
164
|
+
"""Close the underlying SQLite connection."""
|
|
165
|
+
if self._con is not None:
|
|
166
|
+
self._con.close()
|
|
167
|
+
self._con = None
|
|
168
|
+
|
|
169
|
+
def __enter__(self) -> GraphStore:
|
|
170
|
+
"""Enter the context manager.
|
|
171
|
+
|
|
172
|
+
:return: This :class:`GraphStore` instance.
|
|
173
|
+
"""
|
|
174
|
+
return self
|
|
175
|
+
|
|
176
|
+
def __exit__(self, *_: object) -> None:
|
|
177
|
+
"""Exit the context manager, closing the SQLite connection."""
|
|
178
|
+
self.close()
|
|
179
|
+
|
|
180
|
+
# ------------------------------------------------------------------
|
|
181
|
+
# Write
|
|
182
|
+
# ------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
def clear(self) -> None:
|
|
185
|
+
"""Delete all nodes and edges."""
|
|
186
|
+
self.con.execute("DELETE FROM edges;")
|
|
187
|
+
self.con.execute("DELETE FROM nodes;")
|
|
188
|
+
self.con.commit()
|
|
189
|
+
|
|
190
|
+
def write(
|
|
191
|
+
self,
|
|
192
|
+
nodes: Sequence[Node],
|
|
193
|
+
edges: Sequence[Edge],
|
|
194
|
+
*,
|
|
195
|
+
wipe: bool = False,
|
|
196
|
+
) -> None:
|
|
197
|
+
"""
|
|
198
|
+
Persist a complete graph to SQLite.
|
|
199
|
+
|
|
200
|
+
:param nodes: Node list from :class:`~pycode_kg.graph.CodeGraph`.
|
|
201
|
+
:param edges: Edge list from :class:`~pycode_kg.graph.CodeGraph`.
|
|
202
|
+
:param wipe: If ``True``, clear existing data before writing.
|
|
203
|
+
"""
|
|
204
|
+
if wipe:
|
|
205
|
+
self.clear()
|
|
206
|
+
self._upsert_nodes(nodes)
|
|
207
|
+
self._upsert_edges(edges)
|
|
208
|
+
|
|
209
|
+
def _upsert_nodes(self, nodes: Iterable[Node]) -> None:
|
|
210
|
+
"""Insert or update a batch of nodes in the ``nodes`` table.
|
|
211
|
+
|
|
212
|
+
:param nodes: Iterable of :class:`~pycode_kg.pycodekg.Node` objects to persist.
|
|
213
|
+
"""
|
|
214
|
+
rows = [
|
|
215
|
+
(
|
|
216
|
+
n.id,
|
|
217
|
+
n.kind,
|
|
218
|
+
n.name,
|
|
219
|
+
n.qualname,
|
|
220
|
+
n.module_path,
|
|
221
|
+
n.lineno,
|
|
222
|
+
n.end_lineno,
|
|
223
|
+
n.docstring,
|
|
224
|
+
)
|
|
225
|
+
for n in nodes
|
|
226
|
+
]
|
|
227
|
+
self.con.executemany(
|
|
228
|
+
"""
|
|
229
|
+
INSERT INTO nodes
|
|
230
|
+
(id, kind, name, qualname, module_path, lineno, end_lineno, docstring)
|
|
231
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
232
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
233
|
+
kind=excluded.kind,
|
|
234
|
+
name=excluded.name,
|
|
235
|
+
qualname=excluded.qualname,
|
|
236
|
+
module_path=excluded.module_path,
|
|
237
|
+
lineno=excluded.lineno,
|
|
238
|
+
end_lineno=excluded.end_lineno,
|
|
239
|
+
docstring=excluded.docstring
|
|
240
|
+
""",
|
|
241
|
+
rows,
|
|
242
|
+
)
|
|
243
|
+
self.con.commit()
|
|
244
|
+
|
|
245
|
+
def _upsert_edges(self, edges: Iterable[Edge]) -> None:
|
|
246
|
+
"""Insert or update a batch of edges in the ``edges`` table.
|
|
247
|
+
|
|
248
|
+
:param edges: Iterable of :class:`~pycode_kg.pycodekg.Edge` objects to persist.
|
|
249
|
+
"""
|
|
250
|
+
rows = [
|
|
251
|
+
(
|
|
252
|
+
e.src,
|
|
253
|
+
e.rel,
|
|
254
|
+
e.dst,
|
|
255
|
+
(json.dumps(e.evidence, ensure_ascii=False) if e.evidence is not None else None),
|
|
256
|
+
)
|
|
257
|
+
for e in edges
|
|
258
|
+
]
|
|
259
|
+
self.con.executemany(
|
|
260
|
+
"""
|
|
261
|
+
INSERT INTO edges (src, rel, dst, evidence)
|
|
262
|
+
VALUES (?, ?, ?, ?)
|
|
263
|
+
ON CONFLICT(src, rel, dst) DO UPDATE SET
|
|
264
|
+
evidence=excluded.evidence
|
|
265
|
+
""",
|
|
266
|
+
rows,
|
|
267
|
+
)
|
|
268
|
+
self.con.commit()
|
|
269
|
+
|
|
270
|
+
# ------------------------------------------------------------------
|
|
271
|
+
# Read — single node
|
|
272
|
+
# ------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
def node(self, node_id: str) -> dict | None:
|
|
275
|
+
"""
|
|
276
|
+
Fetch a single node by id.
|
|
277
|
+
|
|
278
|
+
:param node_id: Stable node identifier.
|
|
279
|
+
:return: Node dict or ``None`` if not found.
|
|
280
|
+
"""
|
|
281
|
+
row = self.con.execute(
|
|
282
|
+
"""
|
|
283
|
+
SELECT id, kind, name, qualname, module_path, lineno, end_lineno, docstring
|
|
284
|
+
FROM nodes WHERE id = ?
|
|
285
|
+
""",
|
|
286
|
+
(node_id,),
|
|
287
|
+
).fetchone()
|
|
288
|
+
return _row_to_node(row) if row else None
|
|
289
|
+
|
|
290
|
+
# ------------------------------------------------------------------
|
|
291
|
+
# Read — filtered node lists
|
|
292
|
+
# ------------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
def query_nodes(
|
|
295
|
+
self,
|
|
296
|
+
*,
|
|
297
|
+
kinds: Sequence[str] | None = None,
|
|
298
|
+
module: str | None = None,
|
|
299
|
+
) -> list[dict]:
|
|
300
|
+
"""
|
|
301
|
+
Return nodes matching optional filters.
|
|
302
|
+
|
|
303
|
+
:param kinds: Restrict to these node kinds (e.g. ``["function", "method"]``).
|
|
304
|
+
:param module: Restrict to nodes in this module path (exact match).
|
|
305
|
+
:return: List of node dicts.
|
|
306
|
+
"""
|
|
307
|
+
clauses: list[str] = []
|
|
308
|
+
params: list[object] = []
|
|
309
|
+
|
|
310
|
+
if kinds:
|
|
311
|
+
placeholders = ",".join("?" for _ in kinds)
|
|
312
|
+
clauses.append(f"kind IN ({placeholders})")
|
|
313
|
+
params.extend(kinds)
|
|
314
|
+
|
|
315
|
+
if module is not None:
|
|
316
|
+
clauses.append("module_path = ?")
|
|
317
|
+
params.append(module)
|
|
318
|
+
|
|
319
|
+
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
|
320
|
+
rows = self.con.execute(
|
|
321
|
+
f"""
|
|
322
|
+
SELECT id, kind, name, qualname, module_path, lineno, end_lineno, docstring
|
|
323
|
+
FROM nodes {where}
|
|
324
|
+
ORDER BY module_path, lineno
|
|
325
|
+
""",
|
|
326
|
+
params,
|
|
327
|
+
).fetchall()
|
|
328
|
+
return [_row_to_node(r) for r in rows]
|
|
329
|
+
|
|
330
|
+
# ------------------------------------------------------------------
|
|
331
|
+
# Read — edges
|
|
332
|
+
# ------------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
def edges_within(self, node_ids: set[str]) -> list[dict]:
|
|
335
|
+
"""
|
|
336
|
+
Return all edges where both ``src`` and ``dst`` are in *node_ids*.
|
|
337
|
+
|
|
338
|
+
:param node_ids: Set of node IDs to restrict to.
|
|
339
|
+
:return: List of edge dicts with keys ``src``, ``rel``, ``dst``, ``evidence``.
|
|
340
|
+
"""
|
|
341
|
+
if not node_ids:
|
|
342
|
+
return []
|
|
343
|
+
|
|
344
|
+
self.con.execute("DROP TABLE IF EXISTS _tmp_ids;")
|
|
345
|
+
self.con.execute("CREATE TEMP TABLE _tmp_ids (id TEXT PRIMARY KEY);")
|
|
346
|
+
self.con.executemany("INSERT INTO _tmp_ids (id) VALUES (?)", [(i,) for i in node_ids])
|
|
347
|
+
rows = self.con.execute(
|
|
348
|
+
"""
|
|
349
|
+
SELECT e.src, e.rel, e.dst, e.evidence
|
|
350
|
+
FROM edges e
|
|
351
|
+
JOIN _tmp_ids s ON s.id = e.src
|
|
352
|
+
JOIN _tmp_ids d ON d.id = e.dst
|
|
353
|
+
"""
|
|
354
|
+
).fetchall()
|
|
355
|
+
return [{"src": r[0], "rel": r[1], "dst": r[2], "evidence": r[3]} for r in rows]
|
|
356
|
+
|
|
357
|
+
# ------------------------------------------------------------------
|
|
358
|
+
# Graph traversal
|
|
359
|
+
# ------------------------------------------------------------------
|
|
360
|
+
|
|
361
|
+
def expand(
|
|
362
|
+
self,
|
|
363
|
+
seed_ids: set[str],
|
|
364
|
+
*,
|
|
365
|
+
hop: int = 1,
|
|
366
|
+
rels: tuple[str, ...] = DEFAULT_RELS,
|
|
367
|
+
) -> dict[str, ProvMeta]:
|
|
368
|
+
"""
|
|
369
|
+
Expand the graph from *seed_ids* up to *hop* hops.
|
|
370
|
+
|
|
371
|
+
Returns a mapping from every reachable node ID to its
|
|
372
|
+
:class:`ProvMeta` (minimum hop distance and originating seed).
|
|
373
|
+
|
|
374
|
+
:param seed_ids: Starting node IDs (hop 0).
|
|
375
|
+
:param hop: Maximum number of hops to traverse.
|
|
376
|
+
:param rels: Edge relation types to follow.
|
|
377
|
+
:return: ``{node_id: ProvMeta}`` for all reachable nodes.
|
|
378
|
+
"""
|
|
379
|
+
rels = tuple(rels)
|
|
380
|
+
meta: dict[str, ProvMeta] = {sid: ProvMeta(best_hop=0, via_seed=sid) for sid in seed_ids}
|
|
381
|
+
frontier: set[str] = set(seed_ids)
|
|
382
|
+
|
|
383
|
+
for h in range(1, hop + 1):
|
|
384
|
+
nxt: set[str] = set()
|
|
385
|
+
for nid in frontier:
|
|
386
|
+
rows = self.con.execute(
|
|
387
|
+
f"""
|
|
388
|
+
SELECT src, dst FROM edges
|
|
389
|
+
WHERE (src = ? OR dst = ?)
|
|
390
|
+
AND rel IN ({",".join("?" for _ in rels)})
|
|
391
|
+
""",
|
|
392
|
+
(nid, nid, *rels),
|
|
393
|
+
).fetchall()
|
|
394
|
+
for src, dst in rows:
|
|
395
|
+
for cand in (src, dst):
|
|
396
|
+
if cand not in meta:
|
|
397
|
+
meta[cand] = ProvMeta(
|
|
398
|
+
best_hop=h,
|
|
399
|
+
via_seed=meta[nid].via_seed,
|
|
400
|
+
)
|
|
401
|
+
nxt.add(cand)
|
|
402
|
+
elif h < meta[cand].best_hop:
|
|
403
|
+
meta[cand] = ProvMeta(
|
|
404
|
+
best_hop=h,
|
|
405
|
+
via_seed=meta[nid].via_seed,
|
|
406
|
+
)
|
|
407
|
+
nxt.add(cand)
|
|
408
|
+
frontier = nxt
|
|
409
|
+
|
|
410
|
+
return meta
|
|
411
|
+
|
|
412
|
+
# ------------------------------------------------------------------
|
|
413
|
+
# Symbol resolution
|
|
414
|
+
# ------------------------------------------------------------------
|
|
415
|
+
|
|
416
|
+
def resolve_symbols(self) -> int:
|
|
417
|
+
"""
|
|
418
|
+
Add ``RESOLVES_TO`` edges from ``sym:`` stub nodes to their
|
|
419
|
+
first-party definitions (``fn:``, ``cls:``, ``m:``, ``mod:``).
|
|
420
|
+
|
|
421
|
+
The AST visitor emits ``CALLS → sym:Foo`` for any call whose target
|
|
422
|
+
cannot be resolved at walk time (imported names, attribute accesses,
|
|
423
|
+
etc.). This post-build step links those stubs to the canonical
|
|
424
|
+
definition nodes by matching on the ``name`` field, making fan-in
|
|
425
|
+
queries (who calls X?) work correctly via graph traversal.
|
|
426
|
+
|
|
427
|
+
Matching is by name only, so a ``sym:close`` stub will acquire
|
|
428
|
+
``RESOLVES_TO`` edges to *every* in-repo node named ``close``.
|
|
429
|
+
Agents can use the graph context to disambiguate. The operation is
|
|
430
|
+
idempotent — duplicate edges are silently ignored.
|
|
431
|
+
|
|
432
|
+
:return: Number of new ``RESOLVES_TO`` edges written.
|
|
433
|
+
"""
|
|
434
|
+
sym_rows = self.con.execute(
|
|
435
|
+
"SELECT id, name, qualname FROM nodes WHERE kind = 'symbol'"
|
|
436
|
+
).fetchall()
|
|
437
|
+
|
|
438
|
+
def_rows = self.con.execute(
|
|
439
|
+
"""
|
|
440
|
+
SELECT id, kind, name, qualname, module_path
|
|
441
|
+
FROM nodes
|
|
442
|
+
WHERE kind != 'symbol' AND name IS NOT NULL
|
|
443
|
+
"""
|
|
444
|
+
).fetchall()
|
|
445
|
+
|
|
446
|
+
name_to_defs: dict[str, list[str]] = {}
|
|
447
|
+
dotted_to_defs: dict[str, list[str]] = {}
|
|
448
|
+
for def_id, def_kind, def_name, def_qualname, def_module_path in def_rows:
|
|
449
|
+
name_to_defs.setdefault(def_name, []).append(def_id)
|
|
450
|
+
|
|
451
|
+
module_dotted_variants = _module_to_dotted_variants(def_module_path)
|
|
452
|
+
if def_kind == "module":
|
|
453
|
+
for module_dotted in module_dotted_variants:
|
|
454
|
+
dotted_to_defs.setdefault(module_dotted, []).append(def_id)
|
|
455
|
+
elif module_dotted_variants and def_qualname:
|
|
456
|
+
for module_dotted in module_dotted_variants:
|
|
457
|
+
dotted_key = f"{module_dotted}.{def_qualname}"
|
|
458
|
+
dotted_to_defs.setdefault(dotted_key, []).append(def_id)
|
|
459
|
+
|
|
460
|
+
edges: list[tuple[str, str, str, str]] = []
|
|
461
|
+
for sym_id, sym_name, sym_qualname in sym_rows:
|
|
462
|
+
if not sym_name:
|
|
463
|
+
continue
|
|
464
|
+
|
|
465
|
+
candidates: list[str] = []
|
|
466
|
+
mode = "name_fallback"
|
|
467
|
+
confidence = "medium"
|
|
468
|
+
|
|
469
|
+
if sym_qualname and "." in sym_qualname:
|
|
470
|
+
exact = dotted_to_defs.get(sym_qualname, [])
|
|
471
|
+
if exact:
|
|
472
|
+
candidates = exact
|
|
473
|
+
mode = "exact_qualname"
|
|
474
|
+
confidence = "high"
|
|
475
|
+
|
|
476
|
+
if not candidates:
|
|
477
|
+
candidates = name_to_defs.get(sym_name, [])
|
|
478
|
+
if len(candidates) > 1:
|
|
479
|
+
mode = "name_fallback_ambiguous"
|
|
480
|
+
confidence = "low"
|
|
481
|
+
|
|
482
|
+
for def_id in candidates:
|
|
483
|
+
evidence = {
|
|
484
|
+
"resolution_mode": mode,
|
|
485
|
+
"confidence": confidence,
|
|
486
|
+
"symbol": sym_id,
|
|
487
|
+
}
|
|
488
|
+
if mode == "name_fallback_ambiguous":
|
|
489
|
+
evidence["candidate_count"] = len(candidates)
|
|
490
|
+
edges.append(
|
|
491
|
+
(
|
|
492
|
+
sym_id,
|
|
493
|
+
"RESOLVES_TO",
|
|
494
|
+
def_id,
|
|
495
|
+
json.dumps(evidence, ensure_ascii=False),
|
|
496
|
+
)
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
if edges:
|
|
500
|
+
self.con.executemany(
|
|
501
|
+
"""
|
|
502
|
+
INSERT INTO edges (src, rel, dst, evidence)
|
|
503
|
+
VALUES (?, ?, ?, ?)
|
|
504
|
+
ON CONFLICT(src, rel, dst) DO UPDATE SET
|
|
505
|
+
evidence=excluded.evidence
|
|
506
|
+
""",
|
|
507
|
+
edges,
|
|
508
|
+
)
|
|
509
|
+
self.con.commit()
|
|
510
|
+
|
|
511
|
+
return len(edges)
|
|
512
|
+
|
|
513
|
+
# ------------------------------------------------------------------
|
|
514
|
+
# Caller lookup (fan-in)
|
|
515
|
+
# ------------------------------------------------------------------
|
|
516
|
+
|
|
517
|
+
def callers_of(self, node_id: str, *, rel: str = "CALLS") -> list[dict]:
|
|
518
|
+
"""
|
|
519
|
+
Return all nodes that have a *rel* edge pointing at *node_id*,
|
|
520
|
+
including cross-module callers that reference it via ``sym:`` stubs.
|
|
521
|
+
|
|
522
|
+
Two-phase lookup:
|
|
523
|
+
|
|
524
|
+
1. Direct: ``edges WHERE dst = node_id AND rel = rel``
|
|
525
|
+
2. Indirect: find all ``sym:`` stubs with a ``RESOLVES_TO`` edge to
|
|
526
|
+
*node_id*, then collect their incoming *rel* edges.
|
|
527
|
+
|
|
528
|
+
Caller nodes are deduplicated and returned as node dicts.
|
|
529
|
+
|
|
530
|
+
:param node_id: Target node identifier (e.g. ``fn:src/foo.py:bar``).
|
|
531
|
+
:param rel: Relation type to invert (default ``"CALLS"``).
|
|
532
|
+
:return: List of caller node dicts, deduplicated.
|
|
533
|
+
"""
|
|
534
|
+
direct = self.con.execute(
|
|
535
|
+
"SELECT src, evidence FROM edges WHERE dst = ? AND rel = ?",
|
|
536
|
+
(node_id, rel),
|
|
537
|
+
).fetchall()
|
|
538
|
+
|
|
539
|
+
stubs = self.con.execute(
|
|
540
|
+
"SELECT src FROM edges WHERE dst = ? AND rel = 'RESOLVES_TO'",
|
|
541
|
+
(node_id,),
|
|
542
|
+
).fetchall()
|
|
543
|
+
|
|
544
|
+
stub_callers: list[tuple[str, str | None, str]] = []
|
|
545
|
+
for (stub_id,) in stubs:
|
|
546
|
+
rows = self.con.execute(
|
|
547
|
+
"SELECT src, evidence FROM edges WHERE dst = ? AND rel = ?",
|
|
548
|
+
(stub_id, rel),
|
|
549
|
+
).fetchall()
|
|
550
|
+
for src_id, evidence in rows:
|
|
551
|
+
stub_callers.append((src_id, evidence, stub_id))
|
|
552
|
+
|
|
553
|
+
seen: set[str] = set()
|
|
554
|
+
result: list[dict] = []
|
|
555
|
+
for caller_id, evidence in direct:
|
|
556
|
+
if caller_id in seen:
|
|
557
|
+
continue
|
|
558
|
+
seen.add(caller_id)
|
|
559
|
+
n = self.node(caller_id)
|
|
560
|
+
if n:
|
|
561
|
+
lineno = _parse_call_site_lineno(evidence)
|
|
562
|
+
if lineno is not None:
|
|
563
|
+
n = {**n, "call_site_lineno": lineno}
|
|
564
|
+
result.append(n)
|
|
565
|
+
|
|
566
|
+
for caller_id, evidence, _stub_id in stub_callers:
|
|
567
|
+
if caller_id in seen:
|
|
568
|
+
continue
|
|
569
|
+
if not self._is_compatible_stub_caller(caller_id, evidence, node_id):
|
|
570
|
+
continue
|
|
571
|
+
seen.add(caller_id)
|
|
572
|
+
n = self.node(caller_id)
|
|
573
|
+
if n:
|
|
574
|
+
lineno = _parse_call_site_lineno(evidence)
|
|
575
|
+
if lineno is not None:
|
|
576
|
+
n = {**n, "call_site_lineno": lineno}
|
|
577
|
+
result.append(n)
|
|
578
|
+
|
|
579
|
+
return result
|
|
580
|
+
|
|
581
|
+
def _is_compatible_stub_caller(
|
|
582
|
+
self, caller_id: str, evidence: str | None, target_node_id: str
|
|
583
|
+
) -> bool:
|
|
584
|
+
"""Validate a stub-resolved caller against import hints from its module.
|
|
585
|
+
|
|
586
|
+
If the call expression name is imported in the caller's module, retain
|
|
587
|
+
the caller only when one such import symbol resolves to ``target_node_id``.
|
|
588
|
+
When no import hints exist, caller is kept (no extra signal available).
|
|
589
|
+
|
|
590
|
+
:param caller_id: Caller node ID from an incoming CALLS edge.
|
|
591
|
+
:param evidence: JSON-encoded edge evidence from the CALLS edge.
|
|
592
|
+
:param target_node_id: Target node currently being reverse-resolved.
|
|
593
|
+
:return: ``True`` if caller is compatible with target resolution.
|
|
594
|
+
"""
|
|
595
|
+
if not evidence:
|
|
596
|
+
return True
|
|
597
|
+
try:
|
|
598
|
+
ev = json.loads(evidence)
|
|
599
|
+
except (TypeError, json.JSONDecodeError):
|
|
600
|
+
return True
|
|
601
|
+
|
|
602
|
+
expr = ev.get("expr")
|
|
603
|
+
if not isinstance(expr, str) or not expr:
|
|
604
|
+
return True
|
|
605
|
+
|
|
606
|
+
short_name = expr.split(".")[-1]
|
|
607
|
+
caller_node = self.node(caller_id)
|
|
608
|
+
if not caller_node:
|
|
609
|
+
return True
|
|
610
|
+
|
|
611
|
+
module_path = caller_node.get("module_path")
|
|
612
|
+
if not module_path:
|
|
613
|
+
return True
|
|
614
|
+
|
|
615
|
+
module_id = node_id("module", module_path, None)
|
|
616
|
+
import_rows = self.con.execute(
|
|
617
|
+
"SELECT dst FROM edges WHERE src = ? AND rel = 'IMPORTS'",
|
|
618
|
+
(module_id,),
|
|
619
|
+
).fetchall()
|
|
620
|
+
|
|
621
|
+
matching_import_symbols = [
|
|
622
|
+
sym_id
|
|
623
|
+
for (sym_id,) in import_rows
|
|
624
|
+
if sym_id.startswith("sym:") and sym_id.split(".")[-1] == short_name
|
|
625
|
+
]
|
|
626
|
+
if not matching_import_symbols:
|
|
627
|
+
return True
|
|
628
|
+
|
|
629
|
+
for sym_id in matching_import_symbols:
|
|
630
|
+
hit = self.con.execute(
|
|
631
|
+
"SELECT 1 FROM edges WHERE src = ? AND rel = 'RESOLVES_TO' AND dst = ? LIMIT 1",
|
|
632
|
+
(sym_id, target_node_id),
|
|
633
|
+
).fetchone()
|
|
634
|
+
if hit:
|
|
635
|
+
return True
|
|
636
|
+
return False
|
|
637
|
+
|
|
638
|
+
def edges_from(
|
|
639
|
+
self, node_id: str, *, rel: str = "CALLS", limit: int | None = None
|
|
640
|
+
) -> list[dict]:
|
|
641
|
+
"""
|
|
642
|
+
Return all edges originating from *node_id* with the given relation type.
|
|
643
|
+
|
|
644
|
+
:param node_id: Source node identifier (e.g. ``fn:src/foo.py:bar``).
|
|
645
|
+
:param rel: Relation type to match (default ``"CALLS"``).
|
|
646
|
+
:param limit: Maximum number of edges to return (``None`` for unlimited).
|
|
647
|
+
:return: List of edge dicts with keys ``src``, ``rel``, ``dst``, ``evidence``.
|
|
648
|
+
"""
|
|
649
|
+
query = "SELECT src, rel, dst, evidence FROM edges WHERE src = ? AND rel = ?"
|
|
650
|
+
params: list[object] = [node_id, rel]
|
|
651
|
+
if limit is not None:
|
|
652
|
+
query += " LIMIT ?"
|
|
653
|
+
params.append(limit)
|
|
654
|
+
rows = self.con.execute(query, params).fetchall()
|
|
655
|
+
return [{"src": r[0], "rel": r[1], "dst": r[2], "evidence": r[3]} for r in rows]
|
|
656
|
+
|
|
657
|
+
# ------------------------------------------------------------------
|
|
658
|
+
# Stats
|
|
659
|
+
# ------------------------------------------------------------------
|
|
660
|
+
|
|
661
|
+
def stats(self) -> dict:
|
|
662
|
+
"""
|
|
663
|
+
Return node and edge counts by kind/relation, plus domain-specific metrics.
|
|
664
|
+
|
|
665
|
+
``meaningful_nodes`` excludes ``sym:`` infrastructure stubs so callers
|
|
666
|
+
get an accurate count of real code entities (modules, classes,
|
|
667
|
+
functions, methods).
|
|
668
|
+
|
|
669
|
+
``docstring_coverage`` is the fraction of ``fn`` and ``method`` nodes
|
|
670
|
+
that carry a non-empty docstring (0.0–1.0).
|
|
671
|
+
|
|
672
|
+
``snapshot_count`` is the number of entries in the snapshot manifest
|
|
673
|
+
adjacent to the database file (``<db_dir>/snapshots/manifest.json``);
|
|
674
|
+
returns 0 when no manifest exists.
|
|
675
|
+
|
|
676
|
+
:return: dict with ``total_nodes``, ``meaningful_nodes``,
|
|
677
|
+
``total_edges``, ``node_counts``, ``edge_counts``,
|
|
678
|
+
``module_count``, ``class_count``, ``function_count``,
|
|
679
|
+
``method_count``, ``docstring_coverage``, ``snapshot_count``.
|
|
680
|
+
"""
|
|
681
|
+
node_rows = self.con.execute("SELECT kind, COUNT(*) FROM nodes GROUP BY kind").fetchall()
|
|
682
|
+
edge_rows = self.con.execute("SELECT rel, COUNT(*) FROM edges GROUP BY rel").fetchall()
|
|
683
|
+
total_nodes = self.con.execute("SELECT COUNT(*) FROM nodes").fetchone()[0]
|
|
684
|
+
total_edges = self.con.execute("SELECT COUNT(*) FROM edges").fetchone()[0]
|
|
685
|
+
|
|
686
|
+
node_counts: dict[str, int] = {r[0]: r[1] for r in node_rows}
|
|
687
|
+
symbol_count = node_counts.get("symbol", 0)
|
|
688
|
+
|
|
689
|
+
fn_count = node_counts.get("function", 0)
|
|
690
|
+
method_count = node_counts.get("method", 0)
|
|
691
|
+
fn_method_total = fn_count + method_count
|
|
692
|
+
with_doc = self.con.execute(
|
|
693
|
+
"SELECT COUNT(*) FROM nodes"
|
|
694
|
+
" WHERE kind IN ('function','method')"
|
|
695
|
+
" AND docstring IS NOT NULL AND docstring != ''"
|
|
696
|
+
).fetchone()[0]
|
|
697
|
+
|
|
698
|
+
snapshot_count = 0
|
|
699
|
+
manifest_path = self.db_path.parent / "snapshots" / "manifest.json"
|
|
700
|
+
if manifest_path.exists():
|
|
701
|
+
try:
|
|
702
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
703
|
+
snapshot_count = len(manifest.get("snapshots", []))
|
|
704
|
+
except (OSError, ValueError, KeyError):
|
|
705
|
+
pass
|
|
706
|
+
|
|
707
|
+
return {
|
|
708
|
+
"db_path": str(self.db_path),
|
|
709
|
+
"total_nodes": total_nodes,
|
|
710
|
+
"meaningful_nodes": total_nodes - symbol_count,
|
|
711
|
+
"total_edges": total_edges,
|
|
712
|
+
"node_counts": node_counts,
|
|
713
|
+
"edge_counts": {r[0]: r[1] for r in edge_rows},
|
|
714
|
+
"module_count": node_counts.get("module", 0),
|
|
715
|
+
"class_count": node_counts.get("class", 0),
|
|
716
|
+
"function_count": fn_count,
|
|
717
|
+
"method_count": method_count,
|
|
718
|
+
"docstring_coverage": round(with_doc / fn_method_total, 3) if fn_method_total else 0.0,
|
|
719
|
+
"snapshot_count": snapshot_count,
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
def __repr__(self) -> str:
|
|
723
|
+
"""Return a developer-readable representation of this GraphStore.
|
|
724
|
+
|
|
725
|
+
:return: String of the form ``GraphStore(db_path=...)``.
|
|
726
|
+
"""
|
|
727
|
+
return f"GraphStore(db_path={self.db_path!r})"
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
# ---------------------------------------------------------------------------
|
|
731
|
+
# Internal helpers
|
|
732
|
+
# ---------------------------------------------------------------------------
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _parse_call_site_lineno(evidence: str | None) -> int | None:
|
|
736
|
+
"""Parse and extract the call-site line number from a JSON evidence string.
|
|
737
|
+
|
|
738
|
+
:param evidence: JSON-encoded edge evidence, e.g. ``'{"lineno": 42, "expr": "foo"}'``.
|
|
739
|
+
:return: Line number integer, or ``None`` if unavailable or unparseable.
|
|
740
|
+
"""
|
|
741
|
+
if not evidence:
|
|
742
|
+
return None
|
|
743
|
+
try:
|
|
744
|
+
ev = json.loads(evidence)
|
|
745
|
+
lineno = ev.get("lineno")
|
|
746
|
+
return int(lineno) if lineno is not None else None
|
|
747
|
+
except (TypeError, ValueError, json.JSONDecodeError):
|
|
748
|
+
return None
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def _row_to_node(row: tuple) -> dict:
|
|
752
|
+
"""Convert a raw SQLite row into a node dict.
|
|
753
|
+
|
|
754
|
+
:param row: Tuple of ``(id, kind, name, qualname, module_path, lineno, end_lineno, docstring)``.
|
|
755
|
+
:return: Dict with the same keys suitable for JSON serialisation.
|
|
756
|
+
"""
|
|
757
|
+
return {
|
|
758
|
+
"id": row[0],
|
|
759
|
+
"kind": row[1],
|
|
760
|
+
"name": row[2],
|
|
761
|
+
"qualname": row[3],
|
|
762
|
+
"module_path": row[4],
|
|
763
|
+
"lineno": row[5],
|
|
764
|
+
"end_lineno": row[6],
|
|
765
|
+
"docstring": row[7],
|
|
766
|
+
}
|