graphcheck 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 (79) hide show
  1. graphcheck/__init__.py +9 -0
  2. graphcheck/application/__init__.py +0 -0
  3. graphcheck/application/artifacts.py +145 -0
  4. graphcheck/application/paths.py +6 -0
  5. graphcheck/application/run.py +233 -0
  6. graphcheck/application/suites.py +44 -0
  7. graphcheck/baselines.py +228 -0
  8. graphcheck/bootstrap.py +19 -0
  9. graphcheck/cli.py +2339 -0
  10. graphcheck/connection_profiles.py +119 -0
  11. graphcheck/contracts/__init__.py +0 -0
  12. graphcheck/contracts/check.py +258 -0
  13. graphcheck/contracts/profile.py +290 -0
  14. graphcheck/contracts/results.py +409 -0
  15. graphcheck/contracts/scalars.py +23 -0
  16. graphcheck/contracts/schemas.py +140 -0
  17. graphcheck/debug_diagnostics.py +155 -0
  18. graphcheck/diff.py +420 -0
  19. graphcheck/engine/__init__.py +64 -0
  20. graphcheck/engine/baseline.py +262 -0
  21. graphcheck/engine/compiler.py +577 -0
  22. graphcheck/engine/core_pack.py +681 -0
  23. graphcheck/engine/evaluator.py +982 -0
  24. graphcheck/engine/executor.py +192 -0
  25. graphcheck/engine/identifiers.py +25 -0
  26. graphcheck/engine/parameters.py +99 -0
  27. graphcheck/engine/pii_pack.py +318 -0
  28. graphcheck/engine/runner.py +1798 -0
  29. graphcheck/engine/sampling.py +316 -0
  30. graphcheck/errors.py +69 -0
  31. graphcheck/generation/__init__.py +1 -0
  32. graphcheck/generation/client.py +383 -0
  33. graphcheck/generation/config.py +100 -0
  34. graphcheck/generation/disclosure.py +115 -0
  35. graphcheck/generation/prompts.py +112 -0
  36. graphcheck/generation/proposals.py +271 -0
  37. graphcheck/generation/service.py +379 -0
  38. graphcheck/generation/transmission.py +311 -0
  39. graphcheck/generation/writer.py +116 -0
  40. graphcheck/mcp/adapter.py +133 -0
  41. graphcheck/mcp/server.py +62 -0
  42. graphcheck/neo4j_adapter.py +1431 -0
  43. graphcheck/observability/__init__.py +13 -0
  44. graphcheck/observability/collector.py +31 -0
  45. graphcheck/observability/health.py +69 -0
  46. graphcheck/observability/metrics.py +34 -0
  47. graphcheck/observability/runner.py +28 -0
  48. graphcheck/observability/server.py +18 -0
  49. graphcheck/packs/__init__.py +213 -0
  50. graphcheck/packs/catalog.py +106 -0
  51. graphcheck/packs/core.yml +113 -0
  52. graphcheck/packs/metadata.py +329 -0
  53. graphcheck/packs/pii.yml +77 -0
  54. graphcheck/profiler.py +822 -0
  55. graphcheck/project.py +115 -0
  56. graphcheck/reporting/__init__.py +41 -0
  57. graphcheck/reporting/explorer.py +372 -0
  58. graphcheck/reporting/history.py +527 -0
  59. graphcheck/reporting/html.py +2239 -0
  60. graphcheck/reporting/presentation.py +156 -0
  61. graphcheck/reporting/redaction.py +321 -0
  62. graphcheck/reporting/writer.py +97 -0
  63. graphcheck/scoring.py +156 -0
  64. graphcheck/telemetry/__init__.py +28 -0
  65. graphcheck/telemetry/collector.py +296 -0
  66. graphcheck/telemetry/consent.py +208 -0
  67. graphcheck/telemetry/events.py +417 -0
  68. graphcheck/telemetry/inactive.py +130 -0
  69. graphcheck/telemetry/policy.py +769 -0
  70. graphcheck/telemetry/posthog.py +253 -0
  71. graphcheck/telemetry/release.py +10 -0
  72. graphcheck/telemetry/runtime.py +375 -0
  73. graphcheck/telemetry/types.py +222 -0
  74. graphcheck/yaml_loader.py +43 -0
  75. graphcheck-0.1.0.dist-info/METADATA +187 -0
  76. graphcheck-0.1.0.dist-info/RECORD +79 -0
  77. graphcheck-0.1.0.dist-info/WHEEL +4 -0
  78. graphcheck-0.1.0.dist-info/entry_points.txt +2 -0
  79. graphcheck-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,155 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ import yaml
8
+ from pydantic import ValidationError
9
+
10
+ from graphcheck.contracts.check import (
11
+ DuplicateKeyError,
12
+ SuiteValidationError,
13
+ UnknownCheckError,
14
+ load_suite,
15
+ )
16
+ from graphcheck.contracts.results import Capabilities
17
+ from graphcheck.errors import GraphCheckError
18
+ from graphcheck.neo4j_adapter import BlockedCheck, Visibility
19
+ from graphcheck.packs import CapabilityRequirement
20
+ from graphcheck.packs.catalog import PackCatalogError, load_pack_requirements
21
+ from graphcheck.project import load_project_config
22
+
23
+ APOC_INSTALL_FIX = (
24
+ "Install APOC for this Neo4j DBMS, restart Neo4j, then run `graphcheck debug` again."
25
+ )
26
+
27
+ SUPPORTED_REQUIREMENTS: tuple[CapabilityRequirement, ...] = (
28
+ "read",
29
+ "show_procedures",
30
+ "apoc",
31
+ "count_store",
32
+ "store_consistency",
33
+ )
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class CapabilityContext:
38
+ read: bool
39
+ show_procedures: bool
40
+ apoc: bool
41
+ count_store: bool
42
+ store_consistency: bool = False
43
+
44
+ @classmethod
45
+ def from_probe(cls, capabilities: Capabilities, visibility: Visibility) -> CapabilityContext:
46
+ return cls(
47
+ read=visibility.can_read,
48
+ show_procedures=visibility.can_show_procedures,
49
+ apoc=capabilities.apoc,
50
+ count_store=capabilities.count_store,
51
+ # Cypher cannot observe corrupt relationship endpoint records. A future C2 store
52
+ # consistency adapter can set this explicitly without changing SPEC-01 capabilities.
53
+ store_consistency=False,
54
+ )
55
+
56
+
57
+ def blocked_checks_for_project(
58
+ root: Path,
59
+ context: CapabilityContext,
60
+ *,
61
+ pack_paths: Iterable[Path] | None = None,
62
+ ) -> list[BlockedCheck]:
63
+ """Report checks that cannot run because a probed database capability is missing."""
64
+ config = load_project_config(root)
65
+ checks_dir = root / config.checks
66
+ if not checks_dir.exists():
67
+ return []
68
+
69
+ try:
70
+ pack_requirements = load_pack_requirements(pack_paths)
71
+ except PackCatalogError as exc:
72
+ raise GraphCheckError(
73
+ "packs.invalid",
74
+ f"Could not load check pack capability metadata: {exc}",
75
+ "Fix the check pack YAML, then run `graphcheck debug` again.",
76
+ ) from exc
77
+
78
+ blocked: list[BlockedCheck] = []
79
+ for path in _suite_paths(checks_dir):
80
+ try:
81
+ suite = load_suite(path.read_text(encoding="utf-8"), source=str(path))
82
+ except (
83
+ OSError,
84
+ yaml.YAMLError,
85
+ ValidationError,
86
+ DuplicateKeyError,
87
+ UnknownCheckError,
88
+ SuiteValidationError,
89
+ ) as exc:
90
+ raise GraphCheckError(
91
+ "checks.invalid",
92
+ f"Could not load check suite {path}: {exc}",
93
+ "Fix the check YAML, then run `graphcheck debug` again.",
94
+ ) from exc
95
+ for check in suite.checks:
96
+ if check.generated:
97
+ continue
98
+ if check.pattern.value != "conformance":
99
+ continue
100
+ requirements = pack_requirements.get(check.spec.check)
101
+ if requirements is None:
102
+ raise GraphCheckError(
103
+ "packs.requirements_missing",
104
+ f"Check {check.spec.check!r} has no capability declaration in pack metadata.",
105
+ "Add the check to its pack YAML with a non-empty `requires` list.",
106
+ )
107
+ for capability in requirements:
108
+ if _capability_present(context, capability):
109
+ continue
110
+ blocked.append(
111
+ BlockedCheck(
112
+ suite=suite.suite,
113
+ check_id=check.id,
114
+ check=check.spec.check,
115
+ missing_capability=capability,
116
+ fix=_capability_fix(context, capability),
117
+ )
118
+ )
119
+ return blocked
120
+
121
+
122
+ def _suite_paths(checks_dir: Path) -> list[Path]:
123
+ return sorted({*checks_dir.glob("*.yml"), *checks_dir.glob("*.yaml")})
124
+
125
+
126
+ def _capability_present(context: CapabilityContext, capability: CapabilityRequirement) -> bool:
127
+ if capability not in SUPPORTED_REQUIREMENTS:
128
+ raise RuntimeError(f"unsupported pack capability requirement: {capability!r}")
129
+ return bool(getattr(context, capability))
130
+
131
+
132
+ def _capability_fix(context: CapabilityContext, capability: CapabilityRequirement) -> str:
133
+ if capability == "apoc":
134
+ if not context.show_procedures:
135
+ return (
136
+ "Grant procedure visibility and execution to the configured Neo4j user; "
137
+ "if APOC is not installed, install it and restart Neo4j, then run "
138
+ "`graphcheck debug` again."
139
+ )
140
+ return APOC_INSTALL_FIX
141
+ if capability == "read":
142
+ return "Grant read access to the configured Neo4j user, then run `graphcheck debug` again."
143
+ if capability == "show_procedures":
144
+ return (
145
+ "Grant procedure visibility to the configured Neo4j user, "
146
+ "then run `graphcheck debug` again."
147
+ )
148
+ if capability == "count_store":
149
+ return "Enable count-store support for this check, then run `graphcheck debug` again."
150
+ if capability == "store_consistency":
151
+ return (
152
+ "Run Neo4j's offline consistency checker or install a connector that exposes a "
153
+ "read-only relationship-store consistency probe."
154
+ )
155
+ return f"Enable the `{capability}` capability, then run `graphcheck debug` again."
graphcheck/diff.py ADDED
@@ -0,0 +1,420 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from graphcheck.contracts.profile import BaselineProfile, ProfileStatus
9
+ from graphcheck.errors import GraphCheckError
10
+
11
+
12
+ class SchemaVersionMismatch(ValueError):
13
+ """Raised when snapshots use incompatible contracts."""
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class DiffReport:
18
+ schema_version: str
19
+ baseline_a: str
20
+ baseline_b: str
21
+ a_status: str
22
+ b_status: str
23
+ fingerprint_changed: bool
24
+ drift_detected: bool
25
+ labels: dict[str, Any]
26
+ relationship_types: dict[str, Any]
27
+ constraints: dict[str, Any]
28
+ indexes: dict[str, Any]
29
+ statistics: dict[str, Any]
30
+ summary: dict[str, Any]
31
+ a_partial_reason: str | None = None
32
+ b_partial_reason: str | None = None
33
+
34
+
35
+ def _change(name: str, before: int | float, after: int | float) -> dict[str, Any]:
36
+ delta = after - before
37
+ return {
38
+ "name": name,
39
+ "from": before,
40
+ "to": after,
41
+ "delta": delta,
42
+ "pct": None if before == 0 else round(delta / before * 100, 1),
43
+ }
44
+
45
+
46
+ def _count_collection(
47
+ a_items: list[Any], b_items: list[Any], suppress_removed: bool
48
+ ) -> dict[str, Any]:
49
+ a = {item.name: item for item in a_items}
50
+ b = {item.name: item for item in b_items}
51
+ shared = sorted(a.keys() & b.keys())
52
+ return {
53
+ "changed": [
54
+ _change(name, a[name].count, b[name].count)
55
+ for name in shared
56
+ if a[name].count != b[name].count
57
+ ],
58
+ "added": [{"name": name, "to": b[name].count} for name in sorted(b.keys() - a.keys())],
59
+ "removed": []
60
+ if suppress_removed
61
+ else [{"name": name, "from": a[name].count} for name in sorted(a.keys() - b.keys())],
62
+ "unchanged": sum(a[name].count == b[name].count for name in shared),
63
+ }
64
+
65
+
66
+ def _definition(item: Any) -> dict[str, Any]:
67
+ return {
68
+ "name": item.name,
69
+ "labels_or_types": list(item.labels_or_types),
70
+ "properties": list(item.properties),
71
+ "type": item.type,
72
+ }
73
+
74
+
75
+ def _definition_collection(
76
+ a_items: list[Any], b_items: list[Any], suppress_removed: bool
77
+ ) -> dict[str, Any]:
78
+ a = {item.name: item for item in a_items}
79
+ b = {item.name: item for item in b_items}
80
+ added, removed, unchanged = set(b) - set(a), set(a) - set(b), 0
81
+ for name in set(a) & set(b):
82
+ if a[name] == b[name]:
83
+ unchanged += 1
84
+ else:
85
+ added.add(name)
86
+ removed.add(name)
87
+ return {
88
+ "added": [_definition(b[name]) for name in sorted(added)],
89
+ "removed": [] if suppress_removed else [_definition(a[name]) for name in sorted(removed)],
90
+ "unchanged": unchanged,
91
+ }
92
+
93
+
94
+ def compare(
95
+ baseline_a: BaselineProfile,
96
+ baseline_b: BaselineProfile,
97
+ baseline_a_name: str = "baseline_a",
98
+ baseline_b_name: str = "baseline_b",
99
+ ) -> DiffReport:
100
+ """Compare profiles without formatting or recomputing their fingerprints."""
101
+ if baseline_a.status is ProfileStatus.PARTIAL or baseline_b.status is ProfileStatus.PARTIAL:
102
+ raise GraphCheckError(
103
+ "diff.partial_baseline",
104
+ "Comparison is inconclusive because one or more baselines are partial.",
105
+ "Generate complete baseline profiles before running `graphcheck diff`.",
106
+ )
107
+ if baseline_a.schema_version != baseline_b.schema_version:
108
+ raise SchemaVersionMismatch(
109
+ "cannot diff baselines with different schema_version "
110
+ f"(a={baseline_a.schema_version}, b={baseline_b.schema_version})"
111
+ )
112
+ b_partial = baseline_b.status is ProfileStatus.PARTIAL
113
+ labels = _count_collection(
114
+ baseline_a.graph_schema.labels,
115
+ baseline_b.graph_schema.labels,
116
+ b_partial and not baseline_b.graph_schema.labels,
117
+ )
118
+ relationships = _count_collection(
119
+ baseline_a.graph_schema.relationship_types,
120
+ baseline_b.graph_schema.relationship_types,
121
+ b_partial and not baseline_b.graph_schema.relationship_types,
122
+ )
123
+ constraints = _definition_collection(
124
+ baseline_a.graph_schema.constraints,
125
+ baseline_b.graph_schema.constraints,
126
+ b_partial and not baseline_b.graph_schema.constraints,
127
+ )
128
+ indexes = _definition_collection(
129
+ baseline_a.graph_schema.indexes,
130
+ baseline_b.graph_schema.indexes,
131
+ b_partial and not baseline_b.graph_schema.indexes,
132
+ )
133
+ a_cov = {
134
+ (x.owner, x.owner_name, x.property): x for x in baseline_a.statistics.property_coverage
135
+ }
136
+ b_cov = {
137
+ (x.owner, x.owner_name, x.property): x for x in baseline_b.statistics.property_coverage
138
+ }
139
+ cov_changed = []
140
+
141
+ def order(key: tuple[str, str, str]) -> tuple[str, str, str]:
142
+ return (key[1], key[2], key[0])
143
+
144
+ for key in sorted(a_cov.keys() & b_cov.keys(), key=order):
145
+ old, new = a_cov[key], b_cov[key]
146
+ if old.coverage != new.coverage:
147
+ cov_changed.append(
148
+ {
149
+ "owner": old.owner,
150
+ "owner_name": old.owner_name,
151
+ "property": old.property,
152
+ "from": old.coverage,
153
+ "to": new.coverage,
154
+ "delta_pp": round(new.coverage - old.coverage, 1),
155
+ }
156
+ )
157
+ coverage = {
158
+ "changed": cov_changed,
159
+ "added": [b_cov[k].model_dump() for k in sorted(b_cov.keys() - a_cov.keys(), key=order)],
160
+ "removed": []
161
+ if b_partial and not b_cov
162
+ else [a_cov[k].model_dump() for k in sorted(a_cov.keys() - b_cov.keys(), key=order)],
163
+ "unchanged": len(a_cov.keys() & b_cov.keys()) - len(cov_changed),
164
+ }
165
+ a_labels = {x.name: x for x in baseline_a.graph_schema.labels}
166
+ b_labels = {x.name: x for x in baseline_b.graph_schema.labels}
167
+ degree: dict[str, Any] = {}
168
+ for name in sorted(a_labels.keys() & b_labels.keys()):
169
+ old, new = a_labels[name].degree_distribution, b_labels[name].degree_distribution
170
+ if old is None or new is None:
171
+ degree[name] = None
172
+ elif old != new:
173
+ degree[name] = {"from": old.model_dump(), "to": new.model_dump()}
174
+ statistics = {
175
+ "node_count": None
176
+ if baseline_a.statistics.node_count == baseline_b.statistics.node_count
177
+ else _change(
178
+ "node_count", baseline_a.statistics.node_count, baseline_b.statistics.node_count
179
+ ),
180
+ "relationship_count": None
181
+ if baseline_a.statistics.relationship_count == baseline_b.statistics.relationship_count
182
+ else _change(
183
+ "relationship_count",
184
+ baseline_a.statistics.relationship_count,
185
+ baseline_b.statistics.relationship_count,
186
+ ),
187
+ "property_coverage": coverage,
188
+ "degree_distribution": degree,
189
+ }
190
+ summary = {
191
+ "labels": {
192
+ "changed": len(labels["changed"]),
193
+ "added": len(labels["added"]),
194
+ "removed": len(labels["removed"]),
195
+ },
196
+ "relationship_types": {
197
+ "changed": len(relationships["changed"]),
198
+ "added": len(relationships["added"]),
199
+ "removed": len(relationships["removed"]),
200
+ },
201
+ "constraints": {"added": len(constraints["added"]), "removed": len(constraints["removed"])},
202
+ "indexes": {"added": len(indexes["added"]), "removed": len(indexes["removed"])},
203
+ "statistics": {
204
+ "changed": sum(
205
+ (
206
+ statistics["node_count"] is not None,
207
+ statistics["relationship_count"] is not None,
208
+ bool(cov_changed or coverage["added"] or coverage["removed"]),
209
+ any(item is not None for item in degree.values()),
210
+ )
211
+ )
212
+ },
213
+ }
214
+ fingerprint_changed = baseline_a.fingerprint != baseline_b.fingerprint
215
+ drift_detected = any(
216
+ (
217
+ fingerprint_changed,
218
+ bool(labels["changed"] or labels["added"] or labels["removed"]),
219
+ bool(relationships["changed"] or relationships["added"] or relationships["removed"]),
220
+ bool(constraints["added"] or constraints["removed"]),
221
+ bool(indexes["added"] or indexes["removed"]),
222
+ statistics["node_count"] is not None,
223
+ statistics["relationship_count"] is not None,
224
+ bool(coverage["changed"] or coverage["added"] or coverage["removed"]),
225
+ any(item is not None for item in degree.values()),
226
+ )
227
+ )
228
+ return DiffReport(
229
+ baseline_a.schema_version,
230
+ Path(baseline_a_name).name,
231
+ Path(baseline_b_name).name,
232
+ str(baseline_a.status),
233
+ str(baseline_b.status),
234
+ fingerprint_changed,
235
+ drift_detected,
236
+ labels,
237
+ relationships,
238
+ constraints,
239
+ indexes,
240
+ statistics,
241
+ summary,
242
+ baseline_a.partial_reason,
243
+ baseline_b.partial_reason,
244
+ )
245
+
246
+
247
+ def _number(value: int | float) -> str:
248
+ return f"{value:,.0f}" if float(value).is_integer() else f"{value:,}"
249
+
250
+
251
+ def _count_line(item: dict[str, Any], prefix: str = "") -> str:
252
+ before, after, delta = item.get("from", 0), item.get("to", 0), item.get("delta")
253
+ if "from" not in item:
254
+ note = "new"
255
+ elif "to" not in item:
256
+ after, note = 0, "removed"
257
+ else:
258
+ pct = item["pct"]
259
+ note = f"{delta:+,}, n/a" if pct is None else f"{delta:+,}, {pct:+.1f}%"
260
+ return f"{prefix}{item['name']} {_number(before)} → {_number(after)} ({note})"
261
+
262
+
263
+ def _render_counts(title: str, section: dict[str, Any]) -> list[str]:
264
+ body: list[str] = []
265
+ for item in section["changed"]:
266
+ body.append(_count_line(item))
267
+ for item in section["added"]:
268
+ body.append(_count_line(item, "+ "))
269
+ for item in section["removed"]:
270
+ body.append(_count_line(item, "- "))
271
+ if body and section["unchanged"]:
272
+ body.append(f"({section['unchanged']} unchanged)")
273
+ return [title, *body] if body else []
274
+
275
+
276
+ def render_human(report: DiffReport) -> str:
277
+ lines = [
278
+ f"diff {report.baseline_a} → {report.baseline_b}",
279
+ f"fingerprint: {'CHANGED' if report.fingerprint_changed else 'MATCH'}",
280
+ ]
281
+ partial = False
282
+ if report.a_status == str(ProfileStatus.PARTIAL):
283
+ lines.extend(("", f"warning: baseline_a is PARTIAL — {report.a_partial_reason}"))
284
+ partial = True
285
+ if report.b_status == str(ProfileStatus.PARTIAL):
286
+ lines.extend(("", f"warning: baseline_b is PARTIAL — {report.b_partial_reason}"))
287
+ partial = True
288
+ if partial:
289
+ lines.append("Collections missing due to partial status are not reported as removed.")
290
+ if not report.drift_detected:
291
+ return "\n".join((*lines, "", "No drift detected."))
292
+ sections = [
293
+ _render_counts("Labels", report.labels),
294
+ _render_counts("Relationships", report.relationship_types),
295
+ ]
296
+ for title, collection in (("Constraints", report.constraints), ("Indexes", report.indexes)):
297
+ body = []
298
+ for sign, key in (("+", "added"), ("-", "removed")):
299
+ for item in collection[key]:
300
+ target, props = ", ".join(item["labels_or_types"]), ", ".join(item["properties"])
301
+ body.append(f"{sign} {item['name']} [{target}({props}), {item['type']}] ({key})")
302
+ sections.append([title, *body] if body else [])
303
+ stats: list[str] = []
304
+ for label, key in (("Nodes", "node_count"), ("Relationships", "relationship_count")):
305
+ if report.statistics[key]:
306
+ stats.append(_count_line({**report.statistics[key], "name": label}))
307
+ for item in report.statistics["property_coverage"]["changed"]:
308
+ stats.append(
309
+ f"{item['owner_name']}.{item['property']} cover "
310
+ f"{item['from']:.1f}% → {item['to']:.1f}% ({item['delta_pp']:+.1f} pp)"
311
+ )
312
+
313
+ for item in report.statistics["property_coverage"]["added"]:
314
+ stats.append(
315
+ f"+ {item['owner_name']}.{item['property']} cover {item['coverage']:.1f}% (new)"
316
+ )
317
+
318
+ for item in report.statistics["property_coverage"]["removed"]:
319
+ stats.append(
320
+ f"- {item['owner_name']}.{item['property']} cover {item['coverage']:.1f}% (removed)"
321
+ )
322
+ for name, item in report.statistics["degree_distribution"].items():
323
+ if item is not None:
324
+ stats.append(f"{name} degree distribution")
325
+ for field, before in item["from"].items():
326
+ after = item["to"][field]
327
+ delta = round(after - before, 1)
328
+ stats.append(f"{field}: {_number(before)} → {_number(after)} ({delta:+g})")
329
+ sections.append(["Statistics", *stats] if stats else [])
330
+ for section in sections:
331
+ if section:
332
+ lines.extend(("", *section))
333
+ summary = report.summary
334
+ parts: list[str] = []
335
+ labels = summary["labels"]
336
+ if any(labels.values()):
337
+ noun = "label" if labels["changed"] == 1 else "labels"
338
+ parts.append(
339
+ f"{labels['changed']} {noun} changed, {labels['added']} added, "
340
+ f"{labels['removed']} removed"
341
+ )
342
+ relationships = summary["relationship_types"]
343
+ if any(relationships.values()):
344
+ noun = "relationship" if relationships["changed"] == 1 else "relationships"
345
+ parts.append(
346
+ f"{relationships['changed']} {noun} changed, {relationships['added']} added, "
347
+ f"{relationships['removed']} removed"
348
+ )
349
+ for key, singular in (("constraints", "constraint"), ("indexes", "index")):
350
+ values = summary[key]
351
+ details = []
352
+ for action in ("added", "removed"):
353
+ count = values[action]
354
+ if count:
355
+ if singular == "index":
356
+ noun = "index" if count == 1 else "indexes"
357
+ else:
358
+ noun = singular if count == 1 else f"{singular}s"
359
+ details.append(f"{count} {noun} {action}")
360
+ if details:
361
+ parts.append(", ".join(details))
362
+ changed = summary["statistics"]["changed"]
363
+ if changed:
364
+ noun = "statistic" if changed == 1 else "statistics"
365
+ parts.append(f"{changed} {noun} changed")
366
+ if parts:
367
+ lines.extend(("", f"Summary: {' · '.join(parts)}"))
368
+ return "\n".join(lines)
369
+
370
+
371
+ def render_json(report: DiffReport) -> str:
372
+ statistics = {
373
+ **report.statistics,
374
+ "node_count": (
375
+ None
376
+ if report.statistics["node_count"] is None
377
+ else {
378
+ key: value
379
+ for key, value in report.statistics["node_count"].items()
380
+ if key != "name"
381
+ }
382
+ ),
383
+ "relationship_count": (
384
+ None
385
+ if report.statistics["relationship_count"] is None
386
+ else {
387
+ key: value
388
+ for key, value in report.statistics["relationship_count"].items()
389
+ if key != "name"
390
+ }
391
+ ),
392
+ }
393
+ payload = {
394
+ "schema_version": report.schema_version,
395
+ "baseline_a": report.baseline_a,
396
+ "baseline_b": report.baseline_b,
397
+ "a_status": report.a_status,
398
+ "b_status": report.b_status,
399
+ "fingerprint_changed": report.fingerprint_changed,
400
+ "drift_detected": report.drift_detected,
401
+ "labels": report.labels,
402
+ "relationship_types": report.relationship_types,
403
+ "constraints": {
404
+ "added": report.constraints["added"],
405
+ "removed": report.constraints["removed"],
406
+ },
407
+ "indexes": {
408
+ "added": report.indexes["added"],
409
+ "removed": report.indexes["removed"],
410
+ },
411
+ "statistics": statistics,
412
+ "summary": report.summary,
413
+ }
414
+ return json.dumps(payload, indent=2)
415
+
416
+
417
+ def diff(current_baseline: BaselineProfile, latest_baseline: BaselineProfile) -> list[str]:
418
+ """Compatibility wrapper for the original line-oriented API."""
419
+ report = compare(current_baseline, latest_baseline)
420
+ return [] if not report.drift_detected else render_human(report).splitlines()[3:]
@@ -0,0 +1,64 @@
1
+ from graphcheck.engine.baseline import (
2
+ BaselineProvider,
3
+ BaselineValue,
4
+ DirectoryBaselineProvider,
5
+ MappingBaselineProvider,
6
+ )
7
+ from graphcheck.engine.compiler import (
8
+ CompiledCheck,
9
+ ConformancePlan,
10
+ CypherCompiler,
11
+ EvidenceCondition,
12
+ compile_check,
13
+ register_conformance_compiler,
14
+ )
15
+ from graphcheck.engine.evaluator import Evaluation, VerdictEvaluator, evaluate_check
16
+ from graphcheck.engine.executor import ExecutionResult, Executor, ReadOnlyExecutor, execute_query
17
+ from graphcheck.engine.runner import (
18
+ Engine,
19
+ EngineConfig,
20
+ SuiteInput,
21
+ YamlSuiteInput,
22
+ failed_results,
23
+ run_suite,
24
+ run_suite_yaml,
25
+ )
26
+ from graphcheck.engine.sampling import (
27
+ SamplingDecision,
28
+ SamplingPolicy,
29
+ derive_check_seed,
30
+ deterministic_sample_indices,
31
+ wilson_estimate,
32
+ )
33
+
34
+ __all__ = [
35
+ "BaselineProvider",
36
+ "BaselineValue",
37
+ "CompiledCheck",
38
+ "ConformancePlan",
39
+ "CypherCompiler",
40
+ "DirectoryBaselineProvider",
41
+ "Engine",
42
+ "EngineConfig",
43
+ "EvidenceCondition",
44
+ "Evaluation",
45
+ "ExecutionResult",
46
+ "Executor",
47
+ "MappingBaselineProvider",
48
+ "ReadOnlyExecutor",
49
+ "SamplingDecision",
50
+ "SamplingPolicy",
51
+ "SuiteInput",
52
+ "YamlSuiteInput",
53
+ "VerdictEvaluator",
54
+ "compile_check",
55
+ "derive_check_seed",
56
+ "deterministic_sample_indices",
57
+ "evaluate_check",
58
+ "execute_query",
59
+ "failed_results",
60
+ "register_conformance_compiler",
61
+ "run_suite",
62
+ "run_suite_yaml",
63
+ "wilson_estimate",
64
+ ]