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,409 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import Counter
|
|
4
|
+
from datetime import datetime, timedelta
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
from pydantic import (
|
|
9
|
+
BaseModel,
|
|
10
|
+
ConfigDict,
|
|
11
|
+
Field,
|
|
12
|
+
PrivateAttr,
|
|
13
|
+
ValidationInfo,
|
|
14
|
+
field_validator,
|
|
15
|
+
model_validator,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from graphcheck.scoring import SEVERITY_WEIGHTS, calculate_score, calculate_suite_scores
|
|
19
|
+
|
|
20
|
+
SCHEMA_VERSION = "1.2"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Verdict(StrEnum):
|
|
24
|
+
PASS = "pass"
|
|
25
|
+
FAIL = "fail"
|
|
26
|
+
WARN = "warn"
|
|
27
|
+
ERRORED = "errored"
|
|
28
|
+
SKIPPED = "skipped"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Severity(StrEnum):
|
|
32
|
+
ERROR = "error"
|
|
33
|
+
WARN = "warn"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Pattern(StrEnum):
|
|
37
|
+
CONFORMANCE = "conformance"
|
|
38
|
+
DRIFT = "drift"
|
|
39
|
+
COMPETENCY_SHAPE = "competency-shape"
|
|
40
|
+
COMPETENCY_REGRESSION = "competency-regression"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SkipReason(StrEnum):
|
|
44
|
+
GENERATED = "generated"
|
|
45
|
+
UNSUPPORTED = "unsupported"
|
|
46
|
+
NOT_RUN = "not_run"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class RunStatus(StrEnum):
|
|
50
|
+
COMPLETE = "complete"
|
|
51
|
+
PARTIAL = "partial"
|
|
52
|
+
FAILED = "failed"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class RedactionPolicy(StrEnum):
|
|
56
|
+
NONE = "none"
|
|
57
|
+
MASK = "mask"
|
|
58
|
+
HASH = "hash"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
WEIGHTS: dict[Severity, int] = {severity: SEVERITY_WEIGHTS[severity.value] for severity in Severity}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def parse_utc_timestamp(value: str) -> datetime:
|
|
65
|
+
"""Parse a frozen results timestamp and require an explicit UTC offset."""
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
parsed = datetime.fromisoformat(value)
|
|
69
|
+
except ValueError as exc:
|
|
70
|
+
raise ValueError("timestamp must be a valid ISO 8601 datetime") from exc
|
|
71
|
+
if parsed.tzinfo is None or parsed.utcoffset() != timedelta(0):
|
|
72
|
+
raise ValueError("timestamp must include an explicit UTC offset")
|
|
73
|
+
return parsed
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class _Strict(BaseModel):
|
|
77
|
+
model_config = ConfigDict(extra="forbid")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class EvidenceElement(_Strict):
|
|
81
|
+
kind: Literal["node", "rel", "aggregate"]
|
|
82
|
+
id: str
|
|
83
|
+
labels: list[str] | None = None
|
|
84
|
+
type: str | None = None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class Evidence(_Strict):
|
|
88
|
+
message: str
|
|
89
|
+
elements: list[EvidenceElement] = Field(min_length=1)
|
|
90
|
+
truncated: bool
|
|
91
|
+
cap: int
|
|
92
|
+
total_count: int
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class Estimate(_Strict):
|
|
96
|
+
sample_size: int
|
|
97
|
+
population: int
|
|
98
|
+
confidence: float
|
|
99
|
+
ci: tuple[float, float] | None = None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class CheckError(_Strict):
|
|
103
|
+
code: str
|
|
104
|
+
message: str
|
|
105
|
+
fix: str
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class CheckResult(_Strict):
|
|
109
|
+
# SPEC-01 freezes the shape: every key is present. The nullable/false keys carry
|
|
110
|
+
# null/false when unused (no defaults), so a producer that omits one fails validation.
|
|
111
|
+
id: str
|
|
112
|
+
suite_id: str
|
|
113
|
+
pattern: Pattern
|
|
114
|
+
name: str
|
|
115
|
+
provenance: str | None
|
|
116
|
+
severity: Severity
|
|
117
|
+
verdict: Verdict
|
|
118
|
+
skip_reason: SkipReason | None
|
|
119
|
+
started_at: str | None
|
|
120
|
+
duration_ms: int | None
|
|
121
|
+
compiled_query: str | None
|
|
122
|
+
params: dict[str, object] | None
|
|
123
|
+
measured: dict[str, object] | None
|
|
124
|
+
expected: dict[str, object]
|
|
125
|
+
estimate: Estimate | Literal[False]
|
|
126
|
+
evidence: Evidence | None
|
|
127
|
+
error: CheckError | None
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def executed(self) -> bool:
|
|
131
|
+
return self.verdict is not Verdict.SKIPPED
|
|
132
|
+
|
|
133
|
+
@model_validator(mode="after")
|
|
134
|
+
def _field_presence(self) -> CheckResult:
|
|
135
|
+
v = self.verdict
|
|
136
|
+
run_outcomes = (Verdict.PASS, Verdict.FAIL, Verdict.WARN)
|
|
137
|
+
|
|
138
|
+
# An assertion failure encodes its severity in the verdict (SPEC-01 rule 1): a fail is an
|
|
139
|
+
# error-severity failure, a warn is a warn-severity failure. Reject mismatches — otherwise
|
|
140
|
+
# exit_code could downgrade (e.g. severity:error + verdict:warn would exit 2, not 1).
|
|
141
|
+
if v is Verdict.FAIL and self.severity is not Severity.ERROR:
|
|
142
|
+
raise ValueError(f"fail check {self.id!r} must have severity:error")
|
|
143
|
+
if v is Verdict.WARN and self.severity is not Severity.WARN:
|
|
144
|
+
raise ValueError(f"warn check {self.id!r} must have severity:warn")
|
|
145
|
+
|
|
146
|
+
# Only an attempted, measured check can be sampled; errored/skipped are never estimates.
|
|
147
|
+
if v not in run_outcomes and self.estimate is not False:
|
|
148
|
+
raise ValueError(f"{v.value} check {self.id!r} must have estimate=false")
|
|
149
|
+
|
|
150
|
+
if v in (Verdict.FAIL, Verdict.WARN):
|
|
151
|
+
if self.evidence is None:
|
|
152
|
+
raise ValueError(f"{v.value} check {self.id!r} must carry evidence")
|
|
153
|
+
elif self.evidence is not None:
|
|
154
|
+
raise ValueError(f"{v.value} check {self.id!r} must not carry evidence")
|
|
155
|
+
|
|
156
|
+
if v is Verdict.ERRORED:
|
|
157
|
+
if self.error is None:
|
|
158
|
+
raise ValueError(f"errored check {self.id!r} must carry error")
|
|
159
|
+
elif self.error is not None:
|
|
160
|
+
raise ValueError(f"non-errored check {self.id!r} must not carry error")
|
|
161
|
+
|
|
162
|
+
if v is Verdict.SKIPPED:
|
|
163
|
+
if self.skip_reason is None:
|
|
164
|
+
raise ValueError(f"skipped check {self.id!r} must carry skip_reason")
|
|
165
|
+
for field in ("started_at", "duration_ms", "compiled_query", "params", "measured"):
|
|
166
|
+
if getattr(self, field) is not None:
|
|
167
|
+
raise ValueError(f"skipped check {self.id!r} must have null {field}")
|
|
168
|
+
return self # nothing executed; the checks below are for attempted checks
|
|
169
|
+
|
|
170
|
+
if self.skip_reason is not None:
|
|
171
|
+
raise ValueError(f"non-skipped check {self.id!r} must not carry skip_reason")
|
|
172
|
+
|
|
173
|
+
# Every attempted check (pass/fail/warn/errored) has timing.
|
|
174
|
+
if self.started_at is None or self.duration_ms is None:
|
|
175
|
+
raise ValueError(f"attempted check {self.id!r} must carry started_at and duration_ms")
|
|
176
|
+
|
|
177
|
+
if v in run_outcomes:
|
|
178
|
+
for field in ("compiled_query", "params", "measured"):
|
|
179
|
+
if getattr(self, field) is None:
|
|
180
|
+
raise ValueError(f"{v.value} check {self.id!r} must carry {field}")
|
|
181
|
+
elif self.measured is not None: # v is ERRORED here
|
|
182
|
+
raise ValueError(
|
|
183
|
+
f"errored check {self.id!r} must not carry measured (it did not measure)"
|
|
184
|
+
)
|
|
185
|
+
return self
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def score_value(checks: list[CheckResult]) -> int | None:
|
|
189
|
+
"""Compatibility wrapper for the canonical scorer."""
|
|
190
|
+
|
|
191
|
+
return calculate_score(checks).value
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def totals(checks: list[CheckResult]) -> dict[str, int]:
|
|
195
|
+
counts = Counter(c.verdict for c in checks)
|
|
196
|
+
return {
|
|
197
|
+
"checks": len(checks),
|
|
198
|
+
"pass": counts[Verdict.PASS],
|
|
199
|
+
"fail": counts[Verdict.FAIL],
|
|
200
|
+
"warn": counts[Verdict.WARN],
|
|
201
|
+
"errored": counts[Verdict.ERRORED],
|
|
202
|
+
"skipped": counts[Verdict.SKIPPED],
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def exit_code(status: RunStatus, checks: list[CheckResult]) -> int:
|
|
207
|
+
if status is RunStatus.FAILED:
|
|
208
|
+
return 3
|
|
209
|
+
hard = any(
|
|
210
|
+
c.verdict is Verdict.FAIL
|
|
211
|
+
or (
|
|
212
|
+
c.verdict is Verdict.ERRORED
|
|
213
|
+
and c.severity is Severity.ERROR
|
|
214
|
+
and not (
|
|
215
|
+
status is RunStatus.PARTIAL
|
|
216
|
+
and c.error is not None
|
|
217
|
+
and c.error.code == "engine.timeout"
|
|
218
|
+
)
|
|
219
|
+
)
|
|
220
|
+
for c in checks
|
|
221
|
+
)
|
|
222
|
+
if hard:
|
|
223
|
+
return 1
|
|
224
|
+
nothing_evaluated = not any(c.executed for c in checks)
|
|
225
|
+
soft = any(
|
|
226
|
+
c.verdict is Verdict.WARN or (c.verdict is Verdict.ERRORED and c.severity is Severity.WARN)
|
|
227
|
+
for c in checks
|
|
228
|
+
)
|
|
229
|
+
if status is RunStatus.PARTIAL or nothing_evaluated or soft:
|
|
230
|
+
return 2
|
|
231
|
+
return 0
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class Totals(BaseModel):
|
|
235
|
+
# No populate_by_name: the external key is the frozen `pass` alias only, never `passed`.
|
|
236
|
+
model_config = ConfigDict(extra="forbid")
|
|
237
|
+
checks: int
|
|
238
|
+
passed: int = Field(alias="pass")
|
|
239
|
+
fail: int
|
|
240
|
+
warn: int
|
|
241
|
+
errored: int
|
|
242
|
+
skipped: int
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class Score(_Strict):
|
|
246
|
+
# Frozen shape: a non-null score is { value, method, weights } — all present, no defaults.
|
|
247
|
+
value: int
|
|
248
|
+
method: Literal["weighted-by-severity"]
|
|
249
|
+
weights: dict[str, int]
|
|
250
|
+
|
|
251
|
+
@model_validator(mode="after")
|
|
252
|
+
def _weights_locked(self) -> Score:
|
|
253
|
+
expected_weights = dict(SEVERITY_WEIGHTS)
|
|
254
|
+
if self.weights != expected_weights:
|
|
255
|
+
raise ValueError(f"score.weights are hard-coded in v0: {expected_weights}")
|
|
256
|
+
return self
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class Capabilities(_Strict):
|
|
260
|
+
apoc: bool
|
|
261
|
+
count_store: bool
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class RunTarget(_Strict):
|
|
265
|
+
database: str
|
|
266
|
+
server_version: str
|
|
267
|
+
edition: str
|
|
268
|
+
fingerprint: str
|
|
269
|
+
capabilities: Capabilities
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class ResultsTarget(RunTarget):
|
|
273
|
+
nodes: int | None = Field(default=None, ge=0)
|
|
274
|
+
relationships: int | None = Field(default=None, ge=0)
|
|
275
|
+
labels: list[str] | None
|
|
276
|
+
relationship_types: list[str] | None
|
|
277
|
+
|
|
278
|
+
@field_validator("labels", "relationship_types")
|
|
279
|
+
@classmethod
|
|
280
|
+
def _inventory_is_canonical(cls, value: list[str] | None) -> list[str] | None:
|
|
281
|
+
if value is not None and value != sorted(set(value)):
|
|
282
|
+
raise ValueError("target inventory must be sorted and contain no duplicates")
|
|
283
|
+
return value
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
class Selection(_Strict):
|
|
287
|
+
suites: list[str]
|
|
288
|
+
tags: list[str]
|
|
289
|
+
fail_fast: bool
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class Redaction(_Strict):
|
|
293
|
+
policy: RedactionPolicy
|
|
294
|
+
applied: bool
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
class Run(_Strict):
|
|
298
|
+
id: str
|
|
299
|
+
started_at: str
|
|
300
|
+
finished_at: str
|
|
301
|
+
graphcheck_version: str
|
|
302
|
+
pack_version: str
|
|
303
|
+
status: RunStatus
|
|
304
|
+
partial_reason: str | None # present-but-nullable; non-null iff status is partial
|
|
305
|
+
exit_code: int
|
|
306
|
+
selection: Selection
|
|
307
|
+
redaction: Redaction
|
|
308
|
+
target: ResultsTarget | None # present-but-nullable; null only for failed runs
|
|
309
|
+
error: CheckError | None # present-but-nullable; non-null only for failed runs
|
|
310
|
+
|
|
311
|
+
@field_validator("started_at", "finished_at")
|
|
312
|
+
@classmethod
|
|
313
|
+
def _timestamps_are_utc(cls, value: str) -> str:
|
|
314
|
+
parse_utc_timestamp(value)
|
|
315
|
+
return value
|
|
316
|
+
|
|
317
|
+
@model_validator(mode="after")
|
|
318
|
+
def _timestamps_are_ordered(self) -> Run:
|
|
319
|
+
if parse_utc_timestamp(self.finished_at) < parse_utc_timestamp(self.started_at):
|
|
320
|
+
raise ValueError("finished_at must not precede started_at")
|
|
321
|
+
return self
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
class Suite(_Strict):
|
|
325
|
+
id: str
|
|
326
|
+
source_sha: str
|
|
327
|
+
score: int | None
|
|
328
|
+
totals: Totals
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
class Results(_Strict):
|
|
332
|
+
schema_version: Literal["1.2"] # frozen top-level key, required and present
|
|
333
|
+
run: Run
|
|
334
|
+
score: Score | None
|
|
335
|
+
totals: Totals
|
|
336
|
+
suites: list[Suite]
|
|
337
|
+
checks: list[CheckResult]
|
|
338
|
+
_historical_schema_version: str | None = PrivateAttr(default=None)
|
|
339
|
+
|
|
340
|
+
@model_validator(mode="after")
|
|
341
|
+
def _consistency(self, info: ValidationInfo) -> Results:
|
|
342
|
+
status = self.run.status
|
|
343
|
+
if status is RunStatus.FAILED:
|
|
344
|
+
if self.run.error is None:
|
|
345
|
+
raise ValueError("failed run must carry run.error")
|
|
346
|
+
if self.checks or self.suites or self.score is not None:
|
|
347
|
+
raise ValueError("failed run must have empty checks/suites and null score")
|
|
348
|
+
elif self.run.error is not None:
|
|
349
|
+
raise ValueError("non-failed run must not carry run.error")
|
|
350
|
+
|
|
351
|
+
if status in (RunStatus.COMPLETE, RunStatus.PARTIAL) and self.run.target is None:
|
|
352
|
+
raise ValueError("complete/partial run must carry run.target")
|
|
353
|
+
historical = (info.context or {}).get("historical_schema_version") in {"1.0", "1.1"}
|
|
354
|
+
if (
|
|
355
|
+
self.run.target is not None
|
|
356
|
+
and (self.run.target.labels is None or self.run.target.relationship_types is None)
|
|
357
|
+
and not historical
|
|
358
|
+
):
|
|
359
|
+
raise ValueError("schema 1.2 target inventory must contain non-null arrays")
|
|
360
|
+
|
|
361
|
+
if (self.run.partial_reason is not None) != (status is RunStatus.PARTIAL):
|
|
362
|
+
raise ValueError("partial_reason must be non-null iff status is partial")
|
|
363
|
+
|
|
364
|
+
expected_totals = totals(self.checks)
|
|
365
|
+
if self.totals.model_dump(by_alias=True) != expected_totals:
|
|
366
|
+
raise ValueError(f"totals must equal the tally of checks: {expected_totals}")
|
|
367
|
+
|
|
368
|
+
expected_score = calculate_score(self.checks).value
|
|
369
|
+
if expected_score is None:
|
|
370
|
+
if self.score is not None:
|
|
371
|
+
raise ValueError("score must be null when no check executed")
|
|
372
|
+
elif self.score is None or self.score.value != expected_score:
|
|
373
|
+
raise ValueError(f"score.value must be {expected_score}")
|
|
374
|
+
|
|
375
|
+
expected_exit = exit_code(status, self.checks)
|
|
376
|
+
if self.run.exit_code != expected_exit:
|
|
377
|
+
raise ValueError(f"exit_code must be {expected_exit}")
|
|
378
|
+
|
|
379
|
+
has_gap = any(
|
|
380
|
+
c.skip_reason in (SkipReason.UNSUPPORTED, SkipReason.NOT_RUN) for c in self.checks
|
|
381
|
+
)
|
|
382
|
+
if has_gap and status is not RunStatus.PARTIAL:
|
|
383
|
+
raise ValueError("an unsupported/not_run skip requires run.status:partial")
|
|
384
|
+
|
|
385
|
+
identities = [(c.suite_id, c.id) for c in self.checks]
|
|
386
|
+
if len(identities) != len(set(identities)):
|
|
387
|
+
raise ValueError("check identity (suite_id, id) must be unique across checks[]")
|
|
388
|
+
if len({s.id for s in self.suites}) != len(self.suites):
|
|
389
|
+
raise ValueError("suite ids must be unique in suites[]")
|
|
390
|
+
|
|
391
|
+
by_suite: dict[str, list[CheckResult]] = {}
|
|
392
|
+
for c in self.checks:
|
|
393
|
+
by_suite.setdefault(c.suite_id, []).append(c)
|
|
394
|
+
suite_ids = {s.id for s in self.suites}
|
|
395
|
+
for suite_id in by_suite:
|
|
396
|
+
if suite_id not in suite_ids:
|
|
397
|
+
raise ValueError(f"check suite_id {suite_id!r} has no matching suites[] entry")
|
|
398
|
+
suite_scores = calculate_suite_scores(self.checks)
|
|
399
|
+
for suite in self.suites:
|
|
400
|
+
members = by_suite.get(suite.id, [])
|
|
401
|
+
expected_suite_score = suite_scores.get(suite.id)
|
|
402
|
+
expected_suite_value = (
|
|
403
|
+
None if expected_suite_score is None else expected_suite_score.value
|
|
404
|
+
)
|
|
405
|
+
if suite.score != expected_suite_value:
|
|
406
|
+
raise ValueError(f"suite {suite.id!r} score is inconsistent with its checks")
|
|
407
|
+
if suite.totals.model_dump(by_alias=True) != totals(members):
|
|
408
|
+
raise ValueError(f"suite {suite.id!r} totals are inconsistent with its checks")
|
|
409
|
+
return self
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from typing import Annotated
|
|
2
|
+
|
|
3
|
+
from pydantic import BeforeValidator, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _normalize_json_schema_integer(value: object) -> object:
|
|
7
|
+
"""Match Draft 2020-12 integer semantics without general coercion."""
|
|
8
|
+
if type(value) is float and value.is_integer():
|
|
9
|
+
return int(value)
|
|
10
|
+
return value
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
JsonSchemaInteger = Annotated[int, BeforeValidator(_normalize_json_schema_integer)]
|
|
14
|
+
NonNegativeJsonSchemaInteger = Annotated[
|
|
15
|
+
int,
|
|
16
|
+
Field(ge=0),
|
|
17
|
+
BeforeValidator(_normalize_json_schema_integer),
|
|
18
|
+
]
|
|
19
|
+
PositiveJsonSchemaInteger = Annotated[
|
|
20
|
+
int,
|
|
21
|
+
Field(gt=0),
|
|
22
|
+
BeforeValidator(_normalize_json_schema_integer),
|
|
23
|
+
]
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from pydantic import TypeAdapter
|
|
5
|
+
|
|
6
|
+
from graphcheck.contracts.check import _SuiteFile
|
|
7
|
+
from graphcheck.contracts.profile import BaselineProfile
|
|
8
|
+
from graphcheck.contracts.results import Results
|
|
9
|
+
from graphcheck.packs import PACK_VERSION, REGISTRY
|
|
10
|
+
from graphcheck.packs.metadata import PackMetadata
|
|
11
|
+
|
|
12
|
+
SPECS_DIR = Path(__file__).resolve().parents[3] / "docs" / "specs"
|
|
13
|
+
JSON_SCHEMA_2020_12 = "https://json-schema.org/draft/2020-12/schema"
|
|
14
|
+
_CHECK_SCHEMA_COMMENT = (
|
|
15
|
+
"Portable structural contract. Consumers must additionally enforce the semantic "
|
|
16
|
+
"invariants in SPEC-02: globally unique check ids, distinct label_cooccurrence "
|
|
17
|
+
"labels, distinct rel_direction endpoint labels, and distinct temporal_sanity "
|
|
18
|
+
"properties. GraphCheck enforces them in load_suite()."
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def results_schema() -> dict:
|
|
23
|
+
return Results.model_json_schema(by_alias=True)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def profile_schema() -> dict:
|
|
27
|
+
return BaselineProfile.model_json_schema()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def write_profile_schema() -> Path:
|
|
31
|
+
SPECS_DIR.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
path = SPECS_DIR / "profile.schema.json"
|
|
33
|
+
path.write_text(json.dumps(profile_schema(), indent=2, sort_keys=True) + "\n")
|
|
34
|
+
return path
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def write_results_schema() -> Path:
|
|
38
|
+
SPECS_DIR.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
path = SPECS_DIR / "results.schema.json"
|
|
40
|
+
path.write_text(json.dumps(results_schema(), indent=2, sort_keys=True) + "\n")
|
|
41
|
+
return path
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def pack_metadata_schema() -> dict:
|
|
45
|
+
schema = TypeAdapter(PackMetadata).json_schema()
|
|
46
|
+
schema["$schema"] = JSON_SCHEMA_2020_12
|
|
47
|
+
schema["x-pack-version"] = PACK_VERSION
|
|
48
|
+
return schema
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def validate_pack_metadata_schema(instance: object) -> None:
|
|
52
|
+
"""Validate pack metadata, including standard JSON Schema format assertions."""
|
|
53
|
+
from jsonschema import Draft202012Validator, FormatChecker
|
|
54
|
+
|
|
55
|
+
schema = pack_metadata_schema()
|
|
56
|
+
Draft202012Validator.check_schema(schema)
|
|
57
|
+
validator = Draft202012Validator(
|
|
58
|
+
schema,
|
|
59
|
+
format_checker=FormatChecker(),
|
|
60
|
+
)
|
|
61
|
+
validator.validate(instance)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def write_pack_metadata_schema() -> Path:
|
|
65
|
+
SPECS_DIR.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
path = SPECS_DIR / "pack.schema.json"
|
|
67
|
+
path.write_text(json.dumps(pack_metadata_schema(), indent=2, sort_keys=True) + "\n")
|
|
68
|
+
return path
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def check_envelope_schema() -> dict:
|
|
72
|
+
schema = _SuiteFile.model_json_schema(by_alias=True)
|
|
73
|
+
schema["$schema"] = JSON_SCHEMA_2020_12
|
|
74
|
+
schema["$comment"] = (
|
|
75
|
+
"Frozen SPEC-02 envelope only; use check.schema.json for pack-owned `with` validation."
|
|
76
|
+
)
|
|
77
|
+
return schema
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def check_combined_schema() -> dict:
|
|
81
|
+
schema = _SuiteFile.model_json_schema(by_alias=True)
|
|
82
|
+
schema["$schema"] = JSON_SCHEMA_2020_12
|
|
83
|
+
schema["$comment"] = _CHECK_SCHEMA_COMMENT
|
|
84
|
+
schema["x-pack-version"] = PACK_VERSION
|
|
85
|
+
defs = schema["$defs"]
|
|
86
|
+
# Constrain each conformance item's `with` by its `check`: move the auto-generated
|
|
87
|
+
# ConformanceCheck def aside, then redefine its $ref target as an allOf of the base
|
|
88
|
+
# plus a oneOf over the registry. The conformance array already $refs ConformanceCheck,
|
|
89
|
+
# so it picks up the constraint without touching the array schema.
|
|
90
|
+
defs["ConformanceCheckBase"] = defs["ConformanceCheck"]
|
|
91
|
+
branches = []
|
|
92
|
+
for name, model in sorted(REGISTRY.items()):
|
|
93
|
+
with_schema = model.model_json_schema()
|
|
94
|
+
if "$defs" in with_schema:
|
|
95
|
+
# A nested pack model emits internal #/$defs/... refs that dangle once inlined here.
|
|
96
|
+
# v0 pack `with` models must be flat/ref-free; hoisting $defs is C3's job when needed.
|
|
97
|
+
raise ValueError(
|
|
98
|
+
f"pack `with` model {name!r} emits $defs; v0 requires flat, ref-free pack "
|
|
99
|
+
f"schemas so they can be inlined. Hoist its $defs before registering it."
|
|
100
|
+
)
|
|
101
|
+
branches.append(
|
|
102
|
+
{
|
|
103
|
+
"properties": {"check": {"const": name}, "with": with_schema},
|
|
104
|
+
"required": ["check", "with"],
|
|
105
|
+
}
|
|
106
|
+
)
|
|
107
|
+
defs["WithByCheck"] = {"oneOf": branches}
|
|
108
|
+
defs["ConformanceCheck"] = {
|
|
109
|
+
"allOf": [
|
|
110
|
+
{"$ref": "#/$defs/ConformanceCheckBase"},
|
|
111
|
+
{"$ref": "#/$defs/WithByCheck"},
|
|
112
|
+
]
|
|
113
|
+
}
|
|
114
|
+
return schema
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def validate_check_schema(instance: object) -> None:
|
|
118
|
+
"""Validate the portable structural schema, including format assertions.
|
|
119
|
+
|
|
120
|
+
Use ``load_suite`` for the additional semantic invariants defined by SPEC-02.
|
|
121
|
+
"""
|
|
122
|
+
from jsonschema import Draft202012Validator, FormatChecker
|
|
123
|
+
|
|
124
|
+
schema = check_combined_schema()
|
|
125
|
+
Draft202012Validator.check_schema(schema)
|
|
126
|
+
validator = Draft202012Validator(
|
|
127
|
+
schema,
|
|
128
|
+
format_checker=FormatChecker(),
|
|
129
|
+
)
|
|
130
|
+
validator.validate(instance)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def write_check_schemas() -> None:
|
|
134
|
+
SPECS_DIR.mkdir(parents=True, exist_ok=True)
|
|
135
|
+
(SPECS_DIR / "check.envelope.schema.json").write_text(
|
|
136
|
+
json.dumps(check_envelope_schema(), indent=2, sort_keys=True) + "\n"
|
|
137
|
+
)
|
|
138
|
+
(SPECS_DIR / "check.schema.json").write_text(
|
|
139
|
+
json.dumps(check_combined_schema(), indent=2, sort_keys=True) + "\n"
|
|
140
|
+
)
|