agentflow-proof 1.0.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,120 @@
1
+ """Schema-version parsing and compatibility checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from typing import List, Optional
8
+
9
+ from .contracts import POLICY_EXACT, POLICY_NONE, POLICY_SAME_MAJOR
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class SchemaVersion:
14
+ major: int
15
+ minor: int
16
+ patch: int
17
+
18
+
19
+ # Strict dotted triple: no whitespace, signs, leading zeros, or pre-release
20
+ # suffixes (int() would silently accept " 3"; "01" would alias "1").
21
+ _SCHEMA_VERSION_RE = re.compile(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)")
22
+
23
+
24
+ def parse_schema_version(value: str) -> SchemaVersion:
25
+ match = _SCHEMA_VERSION_RE.fullmatch(value)
26
+ if match is None:
27
+ raise ValueError("schema_version must be MAJOR.MINOR.PATCH")
28
+ return SchemaVersion(*(int(part) for part in match.groups()))
29
+
30
+
31
+ def is_schema_version_compatible(actual: str, supported: str) -> bool:
32
+ try:
33
+ actual_version = parse_schema_version(actual)
34
+ supported_version = parse_schema_version(supported)
35
+ except ValueError:
36
+ return False
37
+ return (
38
+ actual_version.major == supported_version.major
39
+ and actual_version.minor <= supported_version.minor
40
+ )
41
+
42
+
43
+ def validate_schema_version(
44
+ actual: object, supported: str, artifact: str, policy: Optional[str] = None
45
+ ) -> List[str]:
46
+ if policy is None:
47
+ from .contracts import (
48
+ ARTIFACT_COMPATIBILITY_POLICIES,
49
+ DEFAULT_COMPATIBILITY_POLICY,
50
+ )
51
+
52
+ policy = ARTIFACT_COMPATIBILITY_POLICIES.get(
53
+ artifact, DEFAULT_COMPATIBILITY_POLICY
54
+ )
55
+ return validate_schema_version_policy(actual, supported, artifact, policy)
56
+
57
+
58
+ def validate_schema_version_policy(
59
+ actual: object, supported: str, artifact: str, policy: str
60
+ ) -> List[str]:
61
+ if policy == POLICY_NONE:
62
+ return []
63
+ if policy not in {POLICY_EXACT, POLICY_SAME_MAJOR}:
64
+ raise ValueError(f"unknown schema compatibility policy: {policy}")
65
+ if not isinstance(actual, str):
66
+ return [f"{artifact} schema_version must be MAJOR.MINOR.PATCH"]
67
+ try:
68
+ parse_schema_version(actual)
69
+ except ValueError:
70
+ return [f"{artifact} schema_version must be MAJOR.MINOR.PATCH"]
71
+ try:
72
+ parse_schema_version(supported)
73
+ except ValueError as exc:
74
+ # A malformed *supported* version is a broken constant (developer
75
+ # error), not bad user data; never blame the artifact for it.
76
+ raise ValueError(
77
+ f"invalid supported schema_version for {artifact}: {supported!r}"
78
+ ) from exc
79
+ compatible = (
80
+ actual == supported
81
+ if policy == POLICY_EXACT
82
+ else is_schema_version_compatible(actual, supported)
83
+ )
84
+ if not compatible:
85
+ return [
86
+ f"{artifact} schema_version {actual} is incompatible with supported {supported}"
87
+ ]
88
+ return []
89
+
90
+
91
+ def validate_historical_proof_schema_version(
92
+ actual: object, supported: str
93
+ ) -> List[str]:
94
+ """Validate the bounded verify-proof historical promise."""
95
+ if isinstance(actual, str):
96
+ try:
97
+ actual_version = parse_schema_version(actual)
98
+ except ValueError:
99
+ return ["proof-pack schema_version must be MAJOR.MINOR.PATCH"]
100
+ else:
101
+ # supported comes from a constant; a parse failure there is a
102
+ # developer error and must not be blamed on the proof.
103
+ supported_version = parse_schema_version(supported)
104
+ if (
105
+ supported_version.major == 1
106
+ and actual_version.major == 0
107
+ and (actual_version.minor, actual_version.patch) >= (4, 0)
108
+ ):
109
+ return []
110
+ if (actual_version.major, actual_version.minor) > (
111
+ supported_version.major,
112
+ supported_version.minor,
113
+ ):
114
+ return [
115
+ f"proof-pack schema_version {actual} is a newer schema than "
116
+ f"supported {supported}; upgrade Agentflow"
117
+ ]
118
+ return validate_schema_version_policy(
119
+ actual, supported, "proof-pack", POLICY_SAME_MAJOR
120
+ )
agentflow/viewer.py ADDED
@@ -0,0 +1,391 @@
1
+ """Static HTML proof viewer (#18).
2
+
3
+ Renders a self-contained, dependency-free report from ``.agent/proof-pack.json``
4
+ and related ledgers. The report is a review aid only; ``verify-proof`` remains
5
+ the authoritative check. The renderer emits no ``<script>`` tags and no
6
+ external references, and every interpolated value is HTML-escaped.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import html
12
+ import os
13
+ from pathlib import Path
14
+ from urllib.parse import urlsplit
15
+ from typing import Any, Dict, List, Optional
16
+
17
+ from .artifacts import read_jsonl, try_read_json
18
+ from .contracts import EXECUTION_ARTIFACT_PATHS
19
+ from .execution import read_step_state
20
+
21
+
22
+ def _try_read_jsonl(path: Path, warnings: List[str]) -> List[Dict[str, Any]]:
23
+ try:
24
+ return read_jsonl(path)
25
+ except (OSError, ValueError):
26
+ warnings.append(f"{path.name} ledger unreadable; section omitted")
27
+ return []
28
+
29
+
30
+ def _try_read_json(path: Path) -> tuple[Optional[Dict[str, Any]], Optional[str]]:
31
+ try:
32
+ return try_read_json(path)
33
+ except OSError as exc:
34
+ return None, str(exc)
35
+
36
+
37
+ def _receipt_href(root: Path, output_dir: Path, relative_path: Any) -> Optional[str]:
38
+ if not isinstance(relative_path, str) or not relative_path:
39
+ return None
40
+ root = root.resolve()
41
+ target = (root / relative_path).resolve()
42
+ if not target.is_relative_to(root):
43
+ return None
44
+ if not target.exists():
45
+ return None
46
+ href = os.path.relpath(target, output_dir.resolve()).replace(os.sep, "/")
47
+ if urlsplit(href).scheme:
48
+ return None
49
+ return href
50
+
51
+
52
+ def _list(value: Any) -> List[Any]:
53
+ return value if isinstance(value, list) else []
54
+
55
+
56
+ def _steps(plan: Dict[str, Any]) -> List[Dict[str, Any]]:
57
+ return [step for step in _list(plan.get("steps")) if isinstance(step, dict)]
58
+
59
+
60
+ def collect_view_model(root: Path, proof_path: Path, output_path: Path) -> Dict[str, Any]:
61
+ """Read proof + ledgers into a render model. Only the proof is required."""
62
+ if not proof_path.exists():
63
+ raise ValueError(
64
+ f"proof metadata missing: {proof_path}; run `agentflow build-proof` first"
65
+ )
66
+ proof, proof_error = _try_read_json(proof_path)
67
+ if proof is None:
68
+ raise ValueError(f"proof metadata unreadable: {proof_error}; run `agentflow build-proof`")
69
+
70
+ warnings: List[str] = []
71
+ plan: Optional[Dict[str, Any]] = None
72
+ plan_path = root / ".agent/plan.lock.json"
73
+ if plan_path.exists():
74
+ plan, plan_error = _try_read_json(plan_path)
75
+ if plan is None:
76
+ warnings.append(f"plan.lock.json unreadable: {plan_error}")
77
+
78
+ output_dir = output_path.parent
79
+ command_rows = _try_read_jsonl(root / EXECUTION_ARTIFACT_PATHS["command-receipts"], warnings)
80
+ command_receipts = []
81
+ for receipt in command_rows:
82
+ if not isinstance(receipt, dict):
83
+ continue
84
+ enriched = dict(receipt)
85
+ enriched["stdout_href"] = _receipt_href(root, output_dir, receipt.get("stdout_path"))
86
+ enriched["stderr_href"] = _receipt_href(root, output_dir, receipt.get("stderr_path"))
87
+ command_receipts.append(enriched)
88
+ file_receipts = [
89
+ receipt
90
+ for receipt in _try_read_jsonl(root / EXECUTION_ARTIFACT_PATHS["file-receipts"], warnings)
91
+ if isinstance(receipt, dict)
92
+ ]
93
+
94
+ step_state: Optional[Dict[str, Any]] = None
95
+ if (root / EXECUTION_ARTIFACT_PATHS["execution-contract"]).exists():
96
+ try:
97
+ step_state = read_step_state(root)
98
+ except (OSError, ValueError):
99
+ warnings.append("step-runs.jsonl ledger unreadable; step status omitted")
100
+
101
+ drift: Optional[Dict[str, Any]] = None
102
+ drift_path = root / ".agent/drift-report.json"
103
+ if drift_path.exists():
104
+ drift, drift_error = _try_read_json(drift_path)
105
+ if drift is None:
106
+ warnings.append(f"drift-report.json unreadable: {drift_error}")
107
+
108
+ return {
109
+ "proof": proof,
110
+ "plan": plan,
111
+ "command_receipts": command_receipts,
112
+ "file_receipts": file_receipts,
113
+ "step_state": step_state,
114
+ "drift": drift,
115
+ "warnings": warnings,
116
+ }
117
+
118
+
119
+ class _Raw(str):
120
+ """Cell content that is already safe HTML (built by this module only)."""
121
+
122
+
123
+ def _esc(value: Any) -> str:
124
+ return html.escape(str(value), quote=True)
125
+
126
+
127
+ def _cell(value: Any) -> str:
128
+ return value if isinstance(value, _Raw) else _esc(value)
129
+
130
+
131
+ def _table(headers: List[str], rows: List[List[Any]], row_classes: Optional[List[str]] = None) -> str:
132
+ head = "".join(f"<th>{_esc(header)}</th>" for header in headers)
133
+ body_rows = []
134
+ for index, row in enumerate(rows):
135
+ cells = "".join(f"<td>{_cell(value)}</td>" for value in row)
136
+ cls = row_classes[index] if row_classes else ""
137
+ attr = f' class="{_esc(cls)}"' if cls else ""
138
+ body_rows.append(f"<tr{attr}>{cells}</tr>")
139
+ return f"<table><thead><tr>{head}</tr></thead><tbody>{''.join(body_rows)}</tbody></table>"
140
+
141
+
142
+ def _items(values: List[Any], empty: str) -> str:
143
+ if not values:
144
+ return f"<p>{_esc(empty)}</p>"
145
+ entries = "".join(f"<li>{_cell(value)}</li>" for value in values)
146
+ return f"<ul>{entries}</ul>"
147
+
148
+
149
+ def _section(title: str, body: str) -> str:
150
+ return f"<section><h2>{_esc(title)}</h2>{body}</section>"
151
+
152
+
153
+ _STATUS_CLASSES = {"passed": "status-passed", "warning": "status-warning", "failed": "status-failed"}
154
+
155
+ _STYLE = """
156
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
157
+ margin: 2rem auto; max-width: 60rem; padding: 0 1rem; color: #1a1a1a; }
158
+ h1 { border-bottom: 2px solid #ccc; padding-bottom: .3rem; }
159
+ .banner { background: #fff8e1; border: 1px solid #e0c341; border-radius: 4px; padding: .6rem .8rem; }
160
+ table { border-collapse: collapse; width: 100%; margin: .5rem 0 1rem; font-size: .9rem; }
161
+ th, td { border: 1px solid #ccc; padding: .3rem .5rem; text-align: left; vertical-align: top; }
162
+ th { background: #f2f2f2; }
163
+ code, .hash { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .85em;
164
+ word-break: break-all; }
165
+ .status-passed { background: #eaf7ea; }
166
+ .status-warning { background: #fff8e1; }
167
+ .status-failed { background: #fdecea; }
168
+ section { margin-bottom: 1.2rem; }
169
+ """
170
+
171
+
172
+ def _step_status_body(plan: Optional[Dict[str, Any]], step_state: Optional[Dict[str, Any]]) -> str:
173
+ if not plan:
174
+ return "<p>Plan not recorded.</p>"
175
+ steps = _steps(plan)
176
+ if not steps:
177
+ return "<p>No plan steps recorded.</p>"
178
+ states = step_state.get("steps", {}) if step_state else {}
179
+ attempts = step_state.get("attempts", {}) if step_state else {}
180
+ rows: List[List[Any]] = []
181
+ for step in steps:
182
+ step_id = step.get("id", "unknown")
183
+ if step_state is None:
184
+ status = "execution ledger not recorded"
185
+ attempt_count: Any = ""
186
+ else:
187
+ status = "completed" if states.get(step_id, {}).get("completed") else "not completed"
188
+ attempt_count = sum(
189
+ 1 for attempt in attempts.values() if attempt.get("step_id") == step_id
190
+ )
191
+ rows.append([step_id, step.get("action", ""), status, attempt_count])
192
+ return _table(["Step", "Action", "Status", "Attempts"], rows)
193
+
194
+
195
+ def _output_link(receipt: Dict[str, Any], stream: str) -> Any:
196
+ href = receipt.get(f"{stream}_href")
197
+ if href:
198
+ return _Raw(f'<a href="{_esc(href)}">{_esc(stream)}</a>')
199
+ path = receipt.get(f"{stream}_path")
200
+ return path or ""
201
+
202
+
203
+ def _command_receipts_body(receipts: List[Dict[str, Any]]) -> str:
204
+ if not receipts:
205
+ return "<p>No command receipts recorded.</p>"
206
+ rows: List[List[Any]] = []
207
+ for receipt in receipts:
208
+ command = receipt.get("command")
209
+ rendered = " ".join(str(part) for part in command) if isinstance(command, list) else str(command or "")
210
+ risk_value = receipt.get("risk")
211
+ risk = risk_value if isinstance(risk_value, dict) else {}
212
+ outputs = [_output_link(receipt, stream) for stream in ("stdout", "stderr")]
213
+ links = _Raw(" ".join(_cell(value) for value in outputs if value != ""))
214
+ rows.append(
215
+ [
216
+ receipt.get("id", ""),
217
+ receipt.get("step_id", ""),
218
+ receipt.get("provenance", ""),
219
+ receipt.get("decision", ""),
220
+ "" if receipt.get("exit_code") is None else receipt.get("exit_code"),
221
+ risk.get("level", ""),
222
+ _Raw(f"<code>{_esc(rendered)}</code>"),
223
+ links,
224
+ ]
225
+ )
226
+ return _table(
227
+ ["Id", "Step", "Provenance", "Decision", "Exit", "Risk", "Command", "Output"], rows
228
+ )
229
+
230
+
231
+ def _file_receipts_body(receipts: List[Dict[str, Any]]) -> str:
232
+ if not receipts:
233
+ return "<p>No file receipts recorded.</p>"
234
+ rows = [
235
+ [receipt.get("id", ""), receipt.get("step_id", ""), receipt.get("path", ""), receipt.get("change_kind", "")]
236
+ for receipt in receipts
237
+ ]
238
+ return _table(["Id", "Step", "Path", "Change"], rows)
239
+
240
+
241
+ def _drift_body(drift: Optional[Dict[str, Any]]) -> str:
242
+ if drift is None:
243
+ return "<p>Drift report not recorded.</p>"
244
+ parts = [f"<p>Status: <strong>{_esc(drift.get('status', 'missing'))}</strong></p>"]
245
+ notes = [note for note in _list(drift.get("notes"))]
246
+ if notes:
247
+ parts.append(_items(notes, ""))
248
+ unmapped = [
249
+ entry.get("path", "")
250
+ for entry in _list(drift.get("unmapped_hunks"))
251
+ if isinstance(entry, dict)
252
+ ]
253
+ if unmapped:
254
+ parts.append("<p>Unmapped hunks:</p>")
255
+ parts.append(_items(unmapped, ""))
256
+ return "".join(parts)
257
+
258
+
259
+ def _checks_body(proof: Dict[str, Any]) -> str:
260
+ checks = [check for check in _list(proof.get("checks")) if isinstance(check, dict)]
261
+ if not checks:
262
+ return "<p>No checks recorded.</p>"
263
+ rows: List[List[Any]] = []
264
+ classes: List[str] = []
265
+ for check in checks:
266
+ status = str(check.get("status", ""))
267
+ message = check.get("message", "")
268
+ if not message and "count" in check:
269
+ message = f"count: {check.get('count')}"
270
+ rows.append([check.get("id", "unknown"), status, message])
271
+ classes.append(_STATUS_CLASSES.get(status, ""))
272
+ return _table(["Check", "Status", "Detail"], rows, classes)
273
+
274
+
275
+ def _residual_warnings_body(proof: Dict[str, Any], collect_warnings: List[str]) -> str:
276
+ warnings = [
277
+ f"{check.get('id', 'unknown')}: {check.get('message', check.get('count', ''))}"
278
+ for check in _list(proof.get("checks"))
279
+ if isinstance(check, dict) and check.get("status") == "warning"
280
+ ]
281
+ warnings.extend(collect_warnings)
282
+ return _items(warnings, "No residual warnings.")
283
+
284
+
285
+ def _finding_location(value: Any) -> str:
286
+ if not isinstance(value, dict):
287
+ return ""
288
+ path = value.get("path")
289
+ if not isinstance(path, str):
290
+ return ""
291
+ line = value.get("line")
292
+ line_end = value.get("line_end")
293
+ if not isinstance(line, int):
294
+ return path
295
+ suffix = f":{line}"
296
+ if isinstance(line_end, int) and line_end != line:
297
+ suffix += f"-{line_end}"
298
+ return path + suffix
299
+
300
+
301
+ def _review_findings_body(proof: Dict[str, Any]) -> str:
302
+ review = proof.get("review") if isinstance(proof.get("review"), dict) else {}
303
+ runs = [run for run in _list(review.get("review_runs")) if isinstance(run, dict)]
304
+ if not runs:
305
+ return "<p>No review runs recorded.</p>"
306
+ rows: List[List[Any]] = []
307
+ for run in runs:
308
+ findings = run.get("findings") if isinstance(run.get("findings"), dict) else {}
309
+ index = [row for row in _list(findings.get("index")) if isinstance(row, dict)]
310
+ if not index:
311
+ rows.append([
312
+ run.get("review_run_id", ""),
313
+ "",
314
+ "",
315
+ "",
316
+ "",
317
+ "",
318
+ "",
319
+ "yes" if run.get("amendment_ready") is True else "no",
320
+ ])
321
+ continue
322
+ for finding in index:
323
+ rows.append([
324
+ run.get("review_run_id", ""),
325
+ finding.get("finding_id", ""),
326
+ finding.get("status", ""),
327
+ finding.get("owning_step", ""),
328
+ finding.get("claim", ""),
329
+ _finding_location(finding.get("location")),
330
+ finding.get("suggested_fix", ""),
331
+ "yes" if run.get("amendment_ready") is True else "no",
332
+ ])
333
+ return _table(
334
+ ["Review", "Finding", "Status", "Owner", "Claim", "Location", "Suggested fix", "Amendment ready"],
335
+ rows,
336
+ )
337
+
338
+
339
+ def _hashes_body(proof: Dict[str, Any]) -> str:
340
+ rows = [
341
+ [item.get("path", ""), _Raw(f'<span class="hash">{_esc(item.get("sha256", ""))}</span>')]
342
+ for item in _list(proof.get("files"))
343
+ if isinstance(item, dict)
344
+ ]
345
+ table = _table(["Path", "SHA-256"], rows) if rows else "<p>No file hashes recorded.</p>"
346
+ core = proof.get("core_sha256", "")
347
+ return table + f'<p>Core checksum: <span class="hash">{_esc(core)}</span></p>'
348
+
349
+
350
+ def render_html(model: Dict[str, Any]) -> str:
351
+ proof: Dict[str, Any] = model.get("proof") or {}
352
+ plan: Optional[Dict[str, Any]] = model.get("plan")
353
+ meta = proof.get("meta", {}) if isinstance(proof.get("meta"), dict) else {}
354
+
355
+ objective = (plan or {}).get("objective") or (
356
+ "Plan not recorded." if plan is None else "No objective recorded."
357
+ )
358
+ gates: List[Any] = list(_list((plan or {}).get("validation_gates")))
359
+ for step in _steps(plan or {}):
360
+ gates.extend(_list(step.get("validation")))
361
+
362
+ sections = [
363
+ _section("Objective", f"<p>{_esc(objective)}</p>"),
364
+ _section("Scope", _items(_list((plan or {}).get("scope")), "No scope recorded.")),
365
+ _section("Step Status", _step_status_body(plan, model.get("step_state"))),
366
+ _section("Validation Gates", _items(gates, "No validation gates recorded.")),
367
+ _section("Command Receipts", _command_receipts_body(model.get("command_receipts", []))),
368
+ _section("File Receipts", _file_receipts_body(model.get("file_receipts", []))),
369
+ _section("Drift Audit", _drift_body(model.get("drift"))),
370
+ _section("Checks", _checks_body(proof)),
371
+ _section("Residual Warnings", _residual_warnings_body(proof, model.get("warnings", []))),
372
+ _section("Review Findings", _review_findings_body(proof)),
373
+ _section("Proof Hashes", _hashes_body(proof)),
374
+ ]
375
+ meta_line = (
376
+ f"Generated {_esc(meta.get('created_at', 'unknown'))} by Agentflow "
377
+ f"{_esc(meta.get('tool_version', 'unknown'))} "
378
+ f"(proof schema {_esc(proof.get('schema_version', 'unknown'))})"
379
+ )
380
+ return (
381
+ "<!doctype html>\n"
382
+ '<html lang="en"><head><meta charset="utf-8">'
383
+ "<title>Agentflow Proof Report</title>"
384
+ f"<style>{_STYLE}</style></head><body>"
385
+ "<h1>Agentflow Proof Report</h1>"
386
+ '<p class="banner">Review aid. <code>agentflow verify-proof</code> is the '
387
+ "authoritative check.</p>"
388
+ f"<p>{meta_line}</p>"
389
+ f"{''.join(sections)}"
390
+ "</body></html>\n"
391
+ )
@@ -0,0 +1,164 @@
1
+ """Workflow contract artifact validation and summaries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any, Dict, List
7
+
8
+ from .artifacts import read_json, write_json
9
+ from .contracts import (
10
+ HUNK_ATTRIBUTION_POLICIES,
11
+ WORKFLOW_CONTRACT_SCHEMA_VERSION,
12
+ WORKFLOW_REVIEW_DEPTHS,
13
+ )
14
+ from .versioning import validate_schema_version
15
+
16
+
17
+ WORKFLOW_CONTRACT_PATH = ".agent/workflow.contract.json"
18
+
19
+ TOP_LEVEL_FIELDS = {
20
+ "schema_version",
21
+ "workflow_pack",
22
+ "workflow_profile",
23
+ "selected_by",
24
+ "selection_reason",
25
+ "required_capabilities",
26
+ "review_depth",
27
+ "validation_policy",
28
+ "proof_policy",
29
+ }
30
+ REQUIRED_FIELDS = {
31
+ "schema_version": str,
32
+ "workflow_pack": str,
33
+ "workflow_profile": str,
34
+ "selected_by": str,
35
+ "selection_reason": str,
36
+ "required_capabilities": list,
37
+ "review_depth": str,
38
+ "validation_policy": dict,
39
+ "proof_policy": dict,
40
+ }
41
+ CAPABILITY_FIELDS = {"id", "required"}
42
+ VALIDATION_POLICY_FIELDS = {"required_gates"}
43
+ PROOF_POLICY_FIELDS = {"hunk_attribution", "require_review_run"}
44
+
45
+
46
+ def _non_empty_string(value: Any) -> bool:
47
+ return isinstance(value, str) and bool(value.strip())
48
+
49
+
50
+ def _non_empty_string_list(value: Any) -> bool:
51
+ return isinstance(value, list) and all(_non_empty_string(item) for item in value)
52
+
53
+
54
+ def validate_workflow_contract(contract: Dict[str, Any]) -> List[str]:
55
+ errors: List[str] = []
56
+ if not isinstance(contract, dict):
57
+ return ["workflow contract must be a JSON object"]
58
+
59
+ for field in sorted(set(contract) - TOP_LEVEL_FIELDS):
60
+ errors.append(f"unknown workflow contract field: {field}")
61
+
62
+ for field, expected_type in REQUIRED_FIELDS.items():
63
+ if field not in contract:
64
+ errors.append(f"missing required workflow contract field: {field}")
65
+ continue
66
+ if not isinstance(contract[field], expected_type):
67
+ errors.append(f"{field} must be {expected_type.__name__}")
68
+
69
+ if errors:
70
+ return errors
71
+
72
+ errors.extend(
73
+ validate_schema_version(
74
+ contract["schema_version"],
75
+ WORKFLOW_CONTRACT_SCHEMA_VERSION,
76
+ "workflow-contract",
77
+ )
78
+ )
79
+
80
+ for field in ("workflow_pack", "workflow_profile", "selected_by", "selection_reason"):
81
+ if not _non_empty_string(contract[field]):
82
+ errors.append(f"{field} must be a non-empty string")
83
+
84
+ for index, capability in enumerate(contract["required_capabilities"], start=1):
85
+ prefix = f"required_capabilities[{index}]"
86
+ if not isinstance(capability, dict):
87
+ errors.append(f"{prefix} must be an object")
88
+ continue
89
+ for field in sorted(set(capability) - CAPABILITY_FIELDS):
90
+ errors.append(f"{prefix} unknown field: {field}")
91
+ if not _non_empty_string(capability.get("id")):
92
+ errors.append(f"{prefix}.id must be a non-empty string")
93
+ if not isinstance(capability.get("required"), bool):
94
+ errors.append(f"{prefix}.required must be boolean")
95
+
96
+ review_depth = contract["review_depth"]
97
+ if review_depth not in WORKFLOW_REVIEW_DEPTHS:
98
+ errors.append(
99
+ "review_depth must be one of: " + ", ".join(WORKFLOW_REVIEW_DEPTHS)
100
+ )
101
+
102
+ validation_policy = contract["validation_policy"]
103
+ for field in sorted(set(validation_policy) - VALIDATION_POLICY_FIELDS):
104
+ errors.append(f"validation_policy unknown field: {field}")
105
+ required_gates = validation_policy.get("required_gates")
106
+ if not _non_empty_string_list(required_gates):
107
+ errors.append("validation_policy.required_gates must contain non-empty strings")
108
+
109
+ proof_policy = contract["proof_policy"]
110
+ for field in sorted(set(proof_policy) - PROOF_POLICY_FIELDS):
111
+ errors.append(f"proof_policy unknown field: {field}")
112
+ hunk_attribution = proof_policy.get("hunk_attribution")
113
+ if hunk_attribution not in HUNK_ATTRIBUTION_POLICIES:
114
+ errors.append(
115
+ "proof_policy.hunk_attribution must be one of: "
116
+ + ", ".join(HUNK_ATTRIBUTION_POLICIES)
117
+ )
118
+ if not isinstance(proof_policy.get("require_review_run"), bool):
119
+ errors.append("proof_policy.require_review_run must be boolean")
120
+
121
+ return errors
122
+
123
+
124
+ def read_workflow_contract(root: Path) -> Dict[str, Any]:
125
+ return read_json(root / WORKFLOW_CONTRACT_PATH)
126
+
127
+
128
+ def write_workflow_contract(root: Path, contract: Dict[str, Any]) -> Path:
129
+ errors = validate_workflow_contract(contract)
130
+ if errors:
131
+ raise ValueError("; ".join(errors))
132
+ path = root / WORKFLOW_CONTRACT_PATH
133
+ write_json(path, contract)
134
+ return path
135
+
136
+
137
+ def workflow_contract_summary(contract: Dict[str, Any]) -> Dict[str, Any]:
138
+ required_capabilities = [
139
+ item["id"]
140
+ for item in contract.get("required_capabilities", [])
141
+ if isinstance(item, dict)
142
+ and item.get("required") is True
143
+ and isinstance(item.get("id"), str)
144
+ ]
145
+ validation_policy = contract.get("validation_policy", {})
146
+ proof_policy = contract.get("proof_policy", {})
147
+ return {
148
+ "workflow_pack": contract.get("workflow_pack"),
149
+ "workflow_profile": contract.get("workflow_profile"),
150
+ "selected_by": contract.get("selected_by"),
151
+ "review_depth": contract.get("review_depth"),
152
+ "required_capabilities": required_capabilities,
153
+ "required_gates": list(validation_policy.get("required_gates", []))
154
+ if isinstance(validation_policy, dict)
155
+ else [],
156
+ "proof_policy": {
157
+ "hunk_attribution": proof_policy.get("hunk_attribution")
158
+ if isinstance(proof_policy, dict)
159
+ else None,
160
+ "require_review_run": proof_policy.get("require_review_run")
161
+ if isinstance(proof_policy, dict)
162
+ else None,
163
+ },
164
+ }