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/snapshots.py ADDED
@@ -0,0 +1,612 @@
1
+ """
2
+ snapshots.py — Temporal Snapshots of PyCodeKG Metrics
3
+
4
+ Thin compatibility layer over ``kg_snapshot``. The shared module provides
5
+ the canonical ``Snapshot``, ``SnapshotManifest``, and ``SnapshotManager``
6
+ implementations. This module:
7
+
8
+ - Re-exports ``Snapshot`` and ``SnapshotManifest`` from ``kg_snapshot``
9
+ for backwards compatibility.
10
+ - Keeps domain-specific ``SnapshotMetrics`` and ``SnapshotDelta`` dataclasses
11
+ (used by the CLI and tests) as local types.
12
+ - Provides ``metrics_to_dict`` / ``metrics_from_dict`` helpers to convert
13
+ between the dataclass representation and the dict-based shared model.
14
+ - Subclasses ``SnapshotManager`` to set ``package_name="pycode-kg"``, override
15
+ ``_compute_delta`` / ``_compute_delta_from_metrics`` with the
16
+ pycode-kg-specific delta fields, and expose ``_collect_module_node_counts``.
17
+
18
+ Existing ``from pycode_kg.snapshots import ...`` call-sites continue to work
19
+ unchanged.
20
+
21
+ Usage
22
+ -----
23
+ >>> from pycode_kg.snapshots import SnapshotManager
24
+ >>> mgr = SnapshotManager(".pycodekg/snapshots")
25
+ >>> snapshot = mgr.capture("v0.5.1", "develop", graph_stats_dict)
26
+ >>> mgr.save_snapshot(snapshot)
27
+ >>> manifest = mgr.load_manifest()
28
+ >>> prev = mgr.get_previous(tree_hash)
29
+
30
+ Author: Eric G. Suchanek, PhD
31
+ Last Revision: 2026-04-07 09:13:36
32
+
33
+ License: Elastic 2.0
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import json
39
+ import sqlite3
40
+ from dataclasses import dataclass, field
41
+ from datetime import UTC, datetime
42
+ from pathlib import Path
43
+ from typing import Any
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Re-export shared data models
47
+ # ---------------------------------------------------------------------------
48
+ from kg_snapshot.snapshots import PruneResult as PruneResult # noqa: F401 — re-export
49
+ from kg_snapshot.snapshots import Snapshot as _BaseSnapshot
50
+ from kg_snapshot.snapshots import SnapshotManager as _BaseSnapshotManager
51
+ from kg_snapshot.snapshots import ( # noqa: F401 — re-export
52
+ SnapshotManifest as SnapshotManifest,
53
+ )
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # Domain-specific dataclasses (used by cmd_snapshot.py and tests)
57
+ # ---------------------------------------------------------------------------
58
+
59
+
60
+ @dataclass
61
+ class SnapshotMetrics:
62
+ """Core metrics captured in a pycode-kg snapshot."""
63
+
64
+ total_nodes: int
65
+ total_edges: int
66
+ meaningful_nodes: int
67
+ docstring_coverage: float # 0.0 to 1.0
68
+ node_counts: dict[str, int]
69
+ edge_counts: dict[str, int]
70
+ critical_issues: int # number of critical issues found
71
+ complexity_median: float # median fan-in across functions
72
+ module_node_counts: dict[str, int] = field(default_factory=dict) # nodes per module path
73
+
74
+
75
+ @dataclass
76
+ class SnapshotDelta:
77
+ """Deltas comparing this snapshot to a baseline or previous snapshot."""
78
+
79
+ nodes: int = 0
80
+ edges: int = 0
81
+ coverage_delta: float = 0.0
82
+ critical_issues_delta: int = 0
83
+
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # Conversion helpers
87
+ # ---------------------------------------------------------------------------
88
+
89
+
90
+ def metrics_to_dict(metrics: SnapshotMetrics) -> dict[str, Any]:
91
+ """Convert a ``SnapshotMetrics`` dataclass to a plain dict for storage."""
92
+ return {
93
+ "total_nodes": metrics.total_nodes,
94
+ "total_edges": metrics.total_edges,
95
+ "meaningful_nodes": metrics.meaningful_nodes,
96
+ "docstring_coverage": metrics.docstring_coverage,
97
+ "node_counts": metrics.node_counts,
98
+ "edge_counts": metrics.edge_counts,
99
+ "critical_issues": metrics.critical_issues,
100
+ "complexity_median": metrics.complexity_median,
101
+ "module_node_counts": metrics.module_node_counts,
102
+ }
103
+
104
+
105
+ def metrics_from_dict(data: dict[str, Any]) -> SnapshotMetrics:
106
+ """Reconstruct a ``SnapshotMetrics`` from a plain dict (e.g. loaded from JSON)."""
107
+ return SnapshotMetrics(
108
+ total_nodes=data.get("total_nodes", 0),
109
+ total_edges=data.get("total_edges", 0),
110
+ meaningful_nodes=data.get("meaningful_nodes", 0),
111
+ docstring_coverage=data.get("docstring_coverage", 0.0),
112
+ node_counts=data.get("node_counts", {}),
113
+ edge_counts=data.get("edge_counts", {}),
114
+ critical_issues=data.get("critical_issues", 0),
115
+ complexity_median=data.get("complexity_median", 0.0),
116
+ module_node_counts=data.get("module_node_counts", {}),
117
+ )
118
+
119
+
120
+ def delta_to_dict(delta: SnapshotDelta) -> dict[str, Any]:
121
+ """Convert a ``SnapshotDelta`` dataclass to a plain dict for storage."""
122
+ return {
123
+ "nodes": delta.nodes,
124
+ "edges": delta.edges,
125
+ "coverage_delta": delta.coverage_delta,
126
+ "critical_issues_delta": delta.critical_issues_delta,
127
+ }
128
+
129
+
130
+ def delta_from_dict(data: dict[str, Any] | None) -> SnapshotDelta | None:
131
+ """Reconstruct a ``SnapshotDelta`` from a plain dict, or return ``None``."""
132
+ if data is None:
133
+ return None
134
+ return SnapshotDelta(
135
+ nodes=data.get("nodes", 0),
136
+ edges=data.get("edges", 0),
137
+ coverage_delta=data.get("coverage_delta", 0.0),
138
+ critical_issues_delta=data.get("critical_issues_delta", 0),
139
+ )
140
+
141
+
142
+ # ---------------------------------------------------------------------------
143
+ # Backwards-compat Snapshot wrapper
144
+ #
145
+ # The shared kg_rag.snapshots.Snapshot stores metrics as a plain dict and
146
+ # vs_previous/vs_baseline as plain dicts. Legacy pycode-kg callers expect
147
+ # attribute access on these fields (snapshot.metrics.total_nodes, delta.nodes).
148
+ #
149
+ # We subclass Snapshot to override attribute access so that .metrics returns a
150
+ # SnapshotMetrics view and .vs_previous / .vs_baseline return SnapshotDelta
151
+ # views, while keeping the underlying dict storage for JSON serialization.
152
+ # ---------------------------------------------------------------------------
153
+
154
+
155
+ class Snapshot(_BaseSnapshot):
156
+ """pycode-kg Snapshot with attribute-accessible metrics and delta fields.
157
+
158
+ Subclasses the shared ``kg_rag.snapshots.Snapshot`` so that:
159
+
160
+ - ``snapshot.metrics`` returns a :class:`SnapshotMetrics` dataclass built
161
+ lazily from the underlying metrics dict.
162
+ - ``snapshot.vs_previous`` / ``snapshot.vs_baseline`` return
163
+ :class:`SnapshotDelta` dataclasses (or ``None``) built lazily from the
164
+ underlying delta dicts.
165
+ - All serialization goes through the shared ``to_dict`` / ``from_dict``
166
+ which store plain dicts, ensuring on-disk format compatibility.
167
+ """
168
+
169
+ # Class-level type declarations for the raw storage attributes
170
+ _metrics_raw: dict[str, Any]
171
+ _vs_previous_raw: dict[str, Any] | None
172
+ _vs_baseline_raw: dict[str, Any] | None
173
+
174
+ # ------------------------------------------------------------------
175
+ # Attribute-access shims
176
+ # ------------------------------------------------------------------
177
+
178
+ @property # type: ignore[override]
179
+ def metrics(self) -> SnapshotMetrics: # type: ignore[override]
180
+ return metrics_from_dict(self._metrics_raw)
181
+
182
+ @metrics.setter
183
+ def metrics(self, value: SnapshotMetrics | dict[str, Any]) -> None:
184
+ self._metrics_raw = metrics_to_dict(value) if isinstance(value, SnapshotMetrics) else value
185
+
186
+ @property # type: ignore[override]
187
+ def vs_previous(self) -> SnapshotDelta | None: # type: ignore[override]
188
+ return delta_from_dict(self._vs_previous_raw)
189
+
190
+ @vs_previous.setter
191
+ def vs_previous(self, value: SnapshotDelta | dict[str, Any] | None) -> None:
192
+ self._vs_previous_raw = delta_to_dict(value) if isinstance(value, SnapshotDelta) else value
193
+
194
+ @property # type: ignore[override]
195
+ def vs_baseline(self) -> SnapshotDelta | None: # type: ignore[override]
196
+ return delta_from_dict(self._vs_baseline_raw)
197
+
198
+ @vs_baseline.setter
199
+ def vs_baseline(self, value: SnapshotDelta | dict[str, Any] | None) -> None:
200
+ self._vs_baseline_raw = delta_to_dict(value) if isinstance(value, SnapshotDelta) else value
201
+
202
+ # ------------------------------------------------------------------
203
+ # __init__: store raw dicts, pass them to super
204
+ # ------------------------------------------------------------------
205
+
206
+ def __init__(
207
+ self,
208
+ branch: str,
209
+ timestamp: str,
210
+ metrics: SnapshotMetrics | dict[str, Any],
211
+ version: str = "",
212
+ hotspots: list[dict[str, Any]] | None = None,
213
+ issues: list[str] | None = None,
214
+ vs_previous: SnapshotDelta | dict[str, Any] | None = None,
215
+ vs_baseline: SnapshotDelta | dict[str, Any] | None = None,
216
+ tree_hash: str = "",
217
+ ) -> None:
218
+ self._metrics_raw = (
219
+ metrics_to_dict(metrics) if isinstance(metrics, SnapshotMetrics) else (metrics or {})
220
+ )
221
+ self._vs_previous_raw = (
222
+ delta_to_dict(vs_previous) if isinstance(vs_previous, SnapshotDelta) else vs_previous
223
+ )
224
+ self._vs_baseline_raw = (
225
+ delta_to_dict(vs_baseline) if isinstance(vs_baseline, SnapshotDelta) else vs_baseline
226
+ )
227
+ super().__init__(
228
+ branch=branch,
229
+ timestamp=timestamp,
230
+ metrics=self._metrics_raw,
231
+ version=version,
232
+ hotspots=hotspots or [],
233
+ issues=issues or [],
234
+ vs_previous=self._vs_previous_raw,
235
+ vs_baseline=self._vs_baseline_raw,
236
+ tree_hash=tree_hash,
237
+ )
238
+
239
+ # ------------------------------------------------------------------
240
+ # to_dict / from_dict — keep shared dict format on disk
241
+ # ------------------------------------------------------------------
242
+
243
+ def to_dict(self) -> dict[str, Any]:
244
+ """Serialize to JSON-compatible dict (plain dicts for all nested fields)."""
245
+ return {
246
+ "key": self.tree_hash,
247
+ "branch": self.branch,
248
+ "timestamp": self.timestamp,
249
+ "version": self.version,
250
+ "metrics": self._metrics_raw,
251
+ "hotspots": self.hotspots,
252
+ "issues": self.issues,
253
+ "vs_previous": self._vs_previous_raw,
254
+ "vs_baseline": self._vs_baseline_raw,
255
+ }
256
+
257
+ @staticmethod
258
+ def from_dict(data: dict[str, Any]) -> Snapshot:
259
+ """Reconstruct from a dict loaded from JSON."""
260
+ raw = dict(data)
261
+
262
+ metrics_data = raw.pop("metrics", {})
263
+ vs_prev_data = raw.pop("vs_previous", None)
264
+ vs_base_data = raw.pop("vs_baseline", None)
265
+
266
+ # Normalise legacy 'tree_hash' field → 'key'
267
+ if "key" not in raw and "tree_hash" in raw:
268
+ raw["key"] = raw.pop("tree_hash")
269
+ else:
270
+ raw.pop("tree_hash", None)
271
+
272
+ key = raw.pop("key", "")
273
+ raw.pop("commit", None) # drop legacy field
274
+ raw.setdefault("version", "")
275
+
276
+ return Snapshot(
277
+ tree_hash=key,
278
+ metrics=metrics_data,
279
+ vs_previous=vs_prev_data,
280
+ vs_baseline=vs_base_data,
281
+ branch=raw.pop("branch", ""),
282
+ timestamp=raw.pop("timestamp", ""),
283
+ version=raw.pop("version", ""),
284
+ hotspots=raw.pop("hotspots", []),
285
+ issues=raw.pop("issues", []),
286
+ )
287
+
288
+
289
+ # ---------------------------------------------------------------------------
290
+ # pycode-kg SnapshotManager subclass
291
+ # ---------------------------------------------------------------------------
292
+
293
+
294
+ class SnapshotManager(_BaseSnapshotManager):
295
+ """pycode-kg specific snapshot manager.
296
+
297
+ Extends the shared :class:`kg_rag.snapshots.SnapshotManager` with:
298
+
299
+ - ``package_name="pycode-kg"`` for automatic version detection.
300
+ - Domain-specific delta fields: ``coverage_delta`` and
301
+ ``critical_issues_delta`` in addition to the base ``nodes`` / ``edges``.
302
+ - ``_collect_module_node_counts()`` — SQLite query for per-module node
303
+ counts, stored in snapshot metrics under ``"module_node_counts"``.
304
+
305
+ The ``capture()`` signature is extended with named keyword arguments
306
+ (``coverage``, ``critical_issues``, ``complexity_median``) so that
307
+ existing call-sites in the CLI do not need to be changed.
308
+ """
309
+
310
+ def __init__(
311
+ self,
312
+ snapshots_dir: Path | str,
313
+ *,
314
+ db_path: Path | str | None = None,
315
+ package_name: str = "pycode-kg",
316
+ ) -> None:
317
+ super().__init__(snapshots_dir, package_name=package_name, db_path=db_path)
318
+
319
+ # ------------------------------------------------------------------
320
+ # Helper: wrap a base Snapshot in the local subclass
321
+ # ------------------------------------------------------------------
322
+
323
+ @staticmethod
324
+ def _wrap_snapshot(base: _BaseSnapshot) -> Snapshot:
325
+ return Snapshot(
326
+ branch=base.branch,
327
+ timestamp=base.timestamp,
328
+ version=base.version,
329
+ metrics=base.metrics,
330
+ hotspots=base.hotspots,
331
+ issues=base.issues,
332
+ vs_previous=base.vs_previous,
333
+ vs_baseline=base.vs_baseline,
334
+ tree_hash=base.tree_hash,
335
+ )
336
+
337
+ # ------------------------------------------------------------------
338
+ # capture — extended signature for backwards compat
339
+ # ------------------------------------------------------------------
340
+
341
+ def capture( # type: ignore[override]
342
+ self,
343
+ version: str | None = None,
344
+ branch: str | None = None,
345
+ graph_stats_dict: dict[str, Any] | None = None,
346
+ coverage: float = 0.0,
347
+ critical_issues: int = 0,
348
+ complexity_median: float = 0.0,
349
+ hotspots: list[dict[str, Any]] | None = None,
350
+ issues: list[str] | None = None,
351
+ tree_hash: str = "",
352
+ ) -> Snapshot:
353
+ """Capture a pycode-kg snapshot.
354
+
355
+ Merges ``graph_stats_dict`` with pycode-kg-specific metric fields
356
+ (``docstring_coverage``, ``critical_issues``, ``complexity_median``,
357
+ ``module_node_counts``) into a single metrics dict, then delegates to
358
+ the shared manager and returns a pycode-kg :class:`Snapshot` instance.
359
+
360
+ :param version: Version string; auto-detected from package if None.
361
+ :param branch: Git branch; auto-detected if None.
362
+ :param graph_stats_dict: Output from the KG ``stats()`` method.
363
+ :param coverage: Docstring coverage fraction (0.0–1.0).
364
+ :param critical_issues: Number of critical issues detected.
365
+ :param complexity_median: Median fan-in across functions.
366
+ :param hotspots: Top hotspot entries.
367
+ :param issues: Issue description strings.
368
+ :param tree_hash: Git tree hash; auto-detected if not provided.
369
+ :return: New :class:`Snapshot` instance (not yet persisted).
370
+ """
371
+ # Collect per-module counts from SQLite (returns {} if db unavailable)
372
+ module_node_counts = self._collect_module_node_counts()
373
+
374
+ # Call the base implementation passing extra fields via **extra_metrics
375
+ base_snap = super().capture(
376
+ version=version,
377
+ branch=branch,
378
+ graph_stats_dict=graph_stats_dict,
379
+ tree_hash=tree_hash,
380
+ hotspots=hotspots,
381
+ issues=issues,
382
+ # Extra domain-specific metric fields:
383
+ docstring_coverage=coverage,
384
+ critical_issues=critical_issues,
385
+ complexity_median=complexity_median,
386
+ module_node_counts=module_node_counts,
387
+ )
388
+
389
+ return self._wrap_snapshot(base_snap)
390
+
391
+ # ------------------------------------------------------------------
392
+ # Override load_snapshot to return pycode-kg Snapshot instances
393
+ # ------------------------------------------------------------------
394
+
395
+ def load_snapshot(self, key: str) -> Snapshot | None:
396
+ """Load a snapshot by key, returning a pycode-kg Snapshot instance."""
397
+ base_snap = super().load_snapshot(key)
398
+ return self._wrap_snapshot(base_snap) if base_snap is not None else None
399
+
400
+ # ------------------------------------------------------------------
401
+ # save_snapshot — convert dataclass fields to plain dicts for manifest
402
+ # ------------------------------------------------------------------
403
+
404
+ def save_snapshot(self, snapshot: Snapshot, *, force: bool = False) -> Path | None: # type: ignore[override]
405
+ """Persist snapshot, normalising metrics to a plain dict for the manifest.
406
+
407
+ The base :meth:`~kg_snapshot.snapshots.SnapshotManager.save_snapshot`
408
+ writes ``snapshot.metrics`` directly into the manifest dict. For
409
+ pycode-kg :class:`Snapshot` instances that property returns a
410
+ :class:`SnapshotMetrics` dataclass — not JSON-serializable. This
411
+ override uses the raw dict for manifest entries while preserving the
412
+ base's dedup behaviour.
413
+
414
+ :param snapshot: Snapshot to persist.
415
+ :param force: If ``True``, always write a new history entry.
416
+ :return: Path to the saved JSON file, or ``None`` if unchanged (dedup).
417
+ :raises ValueError: If ``total_nodes`` is 0.
418
+ """
419
+ metrics_dict = snapshot._metrics_raw
420
+ if metrics_dict.get("total_nodes", 0) == 0:
421
+ raise ValueError(
422
+ "Refusing to save degenerate snapshot with 0 nodes. "
423
+ "Build the KG before capturing a snapshot."
424
+ )
425
+
426
+ manifest = self.load_manifest()
427
+
428
+ # Dedup: if version + metrics unchanged, refresh the latest entry in-place.
429
+ if not force and manifest.snapshots:
430
+ latest = max(manifest.snapshots, key=lambda x: x.get("timestamp", ""))
431
+ if snapshot.version == latest.get("version", "") and not self._metrics_changed(
432
+ metrics_dict, latest.get("metrics", {})
433
+ ):
434
+ old_key = latest["key"]
435
+ old_file = self.snapshots_dir / latest.get("file", f"{old_key}.json")
436
+ snapshot_file = self.snapshots_dir / f"{snapshot.key}.json"
437
+ snapshot_file.write_text(
438
+ json.dumps(snapshot.to_dict(), indent=2) + "\n",
439
+ encoding="utf-8",
440
+ )
441
+ if old_key != snapshot.key and old_file.exists():
442
+ old_file.unlink()
443
+ latest.update(
444
+ key=snapshot.key,
445
+ branch=snapshot.branch,
446
+ timestamp=snapshot.timestamp,
447
+ file=snapshot_file.name,
448
+ )
449
+ manifest.last_update = datetime.now(UTC).isoformat()
450
+ self._save_manifest(manifest)
451
+ return snapshot_file
452
+
453
+ # Normal path: new or changed snapshot.
454
+ snapshot_file = self.snapshots_dir / f"{snapshot.key}.json"
455
+ snapshot_file.write_text(json.dumps(snapshot.to_dict(), indent=2) + "\n", encoding="utf-8")
456
+
457
+ existing_idx = next(
458
+ (i for i, s in enumerate(manifest.snapshots) if s.get("key") == snapshot.key),
459
+ None,
460
+ )
461
+ vs_prev = snapshot._vs_previous_raw
462
+ vs_base = snapshot._vs_baseline_raw
463
+ manifest_entry: dict[str, Any] = {
464
+ "key": snapshot.key,
465
+ "branch": snapshot.branch,
466
+ "timestamp": snapshot.timestamp,
467
+ "version": snapshot.version,
468
+ "file": snapshot_file.name,
469
+ "metrics": metrics_dict,
470
+ "deltas": {"vs_previous": vs_prev, "vs_baseline": vs_base},
471
+ }
472
+ if existing_idx is not None:
473
+ manifest.snapshots[existing_idx] = manifest_entry
474
+ else:
475
+ manifest.snapshots.append(manifest_entry)
476
+ manifest.last_update = datetime.now(UTC).isoformat()
477
+ self._save_manifest(manifest)
478
+ return snapshot_file
479
+
480
+ # ------------------------------------------------------------------
481
+ # Delta computation — adds coverage_delta and critical_issues_delta
482
+ # ------------------------------------------------------------------
483
+
484
+ @staticmethod
485
+ def _as_metrics_dict(m: SnapshotMetrics | dict[str, Any]) -> dict[str, Any]:
486
+ """Return a plain metrics dict whether ``m`` is a dataclass or already a dict."""
487
+ return metrics_to_dict(m) if isinstance(m, SnapshotMetrics) else m # type: ignore[arg-type]
488
+
489
+ def _compute_delta(self, snap_new: Snapshot, snap_old: Snapshot) -> dict[str, Any]:
490
+ """Compute pycode-kg metrics delta including coverage and issue count."""
491
+ return self._compute_delta_from_metrics(
492
+ self._as_metrics_dict(snap_new.metrics),
493
+ self._as_metrics_dict(snap_old.metrics),
494
+ )
495
+
496
+ def _compute_delta_from_metrics(
497
+ self, new_m: dict[str, Any], old_m: dict[str, Any]
498
+ ) -> dict[str, Any]:
499
+ """Compute delta dict from two raw metrics dicts.
500
+
501
+ Includes the base ``nodes`` / ``edges`` plus the pycode-kg-specific
502
+ ``coverage_delta`` and ``critical_issues_delta`` fields.
503
+ """
504
+ return {
505
+ "nodes": new_m.get("total_nodes", 0) - old_m.get("total_nodes", 0),
506
+ "edges": new_m.get("total_edges", 0) - old_m.get("total_edges", 0),
507
+ "coverage_delta": new_m.get("docstring_coverage", 0.0)
508
+ - old_m.get("docstring_coverage", 0.0),
509
+ "critical_issues_delta": new_m.get("critical_issues", 0)
510
+ - old_m.get("critical_issues", 0),
511
+ }
512
+
513
+ # ------------------------------------------------------------------
514
+ # diff_snapshots — includes module_node_counts_delta and issues_delta
515
+ # ------------------------------------------------------------------
516
+
517
+ def diff_snapshots(self, key_a: str, key_b: str) -> dict[str, Any]:
518
+ """Compare two snapshots side-by-side.
519
+
520
+ Extends the shared diff with ``module_node_counts_delta`` and
521
+ ``issues_delta`` (introduced / resolved issue strings).
522
+
523
+ :param key_a: First snapshot key (tree hash).
524
+ :param key_b: Second snapshot key (tree hash).
525
+ :return: Dict with metrics from both, computed deltas.
526
+ """
527
+ snap_a = self.load_snapshot(key_a)
528
+ snap_b = self.load_snapshot(key_b)
529
+
530
+ if not snap_a or not snap_b:
531
+ return {"error": "One or both snapshots not found"}
532
+
533
+ m_a = self._as_metrics_dict(snap_a.metrics)
534
+ m_b = self._as_metrics_dict(snap_b.metrics)
535
+
536
+ all_node_kinds = set(m_a.get("node_counts", {})) | set(m_b.get("node_counts", {}))
537
+ all_edge_rels = set(m_a.get("edge_counts", {})) | set(m_b.get("edge_counts", {}))
538
+
539
+ node_counts_delta = {
540
+ k: m_b.get("node_counts", {}).get(k, 0) - m_a.get("node_counts", {}).get(k, 0)
541
+ for k in all_node_kinds
542
+ }
543
+ edge_counts_delta = {
544
+ k: m_b.get("edge_counts", {}).get(k, 0) - m_a.get("edge_counts", {}).get(k, 0)
545
+ for k in all_edge_rels
546
+ }
547
+
548
+ all_modules = set(m_a.get("module_node_counts", {})) | set(
549
+ m_b.get("module_node_counts", {})
550
+ )
551
+ module_node_counts_delta = {
552
+ mod: m_b.get("module_node_counts", {}).get(mod, 0)
553
+ - m_a.get("module_node_counts", {}).get(mod, 0)
554
+ for mod in all_modules
555
+ if m_b.get("module_node_counts", {}).get(mod, 0)
556
+ != m_a.get("module_node_counts", {}).get(mod, 0)
557
+ }
558
+
559
+ issues_a = set(snap_a.issues)
560
+ issues_b = set(snap_b.issues)
561
+
562
+ return {
563
+ "a": {"key": snap_a.key, "metrics": m_a, "issues": snap_a.issues},
564
+ "b": {"key": snap_b.key, "metrics": m_b, "issues": snap_b.issues},
565
+ "delta": self._compute_delta_from_metrics(m_b, m_a),
566
+ "node_counts_delta": node_counts_delta,
567
+ "edge_counts_delta": edge_counts_delta,
568
+ "module_node_counts_delta": module_node_counts_delta,
569
+ "issues_delta": {
570
+ "introduced": list(issues_b - issues_a),
571
+ "resolved": list(issues_a - issues_b),
572
+ },
573
+ }
574
+
575
+ # ------------------------------------------------------------------
576
+ # SQLite per-module node counts
577
+ # ------------------------------------------------------------------
578
+
579
+ def _collect_module_node_counts(self) -> dict[str, int]:
580
+ """Query SQLite for per-module node counts.
581
+
582
+ :return: Dict mapping ``module_path`` to node count, or ``{}`` if the
583
+ database is unavailable or the query fails.
584
+ """
585
+ if not self.db_path or not self.db_path.exists():
586
+ return {}
587
+ try:
588
+ with sqlite3.connect(self.db_path) as conn:
589
+ rows = conn.execute(
590
+ "SELECT module_path, COUNT(*) FROM nodes GROUP BY module_path"
591
+ ).fetchall()
592
+ return {row[0]: row[1] for row in rows if row[0]}
593
+ except sqlite3.Error:
594
+ return {}
595
+
596
+
597
+ # ---------------------------------------------------------------------------
598
+ # Public re-exports (so ``from pycode_kg.snapshots import X`` keeps working)
599
+ # ---------------------------------------------------------------------------
600
+
601
+ __all__ = [
602
+ "Snapshot",
603
+ "SnapshotManifest",
604
+ "SnapshotManager",
605
+ "SnapshotMetrics",
606
+ "SnapshotDelta",
607
+ "PruneResult",
608
+ "metrics_to_dict",
609
+ "metrics_from_dict",
610
+ "delta_to_dict",
611
+ "delta_from_dict",
612
+ ]
@@ -0,0 +1,12 @@
1
+ CREATE TABLE IF NOT EXISTS centrality_scores (
2
+ node_id TEXT NOT NULL,
3
+ metric TEXT NOT NULL,
4
+ score REAL NOT NULL,
5
+ rank INTEGER,
6
+ computed_at TEXT NOT NULL,
7
+ params_json TEXT NOT NULL,
8
+ PRIMARY KEY (node_id, metric)
9
+ );
10
+
11
+ CREATE INDEX IF NOT EXISTS idx_centrality_metric_rank
12
+ ON centrality_scores(metric, rank);