kontra 0.5.2__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.
- kontra/__init__.py +1871 -0
- kontra/api/__init__.py +22 -0
- kontra/api/compare.py +340 -0
- kontra/api/decorators.py +153 -0
- kontra/api/results.py +2121 -0
- kontra/api/rules.py +681 -0
- kontra/cli/__init__.py +0 -0
- kontra/cli/commands/__init__.py +1 -0
- kontra/cli/commands/config.py +153 -0
- kontra/cli/commands/diff.py +450 -0
- kontra/cli/commands/history.py +196 -0
- kontra/cli/commands/profile.py +289 -0
- kontra/cli/commands/validate.py +468 -0
- kontra/cli/constants.py +6 -0
- kontra/cli/main.py +48 -0
- kontra/cli/renderers.py +304 -0
- kontra/cli/utils.py +28 -0
- kontra/config/__init__.py +34 -0
- kontra/config/loader.py +127 -0
- kontra/config/models.py +49 -0
- kontra/config/settings.py +797 -0
- kontra/connectors/__init__.py +0 -0
- kontra/connectors/db_utils.py +251 -0
- kontra/connectors/detection.py +323 -0
- kontra/connectors/handle.py +368 -0
- kontra/connectors/postgres.py +127 -0
- kontra/connectors/sqlserver.py +226 -0
- kontra/engine/__init__.py +0 -0
- kontra/engine/backends/duckdb_session.py +227 -0
- kontra/engine/backends/duckdb_utils.py +18 -0
- kontra/engine/backends/polars_backend.py +47 -0
- kontra/engine/engine.py +1205 -0
- kontra/engine/executors/__init__.py +15 -0
- kontra/engine/executors/base.py +50 -0
- kontra/engine/executors/database_base.py +528 -0
- kontra/engine/executors/duckdb_sql.py +607 -0
- kontra/engine/executors/postgres_sql.py +162 -0
- kontra/engine/executors/registry.py +69 -0
- kontra/engine/executors/sqlserver_sql.py +163 -0
- kontra/engine/materializers/__init__.py +14 -0
- kontra/engine/materializers/base.py +42 -0
- kontra/engine/materializers/duckdb.py +110 -0
- kontra/engine/materializers/factory.py +22 -0
- kontra/engine/materializers/polars_connector.py +131 -0
- kontra/engine/materializers/postgres.py +157 -0
- kontra/engine/materializers/registry.py +138 -0
- kontra/engine/materializers/sqlserver.py +160 -0
- kontra/engine/result.py +15 -0
- kontra/engine/sql_utils.py +611 -0
- kontra/engine/sql_validator.py +609 -0
- kontra/engine/stats.py +194 -0
- kontra/engine/types.py +138 -0
- kontra/errors.py +533 -0
- kontra/logging.py +85 -0
- kontra/preplan/__init__.py +5 -0
- kontra/preplan/planner.py +253 -0
- kontra/preplan/postgres.py +179 -0
- kontra/preplan/sqlserver.py +191 -0
- kontra/preplan/types.py +24 -0
- kontra/probes/__init__.py +20 -0
- kontra/probes/compare.py +400 -0
- kontra/probes/relationship.py +283 -0
- kontra/reporters/__init__.py +0 -0
- kontra/reporters/json_reporter.py +190 -0
- kontra/reporters/rich_reporter.py +11 -0
- kontra/rules/__init__.py +35 -0
- kontra/rules/base.py +186 -0
- kontra/rules/builtin/__init__.py +40 -0
- kontra/rules/builtin/allowed_values.py +156 -0
- kontra/rules/builtin/compare.py +188 -0
- kontra/rules/builtin/conditional_not_null.py +213 -0
- kontra/rules/builtin/conditional_range.py +310 -0
- kontra/rules/builtin/contains.py +138 -0
- kontra/rules/builtin/custom_sql_check.py +182 -0
- kontra/rules/builtin/disallowed_values.py +140 -0
- kontra/rules/builtin/dtype.py +203 -0
- kontra/rules/builtin/ends_with.py +129 -0
- kontra/rules/builtin/freshness.py +240 -0
- kontra/rules/builtin/length.py +193 -0
- kontra/rules/builtin/max_rows.py +35 -0
- kontra/rules/builtin/min_rows.py +46 -0
- kontra/rules/builtin/not_null.py +121 -0
- kontra/rules/builtin/range.py +222 -0
- kontra/rules/builtin/regex.py +143 -0
- kontra/rules/builtin/starts_with.py +129 -0
- kontra/rules/builtin/unique.py +124 -0
- kontra/rules/condition_parser.py +203 -0
- kontra/rules/execution_plan.py +455 -0
- kontra/rules/factory.py +103 -0
- kontra/rules/predicates.py +25 -0
- kontra/rules/registry.py +24 -0
- kontra/rules/static_predicates.py +120 -0
- kontra/scout/__init__.py +9 -0
- kontra/scout/backends/__init__.py +17 -0
- kontra/scout/backends/base.py +111 -0
- kontra/scout/backends/duckdb_backend.py +359 -0
- kontra/scout/backends/postgres_backend.py +519 -0
- kontra/scout/backends/sqlserver_backend.py +577 -0
- kontra/scout/dtype_mapping.py +150 -0
- kontra/scout/patterns.py +69 -0
- kontra/scout/profiler.py +801 -0
- kontra/scout/reporters/__init__.py +39 -0
- kontra/scout/reporters/json_reporter.py +165 -0
- kontra/scout/reporters/markdown_reporter.py +152 -0
- kontra/scout/reporters/rich_reporter.py +144 -0
- kontra/scout/store.py +208 -0
- kontra/scout/suggest.py +200 -0
- kontra/scout/types.py +652 -0
- kontra/state/__init__.py +29 -0
- kontra/state/backends/__init__.py +79 -0
- kontra/state/backends/base.py +348 -0
- kontra/state/backends/local.py +480 -0
- kontra/state/backends/postgres.py +1010 -0
- kontra/state/backends/s3.py +543 -0
- kontra/state/backends/sqlserver.py +969 -0
- kontra/state/fingerprint.py +166 -0
- kontra/state/types.py +1061 -0
- kontra/version.py +1 -0
- kontra-0.5.2.dist-info/METADATA +122 -0
- kontra-0.5.2.dist-info/RECORD +124 -0
- kontra-0.5.2.dist-info/WHEEL +5 -0
- kontra-0.5.2.dist-info/entry_points.txt +2 -0
- kontra-0.5.2.dist-info/licenses/LICENSE +17 -0
- kontra-0.5.2.dist-info/top_level.txt +1 -0
kontra/state/types.py
ADDED
|
@@ -0,0 +1,1061 @@
|
|
|
1
|
+
# src/kontra/state/types.py
|
|
2
|
+
"""
|
|
3
|
+
State data types for validation result persistence.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from typing import Any, Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
from kontra.version import VERSION
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# -----------------------------------------------------------------------------
|
|
18
|
+
# Annotation Types (v0.5)
|
|
19
|
+
# -----------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Annotation:
|
|
24
|
+
"""
|
|
25
|
+
Annotation on a validation run or specific rule result.
|
|
26
|
+
|
|
27
|
+
Annotations provide "memory without authority" - agents and humans
|
|
28
|
+
can record context about runs (resolutions, root causes, acknowledgments)
|
|
29
|
+
without affecting Kontra's behavior.
|
|
30
|
+
|
|
31
|
+
Invariants:
|
|
32
|
+
- Append-only: annotations are never updated or deleted
|
|
33
|
+
- Uninterpreted: Kontra stores annotation_type but doesn't define vocabulary
|
|
34
|
+
- Opt-in reads: annotations never appear unless explicitly requested
|
|
35
|
+
- Never read during validation or diff
|
|
36
|
+
|
|
37
|
+
Common annotation_type values (suggested, not enforced):
|
|
38
|
+
- "resolution": I fixed this
|
|
39
|
+
- "root_cause": This failed because...
|
|
40
|
+
- "false_positive": This isn't actually a problem
|
|
41
|
+
- "acknowledged": I saw this, will address later
|
|
42
|
+
- "suppressed": Intentionally ignoring this
|
|
43
|
+
- "note": General comment
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
# Identity
|
|
47
|
+
id: Optional[int] = None # Database-assigned ID (None for new annotations)
|
|
48
|
+
run_id: int = 0 # Reference to kontra_runs.id
|
|
49
|
+
rule_result_id: Optional[int] = None # Reference to kontra_rule_results.id (None for run-level)
|
|
50
|
+
rule_id: Optional[str] = None # Semantic rule ID (e.g., "COL:email:not_null") for cross-run queries
|
|
51
|
+
|
|
52
|
+
# Who created it
|
|
53
|
+
actor_type: str = "agent" # "agent" | "human" | "system"
|
|
54
|
+
actor_id: str = "" # e.g., "repair-agent-v2", "alice@example.com"
|
|
55
|
+
|
|
56
|
+
# What it says
|
|
57
|
+
annotation_type: str = "" # Uninterpreted by Kontra
|
|
58
|
+
summary: str = "" # Human-readable summary
|
|
59
|
+
payload: Optional[Dict[str, Any]] = None # Arbitrary structured data
|
|
60
|
+
|
|
61
|
+
# When
|
|
62
|
+
created_at: Optional[datetime] = None
|
|
63
|
+
|
|
64
|
+
def __post_init__(self):
|
|
65
|
+
if self.created_at is None:
|
|
66
|
+
self.created_at = datetime.now(timezone.utc)
|
|
67
|
+
|
|
68
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
69
|
+
"""Convert to dictionary for serialization."""
|
|
70
|
+
d: Dict[str, Any] = {
|
|
71
|
+
"run_id": self.run_id,
|
|
72
|
+
"actor_type": self.actor_type,
|
|
73
|
+
"actor_id": self.actor_id,
|
|
74
|
+
"annotation_type": self.annotation_type,
|
|
75
|
+
"summary": self.summary,
|
|
76
|
+
"created_at": self.created_at.isoformat() if self.created_at else None,
|
|
77
|
+
}
|
|
78
|
+
if self.id is not None:
|
|
79
|
+
d["id"] = self.id
|
|
80
|
+
if self.rule_result_id is not None:
|
|
81
|
+
d["rule_result_id"] = self.rule_result_id
|
|
82
|
+
if self.rule_id is not None:
|
|
83
|
+
d["rule_id"] = self.rule_id
|
|
84
|
+
if self.payload is not None:
|
|
85
|
+
d["payload"] = self.payload
|
|
86
|
+
return d
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def from_dict(cls, d: Dict[str, Any]) -> "Annotation":
|
|
90
|
+
"""Create from dictionary."""
|
|
91
|
+
created_at = d.get("created_at")
|
|
92
|
+
if isinstance(created_at, str):
|
|
93
|
+
created_at = created_at.replace("Z", "+00:00")
|
|
94
|
+
created_at = datetime.fromisoformat(created_at)
|
|
95
|
+
|
|
96
|
+
return cls(
|
|
97
|
+
id=d.get("id"),
|
|
98
|
+
run_id=d.get("run_id", 0),
|
|
99
|
+
rule_result_id=d.get("rule_result_id"),
|
|
100
|
+
rule_id=d.get("rule_id"),
|
|
101
|
+
actor_type=d.get("actor_type", "agent"),
|
|
102
|
+
actor_id=d.get("actor_id", ""),
|
|
103
|
+
annotation_type=d.get("annotation_type", ""),
|
|
104
|
+
summary=d.get("summary", ""),
|
|
105
|
+
payload=d.get("payload"),
|
|
106
|
+
created_at=created_at,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def to_json(self) -> str:
|
|
110
|
+
"""Serialize to JSON string (single line for JSONL)."""
|
|
111
|
+
return json.dumps(self.to_dict(), default=str)
|
|
112
|
+
|
|
113
|
+
@classmethod
|
|
114
|
+
def from_json(cls, json_str: str) -> "Annotation":
|
|
115
|
+
"""Deserialize from JSON string."""
|
|
116
|
+
return cls.from_dict(json.loads(json_str))
|
|
117
|
+
|
|
118
|
+
def to_llm(self) -> str:
|
|
119
|
+
"""Token-optimized format for LLM context."""
|
|
120
|
+
ts = self.created_at.strftime("%Y-%m-%d %H:%M") if self.created_at else "?"
|
|
121
|
+
parts = [
|
|
122
|
+
f"[{self.annotation_type}]",
|
|
123
|
+
f"by {self.actor_type}:{self.actor_id}",
|
|
124
|
+
f"@ {ts}",
|
|
125
|
+
]
|
|
126
|
+
if self.summary:
|
|
127
|
+
parts.append(f'"{self.summary}"')
|
|
128
|
+
return " ".join(parts)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# -----------------------------------------------------------------------------
|
|
132
|
+
# Severity and Failure Mode Enums
|
|
133
|
+
# -----------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class Severity(str, Enum):
|
|
137
|
+
"""
|
|
138
|
+
Rule severity levels for pipeline control.
|
|
139
|
+
|
|
140
|
+
Severity determines how failures affect pipeline execution:
|
|
141
|
+
- BLOCKING: Fails the pipeline (exit code 1)
|
|
142
|
+
- WARNING: Warns but continues (exit code 0)
|
|
143
|
+
- INFO: Logs only, no warning (exit code 0)
|
|
144
|
+
|
|
145
|
+
Default severity is BLOCKING for backwards compatibility.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
BLOCKING = "blocking" # Fails pipeline, exit code 1
|
|
149
|
+
WARNING = "warning" # Warns but continues, exit code 0
|
|
150
|
+
INFO = "info" # Logs only, exit code 0
|
|
151
|
+
|
|
152
|
+
def __str__(self) -> str:
|
|
153
|
+
return self.value
|
|
154
|
+
|
|
155
|
+
@classmethod
|
|
156
|
+
def from_str(cls, value: Optional[str]) -> "Severity":
|
|
157
|
+
"""Parse severity from string, defaulting to BLOCKING."""
|
|
158
|
+
if value is None:
|
|
159
|
+
return cls.BLOCKING
|
|
160
|
+
try:
|
|
161
|
+
return cls(value.lower())
|
|
162
|
+
except ValueError:
|
|
163
|
+
return cls.BLOCKING
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class FailureMode(str, Enum):
|
|
167
|
+
"""
|
|
168
|
+
Semantic failure modes for agent reasoning.
|
|
169
|
+
|
|
170
|
+
Each failure mode indicates WHY a rule failed, enabling:
|
|
171
|
+
- Better error messages for humans
|
|
172
|
+
- Structured reasoning for LLM agents
|
|
173
|
+
- Targeted remediation suggestions
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
# Value-level failures
|
|
177
|
+
NOVEL_CATEGORY = "novel_category" # Unexpected values in allowed_values
|
|
178
|
+
NULL_VALUES = "null_values" # NULL values found
|
|
179
|
+
DUPLICATE_VALUES = "duplicate_values" # Uniqueness violated
|
|
180
|
+
|
|
181
|
+
# Range/bound failures
|
|
182
|
+
RANGE_VIOLATION = "range_violation" # Values outside min/max bounds
|
|
183
|
+
|
|
184
|
+
# Schema failures
|
|
185
|
+
SCHEMA_DRIFT = "schema_drift" # Column type doesn't match expected
|
|
186
|
+
|
|
187
|
+
# Temporal failures
|
|
188
|
+
FRESHNESS_LAG = "freshness_lag" # Data is stale
|
|
189
|
+
|
|
190
|
+
# Dataset-level failures
|
|
191
|
+
ROW_COUNT_LOW = "row_count_low" # Below minimum rows
|
|
192
|
+
ROW_COUNT_HIGH = "row_count_high" # Above maximum rows
|
|
193
|
+
|
|
194
|
+
# Pattern failures
|
|
195
|
+
PATTERN_MISMATCH = "pattern_mismatch" # Regex pattern not matched
|
|
196
|
+
|
|
197
|
+
# Custom rule failures
|
|
198
|
+
CUSTOM_CHECK_FAILED = "custom_check_failed" # custom_sql_check failed
|
|
199
|
+
|
|
200
|
+
# Cross-column failures
|
|
201
|
+
COMPARISON_FAILED = "comparison_failed" # Compare rule failed
|
|
202
|
+
CONDITIONAL_NULL = "conditional_null" # Conditional not-null failed
|
|
203
|
+
CONDITIONAL_RANGE_VIOLATION = "conditional_range_violation" # Conditional range failed
|
|
204
|
+
|
|
205
|
+
# Configuration/contract errors
|
|
206
|
+
CONFIG_ERROR = "config_error" # Contract/config issue (e.g., column not found)
|
|
207
|
+
|
|
208
|
+
def __str__(self) -> str:
|
|
209
|
+
return self.value
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@dataclass
|
|
213
|
+
class RuleDiff:
|
|
214
|
+
"""Diff for a single rule between two states."""
|
|
215
|
+
|
|
216
|
+
rule_id: str
|
|
217
|
+
change_type: str # "new_failure", "resolved", "regression", "improvement", "unchanged"
|
|
218
|
+
|
|
219
|
+
# Counts
|
|
220
|
+
before_count: int = 0
|
|
221
|
+
after_count: int = 0
|
|
222
|
+
delta: int = 0
|
|
223
|
+
|
|
224
|
+
# Status change
|
|
225
|
+
was_passing: bool = True
|
|
226
|
+
now_passing: bool = True
|
|
227
|
+
|
|
228
|
+
# Details from the after state
|
|
229
|
+
severity: str = "blocking" # blocking, warning, info
|
|
230
|
+
failure_mode: Optional[str] = None
|
|
231
|
+
message: Optional[str] = None
|
|
232
|
+
|
|
233
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
234
|
+
"""Convert to dictionary."""
|
|
235
|
+
return {
|
|
236
|
+
"rule_id": self.rule_id,
|
|
237
|
+
"change_type": self.change_type,
|
|
238
|
+
"before_count": self.before_count,
|
|
239
|
+
"after_count": self.after_count,
|
|
240
|
+
"delta": self.delta,
|
|
241
|
+
"was_passing": self.was_passing,
|
|
242
|
+
"now_passing": self.now_passing,
|
|
243
|
+
"severity": self.severity,
|
|
244
|
+
"failure_mode": self.failure_mode,
|
|
245
|
+
"message": self.message,
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@dataclass
|
|
250
|
+
class RuleState:
|
|
251
|
+
"""State for a single rule execution."""
|
|
252
|
+
|
|
253
|
+
rule_id: str
|
|
254
|
+
rule_name: str
|
|
255
|
+
passed: bool
|
|
256
|
+
failed_count: int
|
|
257
|
+
execution_source: str # "metadata", "sql", "polars"
|
|
258
|
+
|
|
259
|
+
# Severity level
|
|
260
|
+
severity: str = "blocking" # "blocking", "warning", "info"
|
|
261
|
+
|
|
262
|
+
# Optional details for failure analysis
|
|
263
|
+
failure_mode: Optional[str] = None # "novel_category", "null_spike", etc.
|
|
264
|
+
details: Optional[Dict[str, Any]] = None
|
|
265
|
+
message: Optional[str] = None
|
|
266
|
+
|
|
267
|
+
# Column info (if applicable)
|
|
268
|
+
column: Optional[str] = None
|
|
269
|
+
|
|
270
|
+
# Database-assigned ID for normalized schema (v0.5)
|
|
271
|
+
id: Optional[int] = None
|
|
272
|
+
|
|
273
|
+
# Annotations (opt-in, never loaded by default)
|
|
274
|
+
annotations: Optional[List["Annotation"]] = None
|
|
275
|
+
|
|
276
|
+
def to_dict(self, include_annotations: bool = False) -> Dict[str, Any]:
|
|
277
|
+
"""Convert to dictionary for serialization."""
|
|
278
|
+
d: Dict[str, Any] = {
|
|
279
|
+
"rule_id": self.rule_id,
|
|
280
|
+
"rule_name": self.rule_name,
|
|
281
|
+
"passed": self.passed,
|
|
282
|
+
"failed_count": self.failed_count,
|
|
283
|
+
"execution_source": self.execution_source,
|
|
284
|
+
"severity": self.severity,
|
|
285
|
+
}
|
|
286
|
+
if self.failure_mode:
|
|
287
|
+
d["failure_mode"] = self.failure_mode
|
|
288
|
+
if self.details:
|
|
289
|
+
d["details"] = self.details
|
|
290
|
+
if self.message:
|
|
291
|
+
d["message"] = self.message
|
|
292
|
+
if self.column:
|
|
293
|
+
d["column"] = self.column
|
|
294
|
+
if self.id is not None:
|
|
295
|
+
d["id"] = self.id
|
|
296
|
+
if include_annotations and self.annotations:
|
|
297
|
+
d["annotations"] = [a.to_dict() for a in self.annotations]
|
|
298
|
+
return d
|
|
299
|
+
|
|
300
|
+
@classmethod
|
|
301
|
+
def from_dict(cls, d: Dict[str, Any]) -> "RuleState":
|
|
302
|
+
"""Create from dictionary."""
|
|
303
|
+
annotations = None
|
|
304
|
+
if "annotations" in d and d["annotations"]:
|
|
305
|
+
annotations = [Annotation.from_dict(a) for a in d["annotations"]]
|
|
306
|
+
|
|
307
|
+
return cls(
|
|
308
|
+
rule_id=d["rule_id"],
|
|
309
|
+
rule_name=d["rule_name"],
|
|
310
|
+
passed=d["passed"],
|
|
311
|
+
failed_count=d["failed_count"],
|
|
312
|
+
execution_source=d["execution_source"],
|
|
313
|
+
severity=d.get("severity", "blocking"),
|
|
314
|
+
failure_mode=d.get("failure_mode"),
|
|
315
|
+
details=d.get("details"),
|
|
316
|
+
message=d.get("message"),
|
|
317
|
+
column=d.get("column"),
|
|
318
|
+
id=d.get("id"),
|
|
319
|
+
annotations=annotations,
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
@classmethod
|
|
323
|
+
def from_result(cls, result: Dict[str, Any]) -> RuleState:
|
|
324
|
+
"""Create from validation engine result dict."""
|
|
325
|
+
# Extract column from rule_id if present (COL:column:rule_name format)
|
|
326
|
+
rule_id = result.get("rule_id", "")
|
|
327
|
+
column = None
|
|
328
|
+
if rule_id.startswith("COL:"):
|
|
329
|
+
parts = rule_id.split(":")
|
|
330
|
+
if len(parts) >= 2:
|
|
331
|
+
column = parts[1]
|
|
332
|
+
|
|
333
|
+
return cls(
|
|
334
|
+
rule_id=rule_id,
|
|
335
|
+
rule_name=result.get("rule_name", result.get("name", "")),
|
|
336
|
+
passed=result.get("passed", False),
|
|
337
|
+
failed_count=result.get("failed_count", 0),
|
|
338
|
+
execution_source=result.get("execution_source", "polars"),
|
|
339
|
+
severity=result.get("severity", "blocking"),
|
|
340
|
+
failure_mode=result.get("failure_mode"),
|
|
341
|
+
details=result.get("details"),
|
|
342
|
+
message=result.get("message"),
|
|
343
|
+
column=column,
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
@dataclass
|
|
348
|
+
class StateSummary:
|
|
349
|
+
"""Summary statistics for a validation run."""
|
|
350
|
+
|
|
351
|
+
passed: bool
|
|
352
|
+
total_rules: int
|
|
353
|
+
passed_rules: int
|
|
354
|
+
failed_rules: int
|
|
355
|
+
row_count: Optional[int] = None
|
|
356
|
+
column_count: Optional[int] = None
|
|
357
|
+
|
|
358
|
+
# Severity-based failure counts
|
|
359
|
+
blocking_failures: int = 0
|
|
360
|
+
warning_failures: int = 0
|
|
361
|
+
info_failures: int = 0
|
|
362
|
+
|
|
363
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
364
|
+
"""Convert to dictionary."""
|
|
365
|
+
d: Dict[str, Any] = {
|
|
366
|
+
"passed": self.passed,
|
|
367
|
+
"total_rules": self.total_rules,
|
|
368
|
+
"passed_rules": self.passed_rules,
|
|
369
|
+
"failed_rules": self.failed_rules,
|
|
370
|
+
"blocking_failures": self.blocking_failures,
|
|
371
|
+
"warning_failures": self.warning_failures,
|
|
372
|
+
"info_failures": self.info_failures,
|
|
373
|
+
}
|
|
374
|
+
if self.row_count is not None:
|
|
375
|
+
d["row_count"] = self.row_count
|
|
376
|
+
if self.column_count is not None:
|
|
377
|
+
d["column_count"] = self.column_count
|
|
378
|
+
return d
|
|
379
|
+
|
|
380
|
+
@classmethod
|
|
381
|
+
def from_dict(cls, d: Dict[str, Any]) -> StateSummary:
|
|
382
|
+
"""Create from dictionary."""
|
|
383
|
+
return cls(
|
|
384
|
+
passed=d["passed"],
|
|
385
|
+
total_rules=d["total_rules"],
|
|
386
|
+
passed_rules=d["passed_rules"],
|
|
387
|
+
failed_rules=d["failed_rules"],
|
|
388
|
+
row_count=d.get("row_count"),
|
|
389
|
+
column_count=d.get("column_count"),
|
|
390
|
+
blocking_failures=d.get("blocking_failures", 0),
|
|
391
|
+
warning_failures=d.get("warning_failures", 0),
|
|
392
|
+
info_failures=d.get("info_failures", 0),
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
@dataclass
|
|
397
|
+
class ValidationState:
|
|
398
|
+
"""
|
|
399
|
+
Complete state snapshot for a validation run.
|
|
400
|
+
|
|
401
|
+
Designed for:
|
|
402
|
+
- Persistence to local filesystem, S3, or database
|
|
403
|
+
- Comparison across runs (diff)
|
|
404
|
+
- Agent reasoning about changes over time
|
|
405
|
+
"""
|
|
406
|
+
|
|
407
|
+
# Identification
|
|
408
|
+
contract_fingerprint: str
|
|
409
|
+
dataset_fingerprint: Optional[str]
|
|
410
|
+
contract_name: str
|
|
411
|
+
dataset_uri: str
|
|
412
|
+
|
|
413
|
+
# Timing
|
|
414
|
+
run_at: datetime
|
|
415
|
+
|
|
416
|
+
# Results
|
|
417
|
+
summary: StateSummary
|
|
418
|
+
rules: List[RuleState]
|
|
419
|
+
|
|
420
|
+
# Metadata
|
|
421
|
+
schema_version: str = "2.0" # v2.0 for normalized schema
|
|
422
|
+
engine_version: str = field(default_factory=lambda: VERSION)
|
|
423
|
+
|
|
424
|
+
# Optional context
|
|
425
|
+
duration_ms: Optional[int] = None
|
|
426
|
+
|
|
427
|
+
# Database-assigned ID for normalized schema (v0.5)
|
|
428
|
+
id: Optional[int] = None
|
|
429
|
+
|
|
430
|
+
# Annotations (opt-in, never loaded by default)
|
|
431
|
+
annotations: Optional[List["Annotation"]] = None
|
|
432
|
+
|
|
433
|
+
def to_dict(self, include_annotations: bool = False) -> Dict[str, Any]:
|
|
434
|
+
"""Convert to dictionary for JSON serialization."""
|
|
435
|
+
d: Dict[str, Any] = {
|
|
436
|
+
"schema_version": self.schema_version,
|
|
437
|
+
"engine_version": self.engine_version,
|
|
438
|
+
"contract_fingerprint": self.contract_fingerprint,
|
|
439
|
+
"dataset_fingerprint": self.dataset_fingerprint,
|
|
440
|
+
"contract_name": self.contract_name,
|
|
441
|
+
"dataset_uri": self.dataset_uri,
|
|
442
|
+
"run_at": self.run_at.isoformat(),
|
|
443
|
+
"summary": self.summary.to_dict(),
|
|
444
|
+
"rules": [r.to_dict(include_annotations=include_annotations) for r in self.rules],
|
|
445
|
+
"duration_ms": self.duration_ms,
|
|
446
|
+
}
|
|
447
|
+
if self.id is not None:
|
|
448
|
+
d["id"] = self.id
|
|
449
|
+
if include_annotations and self.annotations:
|
|
450
|
+
d["annotations"] = [a.to_dict() for a in self.annotations]
|
|
451
|
+
return d
|
|
452
|
+
|
|
453
|
+
@classmethod
|
|
454
|
+
def from_dict(cls, d: Dict[str, Any]) -> "ValidationState":
|
|
455
|
+
"""Create from dictionary."""
|
|
456
|
+
run_at = d["run_at"]
|
|
457
|
+
if isinstance(run_at, str):
|
|
458
|
+
# Parse ISO format, handle both Z and +00:00 suffixes
|
|
459
|
+
run_at = run_at.replace("Z", "+00:00")
|
|
460
|
+
run_at = datetime.fromisoformat(run_at)
|
|
461
|
+
|
|
462
|
+
annotations = None
|
|
463
|
+
if "annotations" in d and d["annotations"]:
|
|
464
|
+
annotations = [Annotation.from_dict(a) for a in d["annotations"]]
|
|
465
|
+
|
|
466
|
+
return cls(
|
|
467
|
+
schema_version=d.get("schema_version", "1.0"),
|
|
468
|
+
engine_version=d.get("engine_version", "unknown"),
|
|
469
|
+
contract_fingerprint=d["contract_fingerprint"],
|
|
470
|
+
dataset_fingerprint=d.get("dataset_fingerprint"),
|
|
471
|
+
contract_name=d["contract_name"],
|
|
472
|
+
dataset_uri=d["dataset_uri"],
|
|
473
|
+
run_at=run_at,
|
|
474
|
+
summary=StateSummary.from_dict(d["summary"]),
|
|
475
|
+
rules=[RuleState.from_dict(r) for r in d["rules"]],
|
|
476
|
+
duration_ms=d.get("duration_ms"),
|
|
477
|
+
id=d.get("id"),
|
|
478
|
+
annotations=annotations,
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
def to_json(self, indent: int = 2, include_annotations: bool = False) -> str:
|
|
482
|
+
"""Serialize to JSON string."""
|
|
483
|
+
return json.dumps(
|
|
484
|
+
self.to_dict(include_annotations=include_annotations),
|
|
485
|
+
indent=indent,
|
|
486
|
+
default=str,
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
@classmethod
|
|
490
|
+
def from_json(cls, json_str: str) -> "ValidationState":
|
|
491
|
+
"""Deserialize from JSON string."""
|
|
492
|
+
return cls.from_dict(json.loads(json_str))
|
|
493
|
+
|
|
494
|
+
@classmethod
|
|
495
|
+
def from_validation_result(
|
|
496
|
+
cls,
|
|
497
|
+
result: Dict[str, Any],
|
|
498
|
+
contract_fingerprint: str,
|
|
499
|
+
dataset_fingerprint: Optional[str],
|
|
500
|
+
contract_name: str,
|
|
501
|
+
dataset_uri: str,
|
|
502
|
+
) -> ValidationState:
|
|
503
|
+
"""
|
|
504
|
+
Create a ValidationState from engine.run() result.
|
|
505
|
+
|
|
506
|
+
Args:
|
|
507
|
+
result: The dict returned by ValidationEngine.run()
|
|
508
|
+
contract_fingerprint: Hash of the contract
|
|
509
|
+
dataset_fingerprint: Hash of dataset metadata (optional)
|
|
510
|
+
contract_name: Name from contract
|
|
511
|
+
dataset_uri: URI of the dataset
|
|
512
|
+
"""
|
|
513
|
+
summary_data = result.get("summary", {})
|
|
514
|
+
results_list = result.get("results", [])
|
|
515
|
+
stats = result.get("stats", {})
|
|
516
|
+
|
|
517
|
+
# Build summary
|
|
518
|
+
total = summary_data.get("total_rules", len(results_list))
|
|
519
|
+
passed_count = sum(1 for r in results_list if r.get("passed", False))
|
|
520
|
+
failed_count = total - passed_count
|
|
521
|
+
|
|
522
|
+
# Count failures by severity
|
|
523
|
+
blocking_failures = 0
|
|
524
|
+
warning_failures = 0
|
|
525
|
+
info_failures = 0
|
|
526
|
+
for r in results_list:
|
|
527
|
+
if not r.get("passed", False):
|
|
528
|
+
severity = r.get("severity", "blocking")
|
|
529
|
+
if severity == "blocking":
|
|
530
|
+
blocking_failures += 1
|
|
531
|
+
elif severity == "warning":
|
|
532
|
+
warning_failures += 1
|
|
533
|
+
elif severity == "info":
|
|
534
|
+
info_failures += 1
|
|
535
|
+
|
|
536
|
+
# Get row/column counts from stats if available
|
|
537
|
+
dataset_stats = stats.get("dataset", {}) if stats else {}
|
|
538
|
+
row_count = dataset_stats.get("nrows")
|
|
539
|
+
column_count = dataset_stats.get("ncols")
|
|
540
|
+
|
|
541
|
+
# Use summary_data if available, otherwise calculate
|
|
542
|
+
summary = StateSummary(
|
|
543
|
+
passed=summary_data.get("passed", blocking_failures == 0),
|
|
544
|
+
total_rules=total,
|
|
545
|
+
passed_rules=passed_count,
|
|
546
|
+
failed_rules=failed_count,
|
|
547
|
+
row_count=row_count,
|
|
548
|
+
column_count=column_count,
|
|
549
|
+
blocking_failures=summary_data.get("blocking_failures", blocking_failures),
|
|
550
|
+
warning_failures=summary_data.get("warning_failures", warning_failures),
|
|
551
|
+
info_failures=summary_data.get("info_failures", info_failures),
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
# Build rule states
|
|
555
|
+
rules = [RuleState.from_result(r) for r in results_list]
|
|
556
|
+
|
|
557
|
+
# Duration
|
|
558
|
+
run_meta = stats.get("run_meta", {}) if stats else {}
|
|
559
|
+
duration_ms = run_meta.get("duration_ms_total")
|
|
560
|
+
|
|
561
|
+
return cls(
|
|
562
|
+
contract_fingerprint=contract_fingerprint,
|
|
563
|
+
dataset_fingerprint=dataset_fingerprint,
|
|
564
|
+
contract_name=contract_name,
|
|
565
|
+
dataset_uri=dataset_uri,
|
|
566
|
+
run_at=datetime.now(timezone.utc),
|
|
567
|
+
summary=summary,
|
|
568
|
+
rules=rules,
|
|
569
|
+
duration_ms=duration_ms,
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
def get_rule(self, rule_id: str) -> Optional[RuleState]:
|
|
573
|
+
"""Get a specific rule state by ID."""
|
|
574
|
+
for rule in self.rules:
|
|
575
|
+
if rule.rule_id == rule_id:
|
|
576
|
+
return rule
|
|
577
|
+
return None
|
|
578
|
+
|
|
579
|
+
def get_failed_rules(self) -> List[RuleState]:
|
|
580
|
+
"""Get all failed rules."""
|
|
581
|
+
return [r for r in self.rules if not r.passed]
|
|
582
|
+
|
|
583
|
+
def get_passed_rules(self) -> List[RuleState]:
|
|
584
|
+
"""Get all passed rules."""
|
|
585
|
+
return [r for r in self.rules if r.passed]
|
|
586
|
+
|
|
587
|
+
def to_llm(self) -> str:
|
|
588
|
+
"""
|
|
589
|
+
Render state in token-optimized format for LLM context.
|
|
590
|
+
|
|
591
|
+
Design:
|
|
592
|
+
- Failed rules get detail, passed rules get summarized
|
|
593
|
+
- Failure modes and severity surfaced for reasoning
|
|
594
|
+
- Compact but complete enough for agent decisions
|
|
595
|
+
"""
|
|
596
|
+
lines = []
|
|
597
|
+
|
|
598
|
+
# Header
|
|
599
|
+
ts = self.run_at.strftime("%Y-%m-%dT%H:%M")
|
|
600
|
+
status = "PASSED" if self.summary.passed else "FAILED"
|
|
601
|
+
lines.append(f"# State: {self.contract_name} @ {ts}")
|
|
602
|
+
lines.append(f"result: {status} ({self.summary.passed_rules}/{self.summary.total_rules} passed)")
|
|
603
|
+
|
|
604
|
+
# Show severity breakdown if there are failures
|
|
605
|
+
if self.summary.failed_rules > 0:
|
|
606
|
+
severity_parts = []
|
|
607
|
+
if self.summary.blocking_failures > 0:
|
|
608
|
+
severity_parts.append(f"{self.summary.blocking_failures} blocking")
|
|
609
|
+
if self.summary.warning_failures > 0:
|
|
610
|
+
severity_parts.append(f"{self.summary.warning_failures} warning")
|
|
611
|
+
if self.summary.info_failures > 0:
|
|
612
|
+
severity_parts.append(f"{self.summary.info_failures} info")
|
|
613
|
+
if severity_parts:
|
|
614
|
+
lines.append(f"failures: {', '.join(severity_parts)}")
|
|
615
|
+
|
|
616
|
+
if self.summary.row_count:
|
|
617
|
+
lines.append(f"rows: {self.summary.row_count:,}")
|
|
618
|
+
|
|
619
|
+
# Failed rules with details
|
|
620
|
+
failed = self.get_failed_rules()
|
|
621
|
+
if failed:
|
|
622
|
+
lines.append("")
|
|
623
|
+
lines.append(f"## Failed ({len(failed)})")
|
|
624
|
+
for rule in failed[:10]: # Limit to top 10
|
|
625
|
+
parts = [rule.rule_id]
|
|
626
|
+
# Include severity if not blocking
|
|
627
|
+
if rule.severity != "blocking":
|
|
628
|
+
parts.append(f"[{rule.severity}]")
|
|
629
|
+
if rule.failed_count > 0:
|
|
630
|
+
count_str = f"{rule.failed_count:,}" if rule.failed_count < 1000000 else f"{rule.failed_count/1000000:.1f}M"
|
|
631
|
+
parts.append(f"{count_str} failures")
|
|
632
|
+
if rule.failure_mode:
|
|
633
|
+
parts.append(rule.failure_mode)
|
|
634
|
+
if rule.message:
|
|
635
|
+
# Truncate long messages
|
|
636
|
+
msg = rule.message[:50] + "..." if len(rule.message) > 50 else rule.message
|
|
637
|
+
parts.append(msg)
|
|
638
|
+
lines.append(f"- {' | '.join(parts)}")
|
|
639
|
+
|
|
640
|
+
if len(failed) > 10:
|
|
641
|
+
lines.append(f" ... and {len(failed) - 10} more")
|
|
642
|
+
|
|
643
|
+
# Passed rules summary (grouped by execution source)
|
|
644
|
+
passed = self.get_passed_rules()
|
|
645
|
+
if passed:
|
|
646
|
+
lines.append("")
|
|
647
|
+
lines.append(f"## Passed ({len(passed)})")
|
|
648
|
+
|
|
649
|
+
# Group by execution source
|
|
650
|
+
by_source: Dict[str, List[RuleState]] = {}
|
|
651
|
+
for rule in passed:
|
|
652
|
+
src = rule.execution_source or "unknown"
|
|
653
|
+
by_source.setdefault(src, []).append(rule)
|
|
654
|
+
|
|
655
|
+
# Group by rule name within source
|
|
656
|
+
summary_parts = []
|
|
657
|
+
for src, rules in sorted(by_source.items()):
|
|
658
|
+
# Count by rule name
|
|
659
|
+
by_name: Dict[str, int] = {}
|
|
660
|
+
for r in rules:
|
|
661
|
+
name = r.rule_id.split(":")[-1] if ":" in r.rule_id else r.rule_id
|
|
662
|
+
by_name[name] = by_name.get(name, 0) + 1
|
|
663
|
+
|
|
664
|
+
for name, count in sorted(by_name.items(), key=lambda x: -x[1]):
|
|
665
|
+
summary_parts.append(f"{count}x {name} [{src}]")
|
|
666
|
+
|
|
667
|
+
lines.append(", ".join(summary_parts[:8]))
|
|
668
|
+
if len(summary_parts) > 8:
|
|
669
|
+
lines.append(f" ... and {len(summary_parts) - 8} more categories")
|
|
670
|
+
|
|
671
|
+
# Footer with fingerprint (for agent tracking)
|
|
672
|
+
lines.append("")
|
|
673
|
+
lines.append(f"fingerprint: {self.contract_fingerprint}")
|
|
674
|
+
|
|
675
|
+
return "\n".join(lines)
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
@dataclass
|
|
679
|
+
class RunSummary:
|
|
680
|
+
"""
|
|
681
|
+
Lightweight summary of a validation run for history listing.
|
|
682
|
+
|
|
683
|
+
Used by get_history() to return a list of runs without loading
|
|
684
|
+
full rule details. Optimized for quick scanning and display.
|
|
685
|
+
"""
|
|
686
|
+
|
|
687
|
+
run_id: str # Unique identifier for the run
|
|
688
|
+
timestamp: datetime # When the run occurred
|
|
689
|
+
passed: bool # Overall pass/fail status
|
|
690
|
+
failed_count: int # Total failures across all rules
|
|
691
|
+
total_rows: Optional[int] # Row count if available
|
|
692
|
+
contract_name: str # Name of the contract
|
|
693
|
+
contract_fingerprint: str # Fingerprint for filtering
|
|
694
|
+
|
|
695
|
+
# Optional metadata
|
|
696
|
+
total_rules: int = 0
|
|
697
|
+
blocking_failures: int = 0
|
|
698
|
+
warning_failures: int = 0
|
|
699
|
+
duration_ms: Optional[int] = None
|
|
700
|
+
|
|
701
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
702
|
+
"""Convert to dictionary for serialization."""
|
|
703
|
+
d: Dict[str, Any] = {
|
|
704
|
+
"run_id": self.run_id,
|
|
705
|
+
"timestamp": self.timestamp.isoformat(),
|
|
706
|
+
"passed": self.passed,
|
|
707
|
+
"failed_count": self.failed_count,
|
|
708
|
+
"contract_name": self.contract_name,
|
|
709
|
+
"contract_fingerprint": self.contract_fingerprint,
|
|
710
|
+
"total_rules": self.total_rules,
|
|
711
|
+
"blocking_failures": self.blocking_failures,
|
|
712
|
+
"warning_failures": self.warning_failures,
|
|
713
|
+
}
|
|
714
|
+
if self.total_rows is not None:
|
|
715
|
+
d["total_rows"] = self.total_rows
|
|
716
|
+
if self.duration_ms is not None:
|
|
717
|
+
d["duration_ms"] = self.duration_ms
|
|
718
|
+
return d
|
|
719
|
+
|
|
720
|
+
@classmethod
|
|
721
|
+
def from_dict(cls, d: Dict[str, Any]) -> "RunSummary":
|
|
722
|
+
"""Create from dictionary."""
|
|
723
|
+
timestamp = d["timestamp"]
|
|
724
|
+
if isinstance(timestamp, str):
|
|
725
|
+
timestamp = timestamp.replace("Z", "+00:00")
|
|
726
|
+
timestamp = datetime.fromisoformat(timestamp)
|
|
727
|
+
|
|
728
|
+
return cls(
|
|
729
|
+
run_id=d["run_id"],
|
|
730
|
+
timestamp=timestamp,
|
|
731
|
+
passed=d["passed"],
|
|
732
|
+
failed_count=d["failed_count"],
|
|
733
|
+
total_rows=d.get("total_rows"),
|
|
734
|
+
contract_name=d["contract_name"],
|
|
735
|
+
contract_fingerprint=d["contract_fingerprint"],
|
|
736
|
+
total_rules=d.get("total_rules", 0),
|
|
737
|
+
blocking_failures=d.get("blocking_failures", 0),
|
|
738
|
+
warning_failures=d.get("warning_failures", 0),
|
|
739
|
+
duration_ms=d.get("duration_ms"),
|
|
740
|
+
)
|
|
741
|
+
|
|
742
|
+
@classmethod
|
|
743
|
+
def from_validation_state(cls, state: "ValidationState", run_id: str) -> "RunSummary":
|
|
744
|
+
"""Create from a full ValidationState."""
|
|
745
|
+
return cls(
|
|
746
|
+
run_id=run_id,
|
|
747
|
+
timestamp=state.run_at,
|
|
748
|
+
passed=state.summary.passed,
|
|
749
|
+
failed_count=state.summary.failed_rules,
|
|
750
|
+
total_rows=state.summary.row_count,
|
|
751
|
+
contract_name=state.contract_name,
|
|
752
|
+
contract_fingerprint=state.contract_fingerprint,
|
|
753
|
+
total_rules=state.summary.total_rules,
|
|
754
|
+
blocking_failures=state.summary.blocking_failures,
|
|
755
|
+
warning_failures=state.summary.warning_failures,
|
|
756
|
+
duration_ms=state.duration_ms,
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
def to_llm(self) -> str:
|
|
760
|
+
"""Token-optimized format for LLM context."""
|
|
761
|
+
ts = self.timestamp.strftime("%Y-%m-%d %H:%M")
|
|
762
|
+
status = "PASS" if self.passed else "FAIL"
|
|
763
|
+
rows = f"{self.total_rows:,}" if self.total_rows else "?"
|
|
764
|
+
return f"{ts} | {status} | {self.failed_count} failures | {rows} rows"
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
@dataclass
|
|
768
|
+
class StateDiff:
|
|
769
|
+
"""
|
|
770
|
+
Diff between two validation states.
|
|
771
|
+
|
|
772
|
+
Captures what changed between runs to enable:
|
|
773
|
+
- Human-readable diff output
|
|
774
|
+
- LLM reasoning about regressions
|
|
775
|
+
- Automated alerting on changes
|
|
776
|
+
"""
|
|
777
|
+
|
|
778
|
+
# Source states
|
|
779
|
+
before: "ValidationState"
|
|
780
|
+
after: "ValidationState"
|
|
781
|
+
|
|
782
|
+
# Summary
|
|
783
|
+
status_changed: bool = False
|
|
784
|
+
has_regressions: bool = False
|
|
785
|
+
has_improvements: bool = False
|
|
786
|
+
|
|
787
|
+
# Rule-level changes
|
|
788
|
+
new_failures: List[RuleDiff] = field(default_factory=list)
|
|
789
|
+
resolved: List[RuleDiff] = field(default_factory=list)
|
|
790
|
+
regressions: List[RuleDiff] = field(default_factory=list) # count increased
|
|
791
|
+
improvements: List[RuleDiff] = field(default_factory=list) # count decreased
|
|
792
|
+
unchanged: List[RuleDiff] = field(default_factory=list)
|
|
793
|
+
|
|
794
|
+
@classmethod
|
|
795
|
+
def compute(cls, before: "ValidationState", after: "ValidationState") -> "StateDiff":
|
|
796
|
+
"""
|
|
797
|
+
Compute diff between two states.
|
|
798
|
+
|
|
799
|
+
Args:
|
|
800
|
+
before: Earlier state
|
|
801
|
+
after: Later state
|
|
802
|
+
|
|
803
|
+
Returns:
|
|
804
|
+
StateDiff with categorized changes
|
|
805
|
+
"""
|
|
806
|
+
diff = cls(before=before, after=after)
|
|
807
|
+
diff.status_changed = before.summary.passed != after.summary.passed
|
|
808
|
+
|
|
809
|
+
# Index rules by ID
|
|
810
|
+
before_rules = {r.rule_id: r for r in before.rules}
|
|
811
|
+
after_rules = {r.rule_id: r for r in after.rules}
|
|
812
|
+
|
|
813
|
+
# Process all rules in after state
|
|
814
|
+
for rule_id, after_rule in after_rules.items():
|
|
815
|
+
before_rule = before_rules.get(rule_id)
|
|
816
|
+
|
|
817
|
+
if before_rule is None:
|
|
818
|
+
# New rule (not in before state)
|
|
819
|
+
if not after_rule.passed:
|
|
820
|
+
rule_diff = RuleDiff(
|
|
821
|
+
rule_id=rule_id,
|
|
822
|
+
change_type="new_failure",
|
|
823
|
+
after_count=after_rule.failed_count,
|
|
824
|
+
delta=after_rule.failed_count,
|
|
825
|
+
was_passing=True, # Didn't exist, treat as "was passing"
|
|
826
|
+
now_passing=False,
|
|
827
|
+
severity=after_rule.severity,
|
|
828
|
+
failure_mode=after_rule.failure_mode,
|
|
829
|
+
message=after_rule.message,
|
|
830
|
+
)
|
|
831
|
+
diff.new_failures.append(rule_diff)
|
|
832
|
+
continue
|
|
833
|
+
|
|
834
|
+
# Rule exists in both states
|
|
835
|
+
was_passing = before_rule.passed
|
|
836
|
+
now_passing = after_rule.passed
|
|
837
|
+
before_count = before_rule.failed_count
|
|
838
|
+
after_count = after_rule.failed_count
|
|
839
|
+
delta = after_count - before_count
|
|
840
|
+
|
|
841
|
+
rule_diff = RuleDiff(
|
|
842
|
+
rule_id=rule_id,
|
|
843
|
+
change_type="unchanged",
|
|
844
|
+
before_count=before_count,
|
|
845
|
+
after_count=after_count,
|
|
846
|
+
delta=delta,
|
|
847
|
+
was_passing=was_passing,
|
|
848
|
+
now_passing=now_passing,
|
|
849
|
+
severity=after_rule.severity,
|
|
850
|
+
failure_mode=after_rule.failure_mode,
|
|
851
|
+
message=after_rule.message,
|
|
852
|
+
)
|
|
853
|
+
|
|
854
|
+
if was_passing and not now_passing:
|
|
855
|
+
# Was passing, now failing
|
|
856
|
+
rule_diff.change_type = "new_failure"
|
|
857
|
+
diff.new_failures.append(rule_diff)
|
|
858
|
+
elif not was_passing and now_passing:
|
|
859
|
+
# Was failing, now passing
|
|
860
|
+
rule_diff.change_type = "resolved"
|
|
861
|
+
diff.resolved.append(rule_diff)
|
|
862
|
+
elif delta > 0:
|
|
863
|
+
# Count increased (regression)
|
|
864
|
+
rule_diff.change_type = "regression"
|
|
865
|
+
diff.regressions.append(rule_diff)
|
|
866
|
+
elif delta < 0:
|
|
867
|
+
# Count decreased (improvement)
|
|
868
|
+
rule_diff.change_type = "improvement"
|
|
869
|
+
diff.improvements.append(rule_diff)
|
|
870
|
+
else:
|
|
871
|
+
# No change
|
|
872
|
+
diff.unchanged.append(rule_diff)
|
|
873
|
+
|
|
874
|
+
# Check for rules removed (in before but not in after)
|
|
875
|
+
for rule_id, before_rule in before_rules.items():
|
|
876
|
+
if rule_id not in after_rules:
|
|
877
|
+
# Rule was removed - if it was failing, that's a "resolution"
|
|
878
|
+
if not before_rule.passed:
|
|
879
|
+
rule_diff = RuleDiff(
|
|
880
|
+
rule_id=rule_id,
|
|
881
|
+
change_type="resolved",
|
|
882
|
+
before_count=before_rule.failed_count,
|
|
883
|
+
after_count=0,
|
|
884
|
+
delta=-before_rule.failed_count,
|
|
885
|
+
was_passing=False,
|
|
886
|
+
now_passing=True,
|
|
887
|
+
)
|
|
888
|
+
diff.resolved.append(rule_diff)
|
|
889
|
+
|
|
890
|
+
# Set summary flags
|
|
891
|
+
diff.has_regressions = len(diff.new_failures) > 0 or len(diff.regressions) > 0
|
|
892
|
+
diff.has_improvements = len(diff.resolved) > 0 or len(diff.improvements) > 0
|
|
893
|
+
|
|
894
|
+
return diff
|
|
895
|
+
|
|
896
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
897
|
+
"""Convert to dictionary for JSON serialization."""
|
|
898
|
+
return {
|
|
899
|
+
"before_run_at": self.before.run_at.isoformat(),
|
|
900
|
+
"after_run_at": self.after.run_at.isoformat(),
|
|
901
|
+
"contract_name": self.after.contract_name,
|
|
902
|
+
"contract_fingerprint": self.after.contract_fingerprint,
|
|
903
|
+
"status_changed": self.status_changed,
|
|
904
|
+
"has_regressions": self.has_regressions,
|
|
905
|
+
"has_improvements": self.has_improvements,
|
|
906
|
+
"summary": {
|
|
907
|
+
"before": self.before.summary.to_dict(),
|
|
908
|
+
"after": self.after.summary.to_dict(),
|
|
909
|
+
},
|
|
910
|
+
"new_failures": [r.to_dict() for r in self.new_failures],
|
|
911
|
+
"resolved": [r.to_dict() for r in self.resolved],
|
|
912
|
+
"regressions": [r.to_dict() for r in self.regressions],
|
|
913
|
+
"improvements": [r.to_dict() for r in self.improvements],
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
def to_json(self, indent: int = 2) -> str:
|
|
917
|
+
"""Serialize to JSON string."""
|
|
918
|
+
return json.dumps(self.to_dict(), indent=indent, default=str)
|
|
919
|
+
|
|
920
|
+
def to_llm(self) -> str:
|
|
921
|
+
"""
|
|
922
|
+
Render diff in token-optimized format for LLM context.
|
|
923
|
+
|
|
924
|
+
Focus on changes, skip unchanged rules entirely.
|
|
925
|
+
"""
|
|
926
|
+
lines = []
|
|
927
|
+
|
|
928
|
+
# Header
|
|
929
|
+
before_ts = self.before.run_at.strftime("%Y-%m-%d")
|
|
930
|
+
after_ts = self.after.run_at.strftime("%Y-%m-%d %H:%M")
|
|
931
|
+
|
|
932
|
+
if self.has_regressions:
|
|
933
|
+
status = "REGRESSION"
|
|
934
|
+
elif self.has_improvements:
|
|
935
|
+
status = "IMPROVED"
|
|
936
|
+
else:
|
|
937
|
+
status = "NO_CHANGE"
|
|
938
|
+
|
|
939
|
+
lines.append(f"# Diff: {self.after.contract_name}")
|
|
940
|
+
lines.append(f"comparing: {before_ts} → {after_ts}")
|
|
941
|
+
lines.append(f"status: {status}")
|
|
942
|
+
|
|
943
|
+
# Summary change
|
|
944
|
+
if self.status_changed:
|
|
945
|
+
before_status = "PASS" if self.before.summary.passed else "FAIL"
|
|
946
|
+
after_status = "PASS" if self.after.summary.passed else "FAIL"
|
|
947
|
+
lines.append(f"overall: {before_status} → {after_status}")
|
|
948
|
+
|
|
949
|
+
# New failures (most important) - group by severity
|
|
950
|
+
if self.new_failures:
|
|
951
|
+
# Separate by severity
|
|
952
|
+
blocking = [rd for rd in self.new_failures if rd.severity == "blocking"]
|
|
953
|
+
warnings = [rd for rd in self.new_failures if rd.severity == "warning"]
|
|
954
|
+
infos = [rd for rd in self.new_failures if rd.severity == "info"]
|
|
955
|
+
|
|
956
|
+
if blocking:
|
|
957
|
+
lines.append("")
|
|
958
|
+
lines.append(f"## New Blocking Failures ({len(blocking)})")
|
|
959
|
+
for rd in blocking[:5]:
|
|
960
|
+
parts = [rd.rule_id]
|
|
961
|
+
if rd.after_count > 0:
|
|
962
|
+
count_str = f"{rd.after_count:,}" if rd.after_count < 1000000 else f"{rd.after_count/1000000:.1f}M"
|
|
963
|
+
parts.append(f"+{count_str}")
|
|
964
|
+
if rd.failure_mode:
|
|
965
|
+
parts.append(rd.failure_mode)
|
|
966
|
+
lines.append(f"- {' | '.join(parts)}")
|
|
967
|
+
if len(blocking) > 5:
|
|
968
|
+
lines.append(f" ... and {len(blocking) - 5} more")
|
|
969
|
+
|
|
970
|
+
if warnings:
|
|
971
|
+
lines.append("")
|
|
972
|
+
lines.append(f"## New Warnings ({len(warnings)})")
|
|
973
|
+
for rd in warnings[:5]:
|
|
974
|
+
parts = [rd.rule_id]
|
|
975
|
+
if rd.after_count > 0:
|
|
976
|
+
count_str = f"{rd.after_count:,}" if rd.after_count < 1000000 else f"{rd.after_count/1000000:.1f}M"
|
|
977
|
+
parts.append(f"+{count_str}")
|
|
978
|
+
if rd.failure_mode:
|
|
979
|
+
parts.append(rd.failure_mode)
|
|
980
|
+
lines.append(f"- {' | '.join(parts)}")
|
|
981
|
+
if len(warnings) > 5:
|
|
982
|
+
lines.append(f" ... and {len(warnings) - 5} more")
|
|
983
|
+
|
|
984
|
+
if infos:
|
|
985
|
+
lines.append("")
|
|
986
|
+
lines.append(f"## New Info Issues ({len(infos)})")
|
|
987
|
+
for rd in infos[:5]:
|
|
988
|
+
parts = [rd.rule_id]
|
|
989
|
+
if rd.after_count > 0:
|
|
990
|
+
count_str = f"{rd.after_count:,}" if rd.after_count < 1000000 else f"{rd.after_count/1000000:.1f}M"
|
|
991
|
+
parts.append(f"+{count_str}")
|
|
992
|
+
if rd.failure_mode:
|
|
993
|
+
parts.append(rd.failure_mode)
|
|
994
|
+
lines.append(f"- {' | '.join(parts)}")
|
|
995
|
+
if len(infos) > 5:
|
|
996
|
+
lines.append(f" ... and {len(infos) - 5} more")
|
|
997
|
+
|
|
998
|
+
# Regressions (count increased) - group by severity
|
|
999
|
+
if self.regressions:
|
|
1000
|
+
blocking_reg = [rd for rd in self.regressions if rd.severity == "blocking"]
|
|
1001
|
+
warning_reg = [rd for rd in self.regressions if rd.severity == "warning"]
|
|
1002
|
+
info_reg = [rd for rd in self.regressions if rd.severity == "info"]
|
|
1003
|
+
|
|
1004
|
+
def fmt_regression(rd):
|
|
1005
|
+
before_str = f"{rd.before_count:,}" if rd.before_count < 1000000 else f"{rd.before_count/1000000:.1f}M"
|
|
1006
|
+
after_str = f"{rd.after_count:,}" if rd.after_count < 1000000 else f"{rd.after_count/1000000:.1f}M"
|
|
1007
|
+
mode = f" | {rd.failure_mode}" if rd.failure_mode else ""
|
|
1008
|
+
return f"- {rd.rule_id}: {before_str} → {after_str} (+{rd.delta:,}){mode}"
|
|
1009
|
+
|
|
1010
|
+
if blocking_reg:
|
|
1011
|
+
lines.append("")
|
|
1012
|
+
lines.append(f"## Blocking Regressions ({len(blocking_reg)})")
|
|
1013
|
+
for rd in blocking_reg[:5]:
|
|
1014
|
+
lines.append(fmt_regression(rd))
|
|
1015
|
+
if len(blocking_reg) > 5:
|
|
1016
|
+
lines.append(f" ... and {len(blocking_reg) - 5} more")
|
|
1017
|
+
|
|
1018
|
+
if warning_reg:
|
|
1019
|
+
lines.append("")
|
|
1020
|
+
lines.append(f"## Warning Regressions ({len(warning_reg)})")
|
|
1021
|
+
for rd in warning_reg[:5]:
|
|
1022
|
+
lines.append(fmt_regression(rd))
|
|
1023
|
+
if len(warning_reg) > 5:
|
|
1024
|
+
lines.append(f" ... and {len(warning_reg) - 5} more")
|
|
1025
|
+
|
|
1026
|
+
if info_reg:
|
|
1027
|
+
lines.append("")
|
|
1028
|
+
lines.append(f"## Info Regressions ({len(info_reg)})")
|
|
1029
|
+
for rd in info_reg[:5]:
|
|
1030
|
+
lines.append(fmt_regression(rd))
|
|
1031
|
+
if len(info_reg) > 5:
|
|
1032
|
+
lines.append(f" ... and {len(info_reg) - 5} more")
|
|
1033
|
+
|
|
1034
|
+
# Resolved
|
|
1035
|
+
if self.resolved:
|
|
1036
|
+
lines.append("")
|
|
1037
|
+
lines.append(f"## Resolved ({len(self.resolved)})")
|
|
1038
|
+
for rd in self.resolved[:5]:
|
|
1039
|
+
lines.append(f"- {rd.rule_id}")
|
|
1040
|
+
if len(self.resolved) > 5:
|
|
1041
|
+
lines.append(f" ... and {len(self.resolved) - 5} more")
|
|
1042
|
+
|
|
1043
|
+
# Improvements (count decreased)
|
|
1044
|
+
if self.improvements:
|
|
1045
|
+
lines.append("")
|
|
1046
|
+
lines.append(f"## Improvements ({len(self.improvements)})")
|
|
1047
|
+
for rd in self.improvements[:5]:
|
|
1048
|
+
lines.append(f"- {rd.rule_id}: {rd.before_count:,} → {rd.after_count:,} ({rd.delta:,})")
|
|
1049
|
+
if len(self.improvements) > 5:
|
|
1050
|
+
lines.append(f" ... and {len(self.improvements) - 5} more")
|
|
1051
|
+
|
|
1052
|
+
# No changes
|
|
1053
|
+
if not self.new_failures and not self.regressions and not self.resolved and not self.improvements:
|
|
1054
|
+
lines.append("")
|
|
1055
|
+
lines.append("No changes detected.")
|
|
1056
|
+
|
|
1057
|
+
# Footer
|
|
1058
|
+
lines.append("")
|
|
1059
|
+
lines.append(f"fingerprint: {self.after.contract_fingerprint}")
|
|
1060
|
+
|
|
1061
|
+
return "\n".join(lines)
|