polycodegraph 0.1.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 (67) hide show
  1. codegraph/__init__.py +10 -0
  2. codegraph/analysis/__init__.py +30 -0
  3. codegraph/analysis/_common.py +125 -0
  4. codegraph/analysis/blast_radius.py +63 -0
  5. codegraph/analysis/cycles.py +79 -0
  6. codegraph/analysis/dataflow.py +861 -0
  7. codegraph/analysis/dead_code.py +165 -0
  8. codegraph/analysis/hotspots.py +68 -0
  9. codegraph/analysis/infrastructure.py +439 -0
  10. codegraph/analysis/metrics.py +52 -0
  11. codegraph/analysis/report.py +222 -0
  12. codegraph/analysis/roles.py +323 -0
  13. codegraph/analysis/untested.py +79 -0
  14. codegraph/cli.py +1506 -0
  15. codegraph/config.py +64 -0
  16. codegraph/embed/__init__.py +35 -0
  17. codegraph/embed/chunker.py +120 -0
  18. codegraph/embed/embedder.py +113 -0
  19. codegraph/embed/query.py +181 -0
  20. codegraph/embed/store.py +360 -0
  21. codegraph/graph/__init__.py +0 -0
  22. codegraph/graph/builder.py +212 -0
  23. codegraph/graph/schema.py +69 -0
  24. codegraph/graph/store_networkx.py +55 -0
  25. codegraph/graph/store_sqlite.py +249 -0
  26. codegraph/mcp_server/__init__.py +6 -0
  27. codegraph/mcp_server/server.py +933 -0
  28. codegraph/parsers/__init__.py +0 -0
  29. codegraph/parsers/base.py +70 -0
  30. codegraph/parsers/go.py +570 -0
  31. codegraph/parsers/python.py +1707 -0
  32. codegraph/parsers/typescript.py +1397 -0
  33. codegraph/py.typed +0 -0
  34. codegraph/resolve/__init__.py +4 -0
  35. codegraph/resolve/calls.py +480 -0
  36. codegraph/review/__init__.py +31 -0
  37. codegraph/review/baseline.py +32 -0
  38. codegraph/review/differ.py +211 -0
  39. codegraph/review/hook.py +70 -0
  40. codegraph/review/risk.py +219 -0
  41. codegraph/review/rules.py +342 -0
  42. codegraph/viz/__init__.py +17 -0
  43. codegraph/viz/_style.py +45 -0
  44. codegraph/viz/dashboard.py +740 -0
  45. codegraph/viz/diagrams.py +370 -0
  46. codegraph/viz/explore.py +453 -0
  47. codegraph/viz/hld.py +683 -0
  48. codegraph/viz/html.py +115 -0
  49. codegraph/viz/mermaid.py +111 -0
  50. codegraph/viz/svg.py +77 -0
  51. codegraph/web/__init__.py +4 -0
  52. codegraph/web/server.py +165 -0
  53. codegraph/web/static/app.css +664 -0
  54. codegraph/web/static/app.js +919 -0
  55. codegraph/web/static/index.html +112 -0
  56. codegraph/web/static/views/architecture.js +1671 -0
  57. codegraph/web/static/views/graph3d.css +564 -0
  58. codegraph/web/static/views/graph3d.js +999 -0
  59. codegraph/web/static/views/graph3d_transform.js +984 -0
  60. codegraph/workspace/__init__.py +34 -0
  61. codegraph/workspace/config.py +110 -0
  62. codegraph/workspace/operations.py +294 -0
  63. polycodegraph-0.1.0.dist-info/METADATA +687 -0
  64. polycodegraph-0.1.0.dist-info/RECORD +67 -0
  65. polycodegraph-0.1.0.dist-info/WHEEL +4 -0
  66. polycodegraph-0.1.0.dist-info/entry_points.txt +2 -0
  67. polycodegraph-0.1.0.dist-info/licenses/LICENSE +21 -0
codegraph/py.typed ADDED
File without changes
@@ -0,0 +1,4 @@
1
+ """Cross-file resolution package."""
2
+ from codegraph.resolve.calls import ResolveStats, resolve_unresolved_edges
3
+
4
+ __all__ = ["ResolveStats", "resolve_unresolved_edges"]
@@ -0,0 +1,480 @@
1
+ """Post-build cross-file resolution.
2
+
3
+ Extractors emit edges with ``dst="unresolved::<target_name>"`` whenever the
4
+ extractor cannot determine the call/import target without seeing the full
5
+ module graph. This pass walks every unresolved edge and tries to rewrite it
6
+ into a real node id using a few cheap heuristics:
7
+
8
+ * ``self.foo`` inside a method -> sibling method on the enclosing class
9
+ * bare ``foo`` inside a module/function -> function/class in the same module
10
+ * ``mod.foo`` -> resolved through the module's IMPORTS edges
11
+ * fully-qualified name -> exact qualname match
12
+ * otherwise: unique tail/qualname match across the whole graph
13
+
14
+ Anything that cannot be resolved unambiguously is left as ``unresolved::*`` so
15
+ later analyses can still ignore it without crashing.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from collections import defaultdict
20
+ from dataclasses import dataclass
21
+
22
+ from codegraph.graph.schema import Edge, EdgeKind, Node, NodeKind
23
+ from codegraph.graph.store_sqlite import SQLiteGraphStore
24
+
25
+ _REFERENCE_KINDS: frozenset[EdgeKind] = frozenset(
26
+ {
27
+ EdgeKind.CALLS, EdgeKind.IMPORTS, EdgeKind.INHERITS,
28
+ EdgeKind.IMPLEMENTS,
29
+ # DF1 — SQLAlchemy data-access edges. Emitted unresolved by the
30
+ # Python parser (model name only) and rewritten here when a
31
+ # matching CLASS exists in-repo.
32
+ EdgeKind.READS_FROM, EdgeKind.WRITES_TO,
33
+ }
34
+ )
35
+ # Edge kinds that the DF1 spec requires to be DROPPED when they cannot
36
+ # be resolved (rather than left as ``unresolved::*`` for downstream tools).
37
+ _DROP_IF_UNRESOLVED: frozenset[EdgeKind] = frozenset(
38
+ {EdgeKind.READS_FROM, EdgeKind.WRITES_TO}
39
+ )
40
+ _DEFINITION_KINDS: frozenset[NodeKind] = frozenset(
41
+ {NodeKind.FUNCTION, NodeKind.METHOD, NodeKind.CLASS, NodeKind.MODULE}
42
+ )
43
+
44
+
45
+ @dataclass
46
+ class ResolveStats:
47
+ inspected: int = 0
48
+ resolved: int = 0
49
+ unresolved: int = 0
50
+
51
+
52
+ def _strip_unresolved(dst: str) -> str:
53
+ prefix = "unresolved::"
54
+ return dst[len(prefix):] if dst.startswith(prefix) else dst
55
+
56
+
57
+ def _normalize_target(name: str) -> str:
58
+ """Strip call-syntax noise so "foo.bar()" / "foo()" become "foo.bar".
59
+
60
+ Also collapses fresh-instance chains like ``Builder().make`` /
61
+ ``Builder(...).run.bar`` into ``Builder.make`` / ``Builder.run.bar``,
62
+ so the resolver can find the method on the constructed class instead
63
+ of the class itself.
64
+ """
65
+ cleaned = name.strip()
66
+ if cleaned.startswith("await "):
67
+ cleaned = cleaned[len("await "):]
68
+ if cleaned.startswith("new "):
69
+ cleaned = cleaned[len("new "):]
70
+ # Collapse balanced ``(...)`` segments. We track depth so nested
71
+ # parens (``Builder(Inner()).make``) collapse correctly to
72
+ # ``Builder.make``.
73
+ out: list[str] = []
74
+ depth = 0
75
+ for ch in cleaned:
76
+ if ch == "(":
77
+ depth += 1
78
+ continue
79
+ if ch == ")":
80
+ if depth > 0:
81
+ depth -= 1
82
+ continue
83
+ if depth == 0:
84
+ out.append(ch)
85
+ cleaned = "".join(out).strip()
86
+ # Drop any trailing dots left over from ``Foo().``.
87
+ while cleaned.endswith("."):
88
+ cleaned = cleaned[:-1]
89
+ return cleaned
90
+
91
+
92
+ class _Index:
93
+ def __init__(self, nodes: list[Node]) -> None:
94
+ self.by_id: dict[str, Node] = {n.id: n for n in nodes}
95
+ self.by_qualname: dict[str, list[Node]] = defaultdict(list)
96
+ self.by_name: dict[str, list[Node]] = defaultdict(list)
97
+ self.module_by_qualname: dict[str, Node] = {}
98
+ self.module_by_file: dict[str, Node] = {}
99
+ for node in nodes:
100
+ if node.kind in _DEFINITION_KINDS:
101
+ self.by_qualname[node.qualname].append(node)
102
+ self.by_name[node.name].append(node)
103
+ if node.kind == NodeKind.MODULE:
104
+ self.module_by_qualname[node.qualname] = node
105
+ self.module_by_file[node.file] = node
106
+
107
+
108
+ def _attr_type_names(
109
+ class_node: Node, attr_head: str
110
+ ) -> list[str]:
111
+ """Return the list of declared type names for ``self.<attr_head>``.
112
+
113
+ Tolerates the legacy schema where ``attr_types[name]`` was a single
114
+ string rather than a list (R2). Empty list if the attribute is not
115
+ annotated.
116
+ """
117
+ attr_types = class_node.metadata.get("attr_types")
118
+ if not isinstance(attr_types, dict):
119
+ return []
120
+ raw = attr_types.get(attr_head)
121
+ if isinstance(raw, str):
122
+ return [raw] if raw else []
123
+ if isinstance(raw, list):
124
+ return [t for t in raw if isinstance(t, str) and t]
125
+ return []
126
+
127
+
128
+ def _resolve_self_attr_targets(
129
+ class_qual: str,
130
+ head: str,
131
+ tail: str,
132
+ index: _Index,
133
+ imports_for_module: dict[str, dict[str, str]],
134
+ src_module: Node | None,
135
+ ) -> list[Node]:
136
+ """Resolve ``self.<head>.<tail>`` to one or more candidate nodes.
137
+
138
+ Honors a list of candidate type names declared on the enclosing class
139
+ (R3 union annotation or if/else branch assignment in ``__init__``)
140
+ and returns one resolved node per type that owns ``tail``.
141
+ """
142
+ class_nodes = index.by_qualname.get(class_qual, [])
143
+ if not class_nodes:
144
+ return []
145
+ type_names = _attr_type_names(class_nodes[0], head)
146
+ if not type_names:
147
+ return []
148
+ out: list[Node] = []
149
+ seen: set[str] = set()
150
+ for type_name in type_names:
151
+ node = _resolve_typed_attr_tail(
152
+ type_name, tail, index, imports_for_module, src_module,
153
+ )
154
+ if node is not None and node.id not in seen:
155
+ seen.add(node.id)
156
+ out.append(node)
157
+ return out
158
+
159
+
160
+ def _resolve_typed_attr_tail(
161
+ type_name: str,
162
+ tail: str,
163
+ index: _Index,
164
+ imports_for_module: dict[str, dict[str, str]],
165
+ src_module: Node | None,
166
+ ) -> Node | None:
167
+ """Resolve ``<type_name>.<tail>`` via fully-qualified, import, or tail."""
168
+ # 1) Fully-qualified.
169
+ full = f"{type_name}.{tail}"
170
+ hit = index.by_qualname.get(full, [])
171
+ if hit:
172
+ return hit[0]
173
+ # 2) Import binding from the same module.
174
+ if src_module is not None:
175
+ bind = imports_for_module.get(src_module.id, {})
176
+ bound = bind.get(type_name)
177
+ if bound:
178
+ hit = index.by_qualname.get(f"{bound}.{tail}", [])
179
+ if hit:
180
+ return hit[0]
181
+ # 3) Tail-match: any class whose qualname ends with ``type_name``.
182
+ for qn in index.by_qualname:
183
+ if qn == type_name or qn.endswith("." + type_name):
184
+ hit = index.by_qualname.get(f"{qn}.{tail}", [])
185
+ if hit:
186
+ return hit[0]
187
+ return None
188
+
189
+
190
+ def _try_multi_self_attr(
191
+ target: str,
192
+ src_node: Node | None,
193
+ edge_kind: EdgeKind,
194
+ index: _Index,
195
+ imports_for_module: dict[str, dict[str, str]],
196
+ ) -> list[Node]:
197
+ """Return >=2 candidate nodes when ``self.X.tail`` has a union type.
198
+
199
+ Returns an empty list when the target is not a self-attribute chain,
200
+ when the enclosing class doesn't declare a union for ``X``, or when
201
+ only zero/one type resolves. The single-candidate path is left to
202
+ the regular ``_resolve_target`` logic so we don't double-resolve.
203
+ """
204
+ if edge_kind != EdgeKind.CALLS:
205
+ return []
206
+ if src_node is None or src_node.kind != NodeKind.METHOD:
207
+ return []
208
+ cleaned = _normalize_target(target)
209
+ if not cleaned.startswith("self."):
210
+ return []
211
+ rest = cleaned[len("self."):]
212
+ head, _, tail = rest.partition(".")
213
+ if not tail:
214
+ return []
215
+ parts = src_node.qualname.split(".")
216
+ if len(parts) < 2:
217
+ return []
218
+ class_qual = ".".join(parts[:-1])
219
+ src_module = index.module_by_file.get(src_node.file)
220
+ multi = _resolve_self_attr_targets(
221
+ class_qual, head, tail, index, imports_for_module, src_module,
222
+ )
223
+ if len(multi) < 2:
224
+ return []
225
+ return multi
226
+
227
+
228
+ def _resolve_target(
229
+ target: str,
230
+ src_node: Node | None,
231
+ index: _Index,
232
+ imports_for_module: dict[str, dict[str, str]],
233
+ ) -> Node | None:
234
+ """Return the best-matching node for ``target``, or None."""
235
+ if not target:
236
+ return None
237
+ target = _normalize_target(target)
238
+ if not target:
239
+ return None
240
+
241
+ src_module: Node | None = None
242
+ if src_node is not None:
243
+ src_module = index.module_by_file.get(src_node.file)
244
+
245
+ # 1. self.X -> derive enclosing class qualname from src qualname.
246
+ # For `self.foo.bar` style chains, only the first segment after `self.`
247
+ # is meaningfully resolvable against the enclosing class; the deeper
248
+ # tail (`.bar`) requires variable-type inference (R3). So we look up
249
+ # `class_qual.first_segment` and fall through to the remaining
250
+ # heuristics with the first segment as the new target rather than
251
+ # constructing a phantom dotted qualname.
252
+ if target.startswith("self."):
253
+ rest = target[len("self."):]
254
+ head = rest.split(".", 1)[0]
255
+ tail = rest[len(head) + 1:] if len(rest) > len(head) else ""
256
+ if src_node is not None and src_node.kind == NodeKind.METHOD:
257
+ parts = src_node.qualname.split(".")
258
+ if len(parts) >= 2:
259
+ class_qual = ".".join(parts[:-1])
260
+ # Direct match (no dotted tail).
261
+ cands = index.by_qualname.get(f"{class_qual}.{rest}", [])
262
+ if cands:
263
+ return cands[0]
264
+ # Dotted tail: try resolving via class-level type annotation
265
+ # (\`name: TypeName\` in the class body). If the enclosing
266
+ # class declares ``head: TypeName``, look up
267
+ # ``TypeName.<tail>`` against in-repo types. Multi-type
268
+ # candidates (R3 union / if-else) are returned via
269
+ # ``_resolve_self_attr_targets`` instead.
270
+ if tail:
271
+ multi = _resolve_self_attr_targets(
272
+ class_qual, head, tail, index,
273
+ imports_for_module, src_module,
274
+ )
275
+ if multi:
276
+ return multi[0]
277
+ # Dotted tail: try resolving just the first segment as a
278
+ # method/attribute on the enclosing class.
279
+ if head != rest:
280
+ cands = index.by_qualname.get(
281
+ f"{class_qual}.{head}", []
282
+ )
283
+ if cands:
284
+ return cands[0]
285
+ # Fall through with just the head; never let "foo.bar" leak as a
286
+ # phantom qualname into later heuristics.
287
+ target = head
288
+
289
+ # 1b. Scope-relative: when the caller is itself a function/method and
290
+ # the target names a function defined directly inside the caller (a
291
+ # nested helper), prefer that closure-local definition over any
292
+ # module-level same-name function.
293
+ if (
294
+ src_node is not None
295
+ and src_node.kind in (NodeKind.FUNCTION, NodeKind.METHOD)
296
+ ):
297
+ nested_q = f"{src_node.qualname}.{target}"
298
+ cands = index.by_qualname.get(nested_q, [])
299
+ if cands:
300
+ return cands[0]
301
+
302
+ # 2. Exact qualname.
303
+ if target in index.by_qualname:
304
+ cands = index.by_qualname[target]
305
+ if len(cands) == 1:
306
+ return cands[0]
307
+
308
+ # 3. Same-module: <src_module>.<target>.
309
+ if src_module is not None:
310
+ candidate_q = f"{src_module.qualname}.{target}"
311
+ cands = index.by_qualname.get(candidate_q, [])
312
+ if cands:
313
+ return cands[0]
314
+
315
+ # 4. Through imports of the source module.
316
+ if src_module is not None:
317
+ bindings = imports_for_module.get(src_module.id, {})
318
+ head, _, tail = target.partition(".")
319
+ bound = bindings.get(head)
320
+ if bound is not None:
321
+ full = bound if not tail else f"{bound}.{tail}"
322
+ cands = index.by_qualname.get(full, [])
323
+ if cands:
324
+ return cands[0]
325
+ mod = index.module_by_qualname.get(bound)
326
+ if mod is not None and tail:
327
+ cands = index.by_qualname.get(f"{mod.qualname}.{tail}", [])
328
+ if cands:
329
+ return cands[0]
330
+
331
+ # 5. Module by qualname (for IMPORTS).
332
+ mod = index.module_by_qualname.get(target)
333
+ if mod is not None:
334
+ return mod
335
+
336
+ # 6. Tail match: any qualname ending with .target -- accept only if unique.
337
+ suffix_matches: list[Node] = []
338
+ for qn, nodes in index.by_qualname.items():
339
+ if qn == target or qn.endswith("." + target):
340
+ suffix_matches.extend(nodes)
341
+ if len(suffix_matches) == 1:
342
+ return suffix_matches[0]
343
+
344
+ # 7. Bare-name match across the whole graph (only if globally unique).
345
+ base = target.rsplit(".", 1)[-1]
346
+ by_name = index.by_name.get(base, [])
347
+ if len(by_name) == 1:
348
+ return by_name[0]
349
+
350
+ return None
351
+
352
+
353
+ def _build_import_bindings(
354
+ edges: list[Edge], index: _Index
355
+ ) -> dict[str, dict[str, str]]:
356
+ """For each module node id, map import alias -> imported module qualname.
357
+
358
+ Currently we only know the textual target_name (e.g. "models" or
359
+ "./utils"), so the alias is the leaf segment.
360
+ """
361
+ bindings: dict[str, dict[str, str]] = defaultdict(dict)
362
+ for edge in edges:
363
+ if edge.kind != EdgeKind.IMPORTS:
364
+ continue
365
+ src_node = index.by_id.get(edge.src)
366
+ if src_node is None or src_node.kind != NodeKind.MODULE:
367
+ continue
368
+ target = edge.metadata.get("target_name")
369
+ if not isinstance(target, str) or not target:
370
+ continue
371
+ # Python parser may already produce absolute dotted qualnames for
372
+ # relative imports (e.g. "pkg.models.Foo"). Only strip leading "./"
373
+ # and "../" path noise, not bare leading dots that may be part of
374
+ # a dotted qualname.
375
+ normalized = target.replace("\\", "/")
376
+ while normalized.startswith("./") or normalized.startswith("../"):
377
+ normalized = normalized[2:] if normalized.startswith("./") \
378
+ else normalized[3:]
379
+ normalized = normalized.replace("/", ".")
380
+ if not normalized:
381
+ continue
382
+ imported_name = edge.metadata.get("imported_name")
383
+ if isinstance(imported_name, str) and imported_name:
384
+ # Bind the alias used in the source file -> full qualname.
385
+ bindings[src_node.id][imported_name] = normalized
386
+ bindings[src_node.id][normalized] = normalized
387
+ else:
388
+ leaf = normalized.rsplit(".", 1)[-1]
389
+ bindings[src_node.id][leaf] = normalized
390
+ bindings[src_node.id][normalized] = normalized
391
+ return bindings
392
+
393
+
394
+ def resolve_unresolved_edges(store: SQLiteGraphStore) -> ResolveStats:
395
+ """Rewrite ``unresolved::*`` edges in-place, returning summary stats."""
396
+ nodes = list(store.iter_nodes())
397
+ edges = list(store.iter_edges())
398
+ index = _Index(nodes)
399
+ bindings = _build_import_bindings(edges, index)
400
+
401
+ stats = ResolveStats()
402
+ new_edges: list[Edge] = []
403
+ deletions: list[tuple[str, str, EdgeKind]] = []
404
+
405
+ for edge in edges:
406
+ if not edge.dst.startswith("unresolved::"):
407
+ continue
408
+ if edge.kind not in _REFERENCE_KINDS:
409
+ continue
410
+ stats.inspected += 1
411
+ meta_target = edge.metadata.get("target_name")
412
+ target = (
413
+ meta_target if isinstance(meta_target, str)
414
+ else _strip_unresolved(edge.dst)
415
+ )
416
+ src_node = index.by_id.get(edge.src)
417
+
418
+ # R3: ``self.X.tail`` may bind to multiple class types when the
419
+ # enclosing class declares a union annotation or assigns the
420
+ # attribute in branching ``__init__`` paths. We emit one edge per
421
+ # candidate so the dead-code analyzer can see all reachable
422
+ # implementations.
423
+ multi = _try_multi_self_attr(
424
+ target, src_node, edge.kind, index, bindings,
425
+ )
426
+ if multi:
427
+ deletions.append((edge.src, edge.dst, edge.kind))
428
+ for hit in multi:
429
+ if hit.id == edge.src:
430
+ continue
431
+ new_edges.append(
432
+ Edge(
433
+ src=edge.src,
434
+ dst=hit.id,
435
+ kind=edge.kind,
436
+ file=edge.file,
437
+ line=edge.line,
438
+ metadata={
439
+ **edge.metadata, "resolved_from": edge.dst,
440
+ },
441
+ )
442
+ )
443
+ stats.resolved += 1
444
+ continue
445
+
446
+ resolved = _resolve_target(target, src_node, index, bindings)
447
+ if resolved is None or resolved.id == edge.src:
448
+ stats.unresolved += 1
449
+ # DF1: drop unresolved data-access edges so the graph never
450
+ # carries ``unresolved::Model`` placeholders for SQL I/O.
451
+ if edge.kind in _DROP_IF_UNRESOLVED:
452
+ deletions.append((edge.src, edge.dst, edge.kind))
453
+ continue
454
+ # DF1 sanity: READS_FROM/WRITES_TO must resolve to a CLASS.
455
+ if (
456
+ edge.kind in _DROP_IF_UNRESOLVED
457
+ and resolved.kind != NodeKind.CLASS
458
+ ):
459
+ stats.unresolved += 1
460
+ deletions.append((edge.src, edge.dst, edge.kind))
461
+ continue
462
+ deletions.append((edge.src, edge.dst, edge.kind))
463
+ new_edges.append(
464
+ Edge(
465
+ src=edge.src,
466
+ dst=resolved.id,
467
+ kind=edge.kind,
468
+ file=edge.file,
469
+ line=edge.line,
470
+ metadata={**edge.metadata, "resolved_from": edge.dst},
471
+ )
472
+ )
473
+ stats.resolved += 1
474
+
475
+ for src, dst, kind in deletions:
476
+ store.delete_edge(src, dst, kind)
477
+ if new_edges:
478
+ store.upsert_edges(new_edges)
479
+
480
+ return stats
@@ -0,0 +1,31 @@
1
+ """PR review: graph diffs, risk scoring, and rule evaluation."""
2
+ from __future__ import annotations
3
+
4
+ from codegraph.review.baseline import load_baseline, save_baseline
5
+ from codegraph.review.differ import EdgeChange, GraphDiff, NodeChange, diff_graphs
6
+ from codegraph.review.risk import Risk, score_change
7
+ from codegraph.review.rules import (
8
+ DEFAULT_RULES,
9
+ Finding,
10
+ Rule,
11
+ RuleMatch,
12
+ evaluate_rules,
13
+ load_rules,
14
+ )
15
+
16
+ __all__ = [
17
+ "DEFAULT_RULES",
18
+ "EdgeChange",
19
+ "Finding",
20
+ "GraphDiff",
21
+ "NodeChange",
22
+ "Risk",
23
+ "Rule",
24
+ "RuleMatch",
25
+ "diff_graphs",
26
+ "evaluate_rules",
27
+ "load_baseline",
28
+ "load_rules",
29
+ "save_baseline",
30
+ "score_change",
31
+ ]
@@ -0,0 +1,32 @@
1
+ """Baseline graph snapshot management."""
2
+ from __future__ import annotations
3
+
4
+ import shutil
5
+ from pathlib import Path
6
+
7
+ import networkx as nx
8
+
9
+ from codegraph.graph.store_networkx import to_digraph
10
+ from codegraph.graph.store_sqlite import SQLiteGraphStore
11
+
12
+
13
+ def save_baseline(db_path: Path, baseline_path: Path) -> None:
14
+ """Copy ``db_path`` to ``baseline_path`` (creating parents as needed)."""
15
+ if not db_path.exists():
16
+ raise FileNotFoundError(f"graph database not found: {db_path}")
17
+ baseline_path.parent.mkdir(parents=True, exist_ok=True)
18
+ shutil.copy2(str(db_path), str(baseline_path))
19
+
20
+
21
+ def load_baseline(baseline_path: Path) -> nx.MultiDiGraph | None:
22
+ """Load the baseline graph from ``baseline_path``.
23
+
24
+ Returns ``None`` when the baseline file is missing.
25
+ """
26
+ if not baseline_path.exists():
27
+ return None
28
+ store = SQLiteGraphStore(baseline_path)
29
+ try:
30
+ return to_digraph(store)
31
+ finally:
32
+ store.close()