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.
- graphcheck/__init__.py +9 -0
- graphcheck/application/__init__.py +0 -0
- graphcheck/application/artifacts.py +145 -0
- graphcheck/application/paths.py +6 -0
- graphcheck/application/run.py +233 -0
- graphcheck/application/suites.py +44 -0
- graphcheck/baselines.py +228 -0
- graphcheck/bootstrap.py +19 -0
- graphcheck/cli.py +2339 -0
- graphcheck/connection_profiles.py +119 -0
- graphcheck/contracts/__init__.py +0 -0
- graphcheck/contracts/check.py +258 -0
- graphcheck/contracts/profile.py +290 -0
- graphcheck/contracts/results.py +409 -0
- graphcheck/contracts/scalars.py +23 -0
- graphcheck/contracts/schemas.py +140 -0
- graphcheck/debug_diagnostics.py +155 -0
- graphcheck/diff.py +420 -0
- graphcheck/engine/__init__.py +64 -0
- graphcheck/engine/baseline.py +262 -0
- graphcheck/engine/compiler.py +577 -0
- graphcheck/engine/core_pack.py +681 -0
- graphcheck/engine/evaluator.py +982 -0
- graphcheck/engine/executor.py +192 -0
- graphcheck/engine/identifiers.py +25 -0
- graphcheck/engine/parameters.py +99 -0
- graphcheck/engine/pii_pack.py +318 -0
- graphcheck/engine/runner.py +1798 -0
- graphcheck/engine/sampling.py +316 -0
- graphcheck/errors.py +69 -0
- graphcheck/generation/__init__.py +1 -0
- graphcheck/generation/client.py +383 -0
- graphcheck/generation/config.py +100 -0
- graphcheck/generation/disclosure.py +115 -0
- graphcheck/generation/prompts.py +112 -0
- graphcheck/generation/proposals.py +271 -0
- graphcheck/generation/service.py +379 -0
- graphcheck/generation/transmission.py +311 -0
- graphcheck/generation/writer.py +116 -0
- graphcheck/mcp/adapter.py +133 -0
- graphcheck/mcp/server.py +62 -0
- graphcheck/neo4j_adapter.py +1431 -0
- graphcheck/observability/__init__.py +13 -0
- graphcheck/observability/collector.py +31 -0
- graphcheck/observability/health.py +69 -0
- graphcheck/observability/metrics.py +34 -0
- graphcheck/observability/runner.py +28 -0
- graphcheck/observability/server.py +18 -0
- graphcheck/packs/__init__.py +213 -0
- graphcheck/packs/catalog.py +106 -0
- graphcheck/packs/core.yml +113 -0
- graphcheck/packs/metadata.py +329 -0
- graphcheck/packs/pii.yml +77 -0
- graphcheck/profiler.py +822 -0
- graphcheck/project.py +115 -0
- graphcheck/reporting/__init__.py +41 -0
- graphcheck/reporting/explorer.py +372 -0
- graphcheck/reporting/history.py +527 -0
- graphcheck/reporting/html.py +2239 -0
- graphcheck/reporting/presentation.py +156 -0
- graphcheck/reporting/redaction.py +321 -0
- graphcheck/reporting/writer.py +97 -0
- graphcheck/scoring.py +156 -0
- graphcheck/telemetry/__init__.py +28 -0
- graphcheck/telemetry/collector.py +296 -0
- graphcheck/telemetry/consent.py +208 -0
- graphcheck/telemetry/events.py +417 -0
- graphcheck/telemetry/inactive.py +130 -0
- graphcheck/telemetry/policy.py +769 -0
- graphcheck/telemetry/posthog.py +253 -0
- graphcheck/telemetry/release.py +10 -0
- graphcheck/telemetry/runtime.py +375 -0
- graphcheck/telemetry/types.py +222 -0
- graphcheck/yaml_loader.py +43 -0
- graphcheck-0.1.0.dist-info/METADATA +187 -0
- graphcheck-0.1.0.dist-info/RECORD +79 -0
- graphcheck-0.1.0.dist-info/WHEEL +4 -0
- graphcheck-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|
+
]
|