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.
Files changed (63) hide show
  1. pycode_kg/.DS_Store +0 -0
  2. pycode_kg/__init__.py +91 -0
  3. pycode_kg/__main__.py +11 -0
  4. pycode_kg/analysis/__init__.py +15 -0
  5. pycode_kg/analysis/bridge.py +108 -0
  6. pycode_kg/analysis/centrality.py +412 -0
  7. pycode_kg/analysis/framework_detector.py +103 -0
  8. pycode_kg/analysis/hybrid_rank.py +53 -0
  9. pycode_kg/app.py +1335 -0
  10. pycode_kg/architecture.py +624 -0
  11. pycode_kg/build_pycodekg_lancedb.py +15 -0
  12. pycode_kg/build_pycodekg_sqlite.py +15 -0
  13. pycode_kg/cli/__init__.py +29 -0
  14. pycode_kg/cli/cmd_analyze.py +96 -0
  15. pycode_kg/cli/cmd_architecture.py +150 -0
  16. pycode_kg/cli/cmd_bridges.py +26 -0
  17. pycode_kg/cli/cmd_build.py +154 -0
  18. pycode_kg/cli/cmd_build_full.py +242 -0
  19. pycode_kg/cli/cmd_centrality.py +131 -0
  20. pycode_kg/cli/cmd_explain.py +180 -0
  21. pycode_kg/cli/cmd_framework_nodes.py +18 -0
  22. pycode_kg/cli/cmd_hooks.py +137 -0
  23. pycode_kg/cli/cmd_init.py +312 -0
  24. pycode_kg/cli/cmd_mcp.py +71 -0
  25. pycode_kg/cli/cmd_model.py +53 -0
  26. pycode_kg/cli/cmd_query.py +211 -0
  27. pycode_kg/cli/cmd_snapshot.py +421 -0
  28. pycode_kg/cli/cmd_viz.py +180 -0
  29. pycode_kg/cli/main.py +23 -0
  30. pycode_kg/cli/options.py +63 -0
  31. pycode_kg/config.py +78 -0
  32. pycode_kg/graph.py +125 -0
  33. pycode_kg/index.py +542 -0
  34. pycode_kg/kg.py +220 -0
  35. pycode_kg/layout3d.py +470 -0
  36. pycode_kg/mcp/bridge_tools.py +19 -0
  37. pycode_kg/mcp/framework_tools.py +18 -0
  38. pycode_kg/mcp_server.py +1965 -0
  39. pycode_kg/module/__init__.py +83 -0
  40. pycode_kg/module/base.py +720 -0
  41. pycode_kg/module/extractor.py +276 -0
  42. pycode_kg/module/types.py +532 -0
  43. pycode_kg/pycodekg.py +543 -0
  44. pycode_kg/pycodekg_query.py +1 -0
  45. pycode_kg/pycodekg_snippet_packer.py +1 -0
  46. pycode_kg/pycodekg_thorough_analysis.py +2751 -0
  47. pycode_kg/pycodekg_viz.py +1 -0
  48. pycode_kg/pycodekg_viz3d.py +1 -0
  49. pycode_kg/ranking/__init__.py +1 -0
  50. pycode_kg/ranking/cli_rank.py +92 -0
  51. pycode_kg/ranking/coderank.py +555 -0
  52. pycode_kg/snapshots.py +612 -0
  53. pycode_kg/sql/004_add_centrality_table.sql +12 -0
  54. pycode_kg/store.py +766 -0
  55. pycode_kg/utils.py +39 -0
  56. pycode_kg/visitor.py +413 -0
  57. pycode_kg/viz3d.py +1353 -0
  58. pycode_kg/viz3d_timeline.py +364 -0
  59. pycode_kg-0.16.0.dist-info/METADATA +305 -0
  60. pycode_kg-0.16.0.dist-info/RECORD +63 -0
  61. pycode_kg-0.16.0.dist-info/WHEEL +4 -0
  62. pycode_kg-0.16.0.dist-info/entry_points.txt +19 -0
  63. 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
+ }