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,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
+ )