constraintloop 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.
@@ -0,0 +1,233 @@
1
+ """Content-addressed project snapshots and git context."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import re
9
+ import subprocess
10
+ from collections.abc import Iterable
11
+ from pathlib import Path, PurePosixPath
12
+
13
+ from constraintloop.models import ConstraintSpec
14
+
15
+ _IGNORED_PARTS = {
16
+ ".git",
17
+ ".constraintloop",
18
+ ".mypy_cache",
19
+ ".pytest_cache",
20
+ ".ruff_cache",
21
+ ".tox",
22
+ ".venv",
23
+ "__pycache__",
24
+ "build",
25
+ "dist",
26
+ "node_modules",
27
+ }
28
+ _SECRET_NAMES = {
29
+ ".env",
30
+ ".env.local",
31
+ ".env.production",
32
+ "credentials.json",
33
+ "secrets.env",
34
+ }
35
+ _SECRET_SUFFIXES = {".key", ".pem", ".p12", ".pfx"}
36
+ _CREDENTIAL_ASSIGNMENT = re.compile(
37
+ r"(?i)\b(api[_-]?key|access[_-]?token|auth[_-]?token|password|secret)"
38
+ r"(\s*[:=]\s*[\"']?)([^\s,\"']{8,})"
39
+ )
40
+
41
+
42
+ def matching_files(project_root: Path, patterns: Iterable[str]) -> list[Path]:
43
+ """Return stable, unique files matching glob patterns under the project."""
44
+ found: dict[str, Path] = {}
45
+ for pattern in patterns:
46
+ for path in project_root.glob(pattern):
47
+ if not path.is_file():
48
+ continue
49
+ try:
50
+ relative = path.relative_to(project_root)
51
+ path.resolve().relative_to(project_root.resolve())
52
+ except ValueError:
53
+ continue
54
+ if any(part in _IGNORED_PARTS for part in relative.parts):
55
+ continue
56
+ found[relative.as_posix()] = path
57
+ return [found[key] for key in sorted(found)]
58
+
59
+
60
+ def constraint_input_digest(
61
+ project_root: Path,
62
+ constraint_id: str,
63
+ spec: ConstraintSpec,
64
+ *,
65
+ contract_digest: str | None = None,
66
+ ) -> str:
67
+ digest = hashlib.sha256()
68
+ digest.update(constraint_id.encode())
69
+ if contract_digest is not None:
70
+ digest.update(contract_digest.encode())
71
+ digest.update(
72
+ json.dumps(spec.model_dump(mode="json"), sort_keys=True, separators=(",", ":")).encode()
73
+ )
74
+ for path in matching_files(project_root, spec.watch):
75
+ relative = path.relative_to(project_root).as_posix()
76
+ digest.update(relative.encode())
77
+ try:
78
+ digest.update(path.read_bytes())
79
+ except OSError as exc:
80
+ digest.update(f"<unreadable:{exc}>".encode())
81
+ return digest.hexdigest()
82
+
83
+
84
+ def project_key(project_root: Path) -> str:
85
+ return hashlib.sha256(str(project_root.resolve()).encode()).hexdigest()[:20]
86
+
87
+
88
+ def git_diff(
89
+ project_root: Path,
90
+ *,
91
+ patterns: Iterable[str] = ("**/*",),
92
+ limit: int | None = None,
93
+ ) -> str:
94
+ """Return staged, unstaged, and untracked changes as a bounded text bundle."""
95
+ pattern_list = list(patterns)
96
+ eligible = sorted(
97
+ {
98
+ relative
99
+ for group in _changed_path_groups(project_root, include_untracked=True)
100
+ if all(_allowed_disclosure_path(path, pattern_list) for path in group)
101
+ for relative in group
102
+ }
103
+ )
104
+ chunks: list[str] = []
105
+ for args in (["git", "diff", "--no-ext-diff"], ["git", "diff", "--cached", "--no-ext-diff"]):
106
+ if not eligible:
107
+ continue
108
+ try:
109
+ result = subprocess.run(
110
+ [*args, "--", *eligible],
111
+ cwd=project_root,
112
+ capture_output=True,
113
+ encoding="utf-8",
114
+ errors="replace",
115
+ timeout=15,
116
+ check=False,
117
+ )
118
+ except (OSError, subprocess.TimeoutExpired):
119
+ continue
120
+ if result.stdout:
121
+ chunks.append(result.stdout)
122
+
123
+ for relative in eligible:
124
+ path = project_root / relative
125
+ if not path.is_file() or _is_git_tracked(project_root, relative):
126
+ continue
127
+ try:
128
+ content = path.read_text(encoding="utf-8", errors="replace")
129
+ except OSError:
130
+ continue
131
+ chunks.append(
132
+ f"diff --git a/{relative} b/{relative}\nnew file\n+++ b/{relative}\n{content}"
133
+ )
134
+
135
+ output = redact_text("\n".join(chunks))
136
+ if limit is not None and len(output.encode()) > limit:
137
+ return output.encode()[:limit].decode("utf-8", errors="ignore") + "\n[diff truncated]"
138
+ return output
139
+
140
+
141
+ def changed_files(project_root: Path, *, include_untracked: bool = True) -> list[str]:
142
+ return sorted(
143
+ {
144
+ relative
145
+ for group in _changed_path_groups(project_root, include_untracked=include_untracked)
146
+ for relative in group
147
+ }
148
+ )
149
+
150
+
151
+ def _changed_path_groups(project_root: Path, *, include_untracked: bool) -> list[tuple[str, ...]]:
152
+ args = ["git", "status", "--porcelain=v1", "--untracked-files=all", "-z"]
153
+ try:
154
+ result = subprocess.run(
155
+ args,
156
+ cwd=project_root,
157
+ capture_output=True,
158
+ encoding="utf-8",
159
+ errors="replace",
160
+ timeout=10,
161
+ check=False,
162
+ )
163
+ except (OSError, subprocess.TimeoutExpired):
164
+ return []
165
+ changed: list[tuple[str, ...]] = []
166
+ entries = result.stdout.split("\0")
167
+ index = 0
168
+ while index < len(entries):
169
+ line = entries[index]
170
+ index += 1
171
+ if len(line) < 4:
172
+ continue
173
+ status = line[:2]
174
+ if status == "??" and not include_untracked:
175
+ continue
176
+ name = line[3:]
177
+ if any(marker in {"R", "C"} for marker in status) and index < len(entries):
178
+ previous_name = entries[index]
179
+ index += 1
180
+ changed.append((name, previous_name))
181
+ else:
182
+ changed.append((name,))
183
+ return changed
184
+
185
+
186
+ def _allowed_disclosure_path(relative: str, patterns: list[str]) -> bool:
187
+ if not is_disclosable_path(relative):
188
+ return False
189
+ path = PurePosixPath(relative)
190
+ return any(
191
+ path.match(pattern)
192
+ or (pattern.startswith("**/") and path.match(pattern.removeprefix("**/")))
193
+ for pattern in patterns
194
+ )
195
+
196
+
197
+ def is_disclosable_path(relative: str) -> bool:
198
+ path = PurePosixPath(relative)
199
+ name = path.name.lower()
200
+ return not (
201
+ name in _SECRET_NAMES
202
+ or (name.startswith(".env.") and name != ".env.example")
203
+ or path.suffix.lower() in _SECRET_SUFFIXES
204
+ )
205
+
206
+
207
+ def redact_text(value: str) -> str:
208
+ redacted = value
209
+ for name, secret in os.environ.items():
210
+ if (
211
+ secret
212
+ and len(secret) >= 8
213
+ and any(marker in name.upper() for marker in ("KEY", "TOKEN", "SECRET", "PASSWORD"))
214
+ ):
215
+ redacted = redacted.replace(secret, "[REDACTED]")
216
+ return _CREDENTIAL_ASSIGNMENT.sub(
217
+ lambda match: f"{match.group(1)}{match.group(2)}[REDACTED]",
218
+ redacted,
219
+ )
220
+
221
+
222
+ def _is_git_tracked(project_root: Path, relative: str) -> bool:
223
+ try:
224
+ result = subprocess.run(
225
+ ["git", "ls-files", "--error-unmatch", "--", relative],
226
+ cwd=project_root,
227
+ capture_output=True,
228
+ timeout=5,
229
+ check=False,
230
+ )
231
+ except (OSError, subprocess.TimeoutExpired):
232
+ return False
233
+ return result.returncode == 0
@@ -0,0 +1,466 @@
1
+ """Constraint execution, evidence freshness, quorum, and policy decisions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import re
8
+ import time
9
+ import uuid
10
+ from concurrent.futures import ThreadPoolExecutor
11
+ from pathlib import Path
12
+ from typing import Any, cast
13
+
14
+ from constraintloop.config import contract_digest
15
+ from constraintloop.digest import (
16
+ changed_files,
17
+ constraint_input_digest,
18
+ git_diff,
19
+ is_disclosable_path,
20
+ matching_files,
21
+ redact_text,
22
+ )
23
+ from constraintloop.environment import load_project_environment
24
+ from constraintloop.evaluators import EvaluatorError, build_evaluator
25
+ from constraintloop.models import (
26
+ ArtifactConstraint,
27
+ CommandConstraint,
28
+ ConstraintResult,
29
+ Contract,
30
+ EvaluationBundle,
31
+ EvaluatorCallMetadata,
32
+ EvidenceRecord,
33
+ MetricConstraint,
34
+ Phase,
35
+ RubricConstraint,
36
+ Verdict,
37
+ )
38
+ from constraintloop.runners import (
39
+ run_artifact_constraint,
40
+ run_command_constraint,
41
+ run_metric_constraint,
42
+ )
43
+ from constraintloop.state import load_cached_result, save_cached_result, waiver_reason
44
+
45
+
46
+ class ConstraintEngine:
47
+ """Run a strict contract against one project snapshot."""
48
+
49
+ def __init__(
50
+ self,
51
+ project_root: Path,
52
+ contract: Contract,
53
+ *,
54
+ use_cache: bool = True,
55
+ allow_waivers: bool = True,
56
+ goal: str | None = None,
57
+ agent_adapter: str | None = None,
58
+ refresh_pending: bool = False,
59
+ ):
60
+ self.project_root = project_root.resolve()
61
+ self.contract = contract
62
+ self.use_cache = use_cache
63
+ self.allow_waivers = allow_waivers
64
+ self.goal = goal
65
+ self.agent_adapter = agent_adapter
66
+ self.refresh_pending = refresh_pending
67
+ self.contract_digest = contract_digest(contract)
68
+
69
+ def run(self, phase: Phase) -> EvidenceRecord:
70
+ """Run applicable constraints in dependency order."""
71
+ started_at = time.time()
72
+ results: dict[str, ConstraintResult] = {}
73
+ pending = {
74
+ constraint_id
75
+ for constraint_id, spec in self.contract.constraints.items()
76
+ if spec.enabled and phase in spec.phases
77
+ }
78
+
79
+ while pending:
80
+ ready = sorted(
81
+ constraint_id
82
+ for constraint_id in pending
83
+ if all(
84
+ dependency not in pending
85
+ for dependency in self.contract.constraints[constraint_id].needs
86
+ )
87
+ )
88
+ if not ready: # Contract validation should make this unreachable.
89
+ raise RuntimeError("No runnable constraints remain")
90
+ runnable: list[
91
+ tuple[
92
+ str,
93
+ CommandConstraint | MetricConstraint | ArtifactConstraint | RubricConstraint,
94
+ str,
95
+ ]
96
+ ] = []
97
+ for constraint_id in ready:
98
+ spec = self.contract.constraints[constraint_id]
99
+ pending_dependencies = [
100
+ dependency
101
+ for dependency in spec.needs
102
+ if dependency in results and results[dependency].verdict == Verdict.PENDING
103
+ ]
104
+ unavailable = [
105
+ dependency
106
+ for dependency in spec.needs
107
+ if dependency not in results
108
+ or results[dependency].verdict
109
+ not in {
110
+ Verdict.PASS,
111
+ Verdict.WAIVED,
112
+ }
113
+ ]
114
+ digest = constraint_input_digest(
115
+ self.project_root,
116
+ constraint_id,
117
+ spec,
118
+ contract_digest=self.contract_digest,
119
+ )
120
+ if pending_dependencies:
121
+ result = ConstraintResult(
122
+ constraint_id=constraint_id,
123
+ kind=spec.kind,
124
+ verdict=Verdict.PENDING,
125
+ enforcement=spec.enforcement,
126
+ input_digest=digest,
127
+ message=f"Dependencies are pending: {', '.join(pending_dependencies)}",
128
+ )
129
+ elif unavailable:
130
+ result = ConstraintResult(
131
+ constraint_id=constraint_id,
132
+ kind=spec.kind,
133
+ verdict=Verdict.ERROR,
134
+ enforcement=spec.enforcement,
135
+ input_digest=digest,
136
+ message=f"Dependencies did not pass: {', '.join(unavailable)}",
137
+ )
138
+ else:
139
+ runnable.append((constraint_id, spec, digest))
140
+ continue
141
+ results[constraint_id] = result
142
+
143
+ deterministic = [item for item in runnable if not isinstance(item[1], RubricConstraint)]
144
+ with ThreadPoolExecutor(
145
+ max_workers=min(self.contract.settings.concurrency, max(1, len(deterministic)))
146
+ ) as executor:
147
+ futures = {
148
+ constraint_id: executor.submit(
149
+ self._run_one,
150
+ constraint_id,
151
+ spec,
152
+ digest,
153
+ phase,
154
+ [results[key] for key in self.contract.constraints if key in results],
155
+ )
156
+ for constraint_id, spec, digest in deterministic
157
+ }
158
+ for constraint_id, _, _ in deterministic:
159
+ results[constraint_id] = futures[constraint_id].result()
160
+
161
+ for constraint_id, spec, digest in runnable:
162
+ if isinstance(spec, RubricConstraint):
163
+ results[constraint_id] = self._run_one(
164
+ constraint_id,
165
+ spec,
166
+ digest,
167
+ phase,
168
+ [results[key] for key in self.contract.constraints if key in results],
169
+ )
170
+ pending.difference_update(ready)
171
+
172
+ record = EvidenceRecord(
173
+ run_id=str(uuid.uuid4()),
174
+ project_root=str(self.project_root),
175
+ contract_digest=self.contract_digest,
176
+ phase=phase,
177
+ started_at=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(started_at)),
178
+ results=[results[key] for key in self.contract.constraints if key in results],
179
+ )
180
+ return record
181
+
182
+ def _run_one(
183
+ self,
184
+ constraint_id: str,
185
+ spec: CommandConstraint | MetricConstraint | ArtifactConstraint | RubricConstraint,
186
+ digest: str,
187
+ phase: Phase,
188
+ prior_results: list[ConstraintResult],
189
+ ) -> ConstraintResult:
190
+ bundle = (
191
+ self._build_bundle(constraint_id, spec, prior_results)
192
+ if isinstance(spec, RubricConstraint)
193
+ else None
194
+ )
195
+ cache_digest = _rubric_cache_digest(digest, bundle) if bundle is not None else digest
196
+ if self.use_cache:
197
+ cached = load_cached_result(self.project_root, constraint_id, cache_digest)
198
+ if (
199
+ cached is not None
200
+ and cached.enforcement == spec.enforcement
201
+ and not (self.refresh_pending and cached.verdict == Verdict.PENDING)
202
+ ):
203
+ if (
204
+ self.allow_waivers
205
+ and phase != Phase.CI
206
+ and not isinstance(spec, RubricConstraint)
207
+ ):
208
+ reason = waiver_reason(self.project_root, cached, self.contract_digest)
209
+ if reason:
210
+ return cached.model_copy(
211
+ update={
212
+ "verdict": Verdict.WAIVED,
213
+ "message": f"Locally waived by a human: {reason}",
214
+ }
215
+ )
216
+ return cached
217
+
218
+ if isinstance(spec, CommandConstraint):
219
+ result = run_command_constraint(
220
+ self.project_root,
221
+ constraint_id,
222
+ spec,
223
+ digest,
224
+ self.contract.settings.evidence_output_limit,
225
+ )
226
+ elif isinstance(spec, MetricConstraint):
227
+ result = run_metric_constraint(
228
+ self.project_root,
229
+ constraint_id,
230
+ spec,
231
+ digest,
232
+ self.contract.settings.evidence_output_limit,
233
+ )
234
+ elif isinstance(spec, ArtifactConstraint):
235
+ result = run_artifact_constraint(self.project_root, constraint_id, spec, digest)
236
+ else:
237
+ assert bundle is not None
238
+ result = self._run_rubric(constraint_id, spec, digest, bundle)
239
+
240
+ if self.use_cache:
241
+ save_cached_result(self.project_root, result, cache_digest=cache_digest)
242
+ return result
243
+
244
+ def _run_rubric(
245
+ self,
246
+ constraint_id: str,
247
+ spec: RubricConstraint,
248
+ digest: str,
249
+ bundle: EvaluationBundle,
250
+ ) -> ConstraintResult:
251
+ started = time.monotonic()
252
+ try:
253
+ environment = load_project_environment(self.project_root)
254
+ except ValueError as exc:
255
+ return ConstraintResult(
256
+ constraint_id=constraint_id,
257
+ kind=spec.kind,
258
+ verdict=Verdict.UNCERTAIN,
259
+ enforcement=spec.enforcement,
260
+ input_digest=digest,
261
+ message=f"Could not load evaluator environment: {exc}",
262
+ duration_ms=(time.monotonic() - started) * 1000,
263
+ )
264
+ if self.agent_adapter:
265
+ environment["CONSTRAINTLOOP_CALLER_ADAPTER"] = self.agent_adapter
266
+ evaluator = build_evaluator(
267
+ self.contract.evaluators[spec.evaluator],
268
+ cwd=self.project_root,
269
+ environment=environment,
270
+ )
271
+ verdicts = []
272
+ errors: list[str] = []
273
+ evaluator_calls: list[EvaluatorCallMetadata] = []
274
+ for _ in range(spec.runs):
275
+ try:
276
+ verdicts.append(evaluator.evaluate(bundle))
277
+ except EvaluatorError as exc:
278
+ errors.append(str(exc))
279
+ metadata = getattr(evaluator, "last_metadata", None)
280
+ if isinstance(metadata, EvaluatorCallMetadata):
281
+ evaluator_calls.append(metadata)
282
+
283
+ passes = sum(verdict.verdict == "pass" for verdict in verdicts)
284
+ fails = sum(verdict.verdict == "fail" for verdict in verdicts)
285
+ quorum = spec.pass_quorum or 1
286
+ findings = [finding for verdict in verdicts for finding in verdict.findings]
287
+ rationales = [verdict.rationale for verdict in verdicts]
288
+ if passes >= quorum:
289
+ verdict = Verdict.PASS
290
+ message = f"Rubric passed quorum ({passes}/{spec.runs}; required {quorum})"
291
+ elif errors or any(item.verdict == "uncertain" for item in verdicts):
292
+ verdict = Verdict.UNCERTAIN
293
+ message = f"Rubric did not reach a reliable quorum ({passes} pass, {fails} fail)"
294
+ else:
295
+ verdict = Verdict.FAIL
296
+ message = f"Rubric failed quorum ({passes}/{spec.runs}; required {quorum})"
297
+ details = rationales + errors
298
+ return ConstraintResult(
299
+ constraint_id=constraint_id,
300
+ kind=spec.kind,
301
+ verdict=verdict,
302
+ enforcement=spec.enforcement,
303
+ input_digest=digest,
304
+ message=message,
305
+ duration_ms=(time.monotonic() - started) * 1000,
306
+ output_tail="\n\n".join(details)[-self.contract.settings.evidence_output_limit :]
307
+ or None,
308
+ findings=findings,
309
+ evaluator_calls=evaluator_calls,
310
+ )
311
+
312
+ def _build_bundle(
313
+ self,
314
+ constraint_id: str,
315
+ spec: RubricConstraint,
316
+ deterministic_results: list[ConstraintResult],
317
+ ) -> EvaluationBundle:
318
+ limit = self.contract.settings.evaluation_bundle_limit
319
+ diff = git_diff(self.project_root, patterns=spec.include, limit=limit // 4)
320
+ used = len(diff.encode())
321
+ files: dict[str, str] = {}
322
+ omitted: list[str] = []
323
+ query = f"{constraint_id} {spec.rubric} {self.goal or ''}".lower()
324
+ query_tokens = set(re.findall(r"[a-z0-9]{3,}", query))
325
+ changed = set(changed_files(self.project_root))
326
+
327
+ def priority(path: Path) -> tuple[int, int, int, int, str]:
328
+ relative = path.relative_to(self.project_root).as_posix()
329
+ path_tokens = set(re.findall(r"[a-z0-9]{3,}", relative.lower()))
330
+ relevance = len(query_tokens & path_tokens)
331
+ source = int(relative.startswith(("src/", "lib/", "app/")))
332
+ try:
333
+ modified = path.stat().st_mtime_ns
334
+ except OSError:
335
+ modified = 0
336
+ return (-relevance, -int(relative in changed), -source, -modified, relative)
337
+
338
+ matched = matching_files(self.project_root, spec.include)
339
+ for path in matched:
340
+ relative = path.relative_to(self.project_root).as_posix()
341
+ if not is_disclosable_path(relative):
342
+ omitted.append(relative)
343
+ candidates = sorted(
344
+ (
345
+ path
346
+ for path in matched
347
+ if is_disclosable_path(path.relative_to(self.project_root).as_posix())
348
+ ),
349
+ key=priority,
350
+ )
351
+ for path in candidates:
352
+ relative = path.relative_to(self.project_root).as_posix()
353
+ try:
354
+ content = path.read_text(encoding="utf-8", errors="replace")
355
+ except OSError:
356
+ omitted.append(relative)
357
+ continue
358
+ cost = len(relative.encode()) + len(content.encode())
359
+ if used + cost > limit:
360
+ omitted.append(relative)
361
+ continue
362
+ files[relative] = redact_text(content)
363
+ used += cost
364
+ deterministic = [
365
+ cast(dict[str, Any], _redact_value(result.model_dump(mode="json")))
366
+ for result in deterministic_results
367
+ ]
368
+ bundle = EvaluationBundle(
369
+ constraint_id=constraint_id,
370
+ rubric=spec.rubric,
371
+ goal=self.goal,
372
+ diff=diff,
373
+ deterministic_results=deterministic,
374
+ files=files,
375
+ omitted_files=omitted,
376
+ )
377
+ while len(bundle.model_dump_json().encode()) > limit and files:
378
+ relative = next(reversed(files))
379
+ files.pop(relative)
380
+ omitted.append(relative)
381
+ bundle = bundle.model_copy(
382
+ update={"files": dict(files), "omitted_files": list(omitted)}
383
+ )
384
+ if len(bundle.model_dump_json().encode()) > limit and bundle.diff:
385
+ overflow = len(bundle.model_dump_json().encode()) - limit
386
+ keep = max(0, len(bundle.diff.encode()) - overflow - 32)
387
+ bundle = bundle.model_copy(
388
+ update={
389
+ "diff": bundle.diff.encode()[:keep].decode("utf-8", errors="ignore")
390
+ + "\n[diff truncated]"
391
+ }
392
+ )
393
+ if len(bundle.model_dump_json().encode()) > limit:
394
+ compact = []
395
+ for item in deterministic:
396
+ compact.append(
397
+ {
398
+ key: value
399
+ for key, value in item.items()
400
+ if key
401
+ in {
402
+ "constraint_id",
403
+ "kind",
404
+ "verdict",
405
+ "enforcement",
406
+ "message",
407
+ "cached",
408
+ }
409
+ }
410
+ )
411
+ bundle = bundle.model_copy(update={"deterministic_results": compact})
412
+ return bundle
413
+
414
+
415
+ def blocking_results(record: EvidenceRecord) -> list[ConstraintResult]:
416
+ return [result for result in record.results if result.blocks]
417
+
418
+
419
+ def _redact_value(value: object) -> object:
420
+ if isinstance(value, str):
421
+ return redact_text(value)
422
+ if isinstance(value, list):
423
+ return [_redact_value(item) for item in value]
424
+ if isinstance(value, dict):
425
+ return {key: _redact_value(item) for key, item in value.items()}
426
+ return value
427
+
428
+
429
+ def _rubric_cache_digest(base_digest: str, bundle: EvaluationBundle) -> str:
430
+ payload = bundle.model_dump(mode="json")
431
+ for result in payload["deterministic_results"]:
432
+ for volatile in ("cached", "duration_ms", "evaluator_calls"):
433
+ result.pop(volatile, None)
434
+ digest = hashlib.sha256()
435
+ digest.update(base_digest.encode())
436
+ digest.update(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode())
437
+ return digest.hexdigest()
438
+
439
+
440
+ def format_summary(record: EvidenceRecord, *, include_output: bool = False) -> str:
441
+ has_advisories = record.passed and any(
442
+ result.verdict
443
+ not in {
444
+ Verdict.PASS,
445
+ Verdict.SKIPPED,
446
+ Verdict.WAIVED,
447
+ }
448
+ for result in record.results
449
+ )
450
+ outcome = "PASS WITH ADVISORIES" if has_advisories else "PASS" if record.passed else "BLOCKED"
451
+ lines = [f"ConstraintLoop {record.phase.value}: {outcome} ({len(record.results)} constraints)"]
452
+ icons = {
453
+ Verdict.PASS: "PASS",
454
+ Verdict.PENDING: "PENDING",
455
+ Verdict.FAIL: "FAIL",
456
+ Verdict.ERROR: "ERROR",
457
+ Verdict.SKIPPED: "SKIP",
458
+ Verdict.UNCERTAIN: "UNCERTAIN",
459
+ Verdict.WAIVED: "WAIVED",
460
+ }
461
+ for result in record.results:
462
+ cache = " [cached]" if result.cached else ""
463
+ lines.append(f"- {icons[result.verdict]} {result.constraint_id}{cache}: {result.message}")
464
+ if include_output and result.output_tail and result.verdict != Verdict.PASS:
465
+ lines.append(result.output_tail)
466
+ return "\n".join(lines)